DEV Community

幻灵末士
幻灵末士

Posted on AI-assisted

How I Found a postMessage Origin Bypass in an OAuth SDK

I spend a lot of time reading other people's code. Not because I enjoy it—though honestly, I kind of do—but because that's where the interesting bugs live. Not the flashy ones that get all the attention on Twitter. The quiet ones. The ones hiding in plain sight, inside a single missing if statement.

This is the story of one of those bugs.

The Backstory

A few weeks ago I was doing a security review of a web3 project. You know the drill: connect wallet, sign in with Google, maybe an NFT drop if you're lucky. The usual.

The project used an OAuth SDK for their "Sign in with Google" flow. Pretty standard stuff. You click a button, a popup opens, you authenticate with Google, the popup closes, and you're logged in. Smooth UX, works great.

Under the hood, this flow relies on postMessage to communicate between the popup window and the main application window. And if you've done any web security work, you know where this is going.

postMessage is one of those APIs that's incredibly useful and incredibly easy to get wrong. The browser happily delivers messages between windows regardless of where they come from. It's up to you, the developer, to check event.origin and make sure you're only accepting messages from places you actually trust.

Spoiler alert: sometimes people forget.

The Hunt

I started by looking at how the SDK handled messages coming back from the OAuth popup. Here's roughly what I found:

const redirectEvent = (event: MessageEvent) => {
  this.createIntermediaryEvent(
    OAuthPopupEventEmit.PopupEvent,
    requestPayload.id
  )(event.data);
};

window.addEventListener('message', redirectEvent);
Enter fullscreen mode Exit fullscreen mode

Take a good look at that. What's missing?

There's no event.origin check. No validation of where the message is coming from. The listener receives a message, grabs event.data, and forwards it along to be processed. It doesn't care if the message came from the legitimate OAuth popup, a malicious page, or the void.

This is the equivalent of answering your front door without looking through the peephole. Sure, it's probably your friend, but you're not even checking.

The Interesting Part

Now, a missing origin check is bad on its own. But I wanted to know: how bad? What can an attacker actually do with this?

The answer depended on how the SDK matches incoming messages to pending OAuth requests. And here's where it got interesting.

The SDK uses a payloadId to correlate popup messages with the original login request. When you initiate an OAuth flow, the SDK generates an ID, sends it along with the popup URL, and waits for a message that references that same ID.

I traced this ID generation back to its source, expecting to find a cryptographically secure random generator. Something with crypto.getRandomValues() or at least a timestamp mixed in. Instead, I found something much simpler:

let id = 0;
export const getPayloadId = () => ++id;
Enter fullscreen mode Exit fullscreen mode

That's it. A module-level counter that increments by one every time it's called. On the web platform, every OAuth request gets the next integer in sequence. If you see request #7 go out, you know the next one will be #8.

Let me walk through how an attacker could chain this together. As soon as the victim's OAuth popup opens, the SDK registers its message listener. At that exact moment, the attacker's page—which the victim has open in another tab—can send a message via window.opener.postMessage(). That message includes a payloadId that the attacker predicted by simply counting the current request number. The listener, with no origin check, passes the message to the SDK's internal logic, which processes it as if it came from the legitimate Google popup. The attacker doesn't need to guess anything else.

That's what turned this from "interesting oddity" into "real exploit."

The Cherry on Top

While digging through this SDK's codebase, I noticed something that made me chuckle. The same company maintained another package in the same SDK family. That package did do the right thing:

if (event.origin !== this.endpoint) return;
Enter fullscreen mode Exit fullscreen mode

They knew about origin validation. They'd done it correctly elsewhere. But in the OAuth extension, it was just... absent. A lapse in consistency between two codebases that should have followed the same pattern.

This is actually pretty common in larger codebases. Different teams, different timelines, different levels of review. Knowledge gets siloed. What one maintainer knows, another doesn't. And sometimes a critical check that exists in iframe-controller.ts never makes it to oauth2/src/index.ts.

The Fix

I reported the issue, and the fix was about as simple as you'd expect. Add the origin check, mirror the pattern that already existed elsewhere in their own codebase:

const redirectEvent = (event: MessageEvent) => {
  if (event.origin !== this.sdk.endpoint) return;
  this.createIntermediaryEvent(
    OAuthPopupEventEmit.PopupEvent,
    requestPayload.id
  )(event.data);
};
Enter fullscreen mode Exit fullscreen mode

One line. That's it. One line separates "vulnerable" from "not vulnerable."

What I Learned (And You Should Too)

