Chaining OOS XSS to a Loose postMessage on the Main Domain

June 3, 2026


How it started

I was browsing around, testing the integration feature. The flow caught my eyes.

On the main page:

https://app.abc.com/integrations

You click on the integration you want to connect. The main page opens a new window (child) pointing to the third-party integration domain, for example:

https://app-na2.hubspot.com/oauth/abc/xyz

You authorize there. If successful, it appends the authorization code to the URL and redirects back to the main domain:

https://app.abc.com/connect_{integration}.html

The callback page

At that callback file, the code grabs the postMessage and sends it back to the parent:

<body>
  <script>
    const urlParams = new URLSearchParams(window.location.search);
    const state = urlParams.get('state');
    const decodedState = decodeURIComponent(state);
    const providedTargetOrigin = JSON.parse(decodedState).targetOrigin;

    const { origin } = new URL(providedTargetOrigin);
    const validTargetOrigin =
      origin.endsWith('.abc.com') ||
      origin.endsWith('.abc-stg.com') ||
      origin.endsWith('.abc-qa.com') ||
      origin === 'http://localhost:4200';

    if (!validTargetOrigin) {
      const div = document.createElement('h2');
      div.textContent = `Target origin is invalid!`;
      document.querySelector('body').appendChild(div);
    } else {
      opener.postMessage(
        {
          url: window.location.href,
          state: JSON.parse(decodedState).state,
        },
        providedTargetOrigin,
      );
    }
  </script>
</body>

Nothing obviously wrong at first glance. It validates the origin. It uses endsWith(). It rejects anything that doesn't match.

But look closer.

The too-broad origin check

The validation accepts any origin ending with:

  • .abc.com
  • .abc-stg.com
  • .abc-qa.com
  • http://localhost:4200

That means the main domain trusts staging and QA subdomains. And those are out of scope for the bounty program — but they are still trusted by production.

That's the first half of the chain.

The XSS on staging

While poking around the staging environment, I found this:

https://app.abc-stg.com/abcxyz/popup.html?url=javascript:alert(1)

The file looks roughly like this:

const urlParams = new URLSearchParams(window.location.search);
const url = urlParams.get('url');
...
window.location.href = url;
...

The url parameter is passed straight into a navigation sink, no sanitization. javascript: URIs execute. No click needed — the redirect fires on load.

Staging only. Out of scope. On its own, not worth a report.

But it doesn't have to stand alone.

The chain

Here's the fun part.

The OAuth callback on the main domain sends the authorization code back via postMessage. The origin check accepts *.abc-stg.com. The staging domain has an XSS.

Put those together:

  1. Attacker crafts the OAuth flow in a popup.
  2. Victim clicks on that crafted link and lands on the staging XSS payload.
  3. The payload registers a message listener.
  4. The OAuth callback on the main domain fires postMessage back to the opener — but the opener is the staging XSS page, and the origin check happily accepts it.
  5. The authorization code lands in the attacker's listener.

Once the attacker has the authorization code, they can:

  • Exchange it for a token
  • Connect the victim's integration to the attacker's workspace
  • Read and write data through the integration

In this case, HubSpot CRM data — contacts, companies, deals, tickets, conversations. All of it.

What's actually broken

The XSS on staging is the pivot. The real bug is the origin check on the main domain.

Development origins like .abc-stg.com and http://localhost:4200 shouldn't be trusted in a production callback. They widen the trust boundary far beyond what's intended.

Fix the origin check — exact match, production domains only — and the chain falls apart.