Always validate event.origin. I know, you've heard this a thousand times. But I just found a production SDK that forgot, so apparently we need to keep saying it. Never assume a message event comes from where you expect. Always check.

Predictable IDs are dangerous. Sequential IDs aren't just bad for enumeration attacks—they can enable cross-window attacks like this one. Use cryptographically random IDs when correlating async operations.

Consistency matters in security. If one part of your codebase does security right and another doesn't, that's not just an inconsistency. It's a roadmap for attackers. They'll find the weakest link.

Read code. I didn't find this with a scanner. I just opened the source code, traced the message flow, and followed it until something didn't add up. Sometimes the best tool is just a text editor and a willingness to ask "what if?"

Final Thoughts

I can't share specifics about the SDK or the exact details of my report—responsible disclosure means giving the vendor time to patch and publish their own advisory. But the pattern is what matters here. Missing origin validation is one of those bugs that shows up everywhere: SDKs, wallets, analytics tools, chat widgets. If your app uses postMessage anywhere, go check your listeners right now. I'll wait.

Seriously, go check.

The best bugs aren't always the ones with the most complex attack chains. Sometimes they're the ones hiding in the gap between what the code assumes and what the browser actually delivers.


Thanks for reading! If you enjoyed this, I write about web security, code review, and the occasional "how did that even work" bug. Follow along for more.

Top comments (5)

Collapse
 
crdtcto profile image
Kane Lim

发现得好。有趣的是,单是缺少 event.origin 检查本身看起来就是一个相当常见的问题,但 payloadId 的可预测性使得整个漏洞更容易被利用。

我也很赞同检查相邻代码的观点。在同一 SDK 系列的其他地方找到已实现的正确来源验证,这很好地提醒我们,安全审查不仅仅是查找漏洞,还要发现既定安全模式未被一致应用的地方。

对于 postMessage,我通常将来源验证和消息/模式验证视为独立的检查。即使来源可信,我仍然希望在执行操作之前验证消息结构和预期状态。

这很好地说明了为什么追踪完整的数据流通常比仅仅依赖自动化扫描器更有价值。

Collapse
 
thecrazyrabbit profile image
幻灵末士

Appreciate you reading closely. You raise a really good point about payload validation beyond just origin—I think that's often the second-order mistake: even if the source is verified, blindly trusting the message shape is its own risk. I've been thinking about how to formalize that as a checklist item for audit work. Curious—do you usually validate message schema as part of your review, or rely on type safety at the handler layer?

Collapse
 
crdtcto profile image
Kane Lim

Yeah, I usually treat schema validation as a separate security boundary rather than relying on TypeScript alone.

Types help a lot during development, but they disappear at runtime, and postMessage is ultimately untrusted input. So my usual flow is: validate event.origin first, then validate the message shape, expected action/type, correlation ID, and finally the state/context of the pending operation before doing anything sensitive.

I’ve found the state check is especially useful in OAuth-style flows. Even a correctly shaped message from a trusted origin shouldn’t automatically be accepted if there isn’t an active request matching that state.

For an audit checklist, I’d probably make it something like: origin → schema → correlation → state → authorization/action. That catches quite a few issues that a simple event.origin check can miss.

Thread Thread
 
thecrazyrabbit profile image
幻灵末士

This is a really useful framework — especially the state check before authorization part. I've seen cases where origin and schema passed but the operation executed against a stale or completed request, which is essentially a race condition by design. It's interesting you mention 'correlation' as a separate step — I actually think that's the step that fails most often in practice, because developers tend to treat IDs as 'opaque identifiers' without realizing they also need to be unpredictable in these contexts. Do you usually enforce specific randomness constraints at the correlation layer, or rely on the upstream OAuth provider's state parameter for that?

Thread Thread
 
crdtcto profile image
Kane Lim

I usually enforce randomness at the correlation layer rather than assuming the upstream OAuth state is enough.

The provider’s state should definitely be cryptographically random and tied to the authorization transaction, but I still prefer the local correlation ID to have its own security properties. They serve slightly different purposes: state protects the OAuth flow, while the local ID helps bind messages back to the exact pending operation.

For anything security-sensitive, I’d want the correlation value to be generated with crypto.getRandomValues() / crypto.randomUUID() (depending on the protocol and required format), and I’d also make sure it has a short lifetime and becomes invalid after successful consumption.

That last part is important too. Randomness prevents prediction, but single-use state prevents replay. So my mental model is basically: unpredictable ID + correct origin + active state + one-time consumption.

I think that combination catches a lot more than treating correlation IDs as just database-style identifiers.