How a Missing Dot in a CORS Regex Let evilourapp.com Read Signed-In Users' Session Data
← Back
September 13, 2026Security9 min read

How a Missing Dot in a CORS Regex Let evilourapp.com Read Signed-In Users' Session Data

Published September 13, 20269 min read

02:47 UTC. A Datadog log monitor pages security on-call: distinct Origin headers hitting /api/v2/me just crossed 40 in a five-minute window. Every one of those requests carries a valid session cookie. Nobody logs into 40 different websites in five minutes.


the setup

account-service serves the logged-in user's profile off a session cookie, no bearer token involved. The cookie, sid, is set with SameSite=None; Secure, because the product has an embeddable dashboard widget that customers drop into their own sites, and the widget needs the session cookie to travel with it cross-origin. Once a cookie is SameSite=None, the browser will attach it to a request from anywhere. The only thing standing between "anywhere" and "actually us" is the server's CORS origin check.

That check had been a simple suffix match on subdomains for two years:

cors.ts, before the regression
function isAllowedOrigin(origin: string): boolean {
    return origin.endsWith('.ourapp.com');
}

It worked for every subdomain the widget ran from, and it rejected everything else, including our own bare apex domain. https://ourapp.com does not end with .ourapp.com, there is no subdomain in front of the dot. Nine days before the page, the growth team filed a bug: the marketing site's embed loader, served straight from the apex, couldn't call /api/v2/widget/config. Every request came back with a CORS error in the browser console.

A fix went out same day, reviewed and merged as a one-line change.

PR #2137, "fix: allow apex domain in CORS check"
- return origin.endsWith('.ourapp.com');
+ return origin.endsWith('ourapp.com');

It closed the ticket. The apex domain matched now. So did anything else that happened to end in the same eleven characters.


the scramble

First theory: credential stuffing, a bot with a leaked password list hammering the login endpoint from a rotating set of proxies. The login endpoint's own logs ruled that out fast: zero failed auth attempts in the window. Every one of those 40-plus requests already had a valid sid cookie. Nobody was trying to log in. They were already logged in, or riding someone else's session.

#security-incidents, 02:53 UTC
on-call: login-service auth attempts flat, no spike, this isn't credential stuffing
on-call: every request to /api/v2/me in the alert window already has a valid sid cookie

Second theory: an XSS hole was letting an attacker read cookies directly out of the DOM on our own pages. We pulled CSP violation reports and Sentry's error volume for the window. Nothing. No injected scripts, no console errors, no CSP blocks. If cookies were leaking, they weren't leaking through our own frontend.

Third theory, the one that cost the most time: a partner integration had gone rogue, one of the handful of customer domains explicitly allowed to embed the widget, now misbehaving or compromised. We checked every partner domain against the Origins in the alert. None of them matched. Whatever was hitting the endpoint wasn't on any list we'd ever approved.


the hunt

With the obvious theories dead, we went straight to the edge logs and looked at what the server was actually sending back on those requests, not just what it was receiving.

edge gateway access log, filtered on /api/v2/me, 02:40-02:50 UTC
origin=https://evilourapp.com status=200 access-control-allow-origin=https://evilourapp.com access-control-allow-credentials=true
origin=https://shareourapp.com status=200 access-control-allow-origin=https://shareourapp.com access-control-allow-credentials=true
origin=https://track.evilourapp.com status=200 access-control-allow-origin=https://track.evilourapp.com access-control-allow-credentials=true
origin=https://ourapp.com status=200 access-control-allow-origin=https://ourapp.com access-control-allow-credentials=true

Every one of those unfamiliar Origins was getting a 200, and the server was reflecting the exact Origin it received back in Access-Control-Allow-Origin, with Allow-Credentials: true right next to it. That combination means the browser hands the response body straight to the requesting page's JavaScript. Whoever controlled evilourapp.com could read whatever /api/v2/me returned for any visitor whose browser still had a live sid cookie.

git blame on cors.ts pointed straight at PR #2137, nine days old, one line changed, reviewed and approved in eleven minutes because the diff looked trivial.

  BEFORE (dot required):
  origin.endsWith('.ourapp.com')
    "https://widget.ourapp.com"  → match (subdomain, intended)
    "https://ourapp.com"         → no match (apex, the reported bug)
    "https://evilourapp.com"     → no match (no dot before "ourapp.com")

  AFTER (dot dropped):
  origin.endsWith('ourapp.com')
    "https://widget.ourapp.com"  → match
    "https://ourapp.com"         → match (the bug the PR fixed)
    "https://evilourapp.com"     → match (the bug the PR introduced)

the find

Root cause: a one-character regression in a suffix check, waved through because the diff looked too small to argue with. No negative test case existed to catch what it actually allowed. The fix correctly matched the apex domain. It also, by the same logic, matched any registrable domain that happened to end in the literal string ourapp.com, dot or no dot. evilourapp.com is a real, purchasable domain. So is shareourapp.com.

This didn't look like a targeted attack, at least not at first. The logs were full of near-miss Origins that didn't match anything, ourapp-com.net, ourapp.company, mixed in with the ones that did. That's the signature of a scanner trying string variations against a list of known SaaS domains until one of them returned a 200 with credentials reflected back.

Because the widget's cookie is SameSite=None, there was no second layer underneath the CORS check to catch this. The Origin allow-list wasn't defense in depth here, it was the entire perimeter.


the fix

The suffix check came out entirely, replaced with an explicit, statically defined set of exact origins. No regex, no endsWith, no pattern that could quietly widen.

cors.ts, after
const ALLOWED_ORIGINS = new Set([
    'https://ourapp.com',
    'https://app.ourapp.com',
    'https://widget.ourapp.com',
    ...APPROVED_PARTNER_ORIGINS, // reviewed and added one at a time, never pattern-matched
]);

function isAllowedOrigin(origin: string): boolean {
    return ALLOWED_ORIGINS.has(origin);
}

We added a unit test file that exists specifically to fail loudly the next time someone tries a suffix or regex shortcut:

cors.test.ts
const KNOWN_BAD_ORIGINS = [
    'https://evilourapp.com',
    'https://ourapp.com.attacker.io',
    'https://notourapp.com',
];

test.each(KNOWN_BAD_ORIGINS)('rejects %s', (origin) => {
    expect(isAllowedOrigin(origin)).toBe(false);
});

Then two response actions that didn't wait for the code fix to deploy: every active sid session for an account that had appeared in the nine-day access log sweep got force-invalidated, and every workspace admin's API keys got rotated, since those keys were visible in the same /api/v2/me payload the leaked cookies exposed.


the aftermath

9 days From the PR merge to the alert firing
3 Attacker-controlled domains matching the broken suffix check
212 Accounts whose session data was fetched cross-origin
34 min From the page to the corrected origin check deployed

No password was cracked and no server was breached. The whole incident lived inside a single line of a CORS check, reviewed by a real engineer, in a PR that fixed a real bug. That's what made it dangerous. A suffix match against a domain name is not the same claim as an explicit list of approved origins. The two look interchangeable right up until someone registers a domain that satisfies one without ever meeting the other.

  • Once a session cookie is SameSite=None, the CORS origin check stops being defense in depth and becomes the actual boundary. Treat any change to it like a change to an access-control list, not a routine bug fix.
  • endsWith() and unanchored suffix regexes against a domain are a security bug waiting for someone to register the right string. An explicit allow-list has no equivalent failure mode, it can only be too short, never accidentally too wide.
  • A one-line diff earns a fast review, not a careless one. The line that removed a leading dot took eleven minutes to approve and nine days to notice.
  • Alert on successful, credentialed responses to Origins outside a known-good set, not just on traffic volume. Volume anomalies catch scrapers. They don't catch a handful of well-placed requests from a domain built to pass your own check.

The unit tests against known-bad origins are what actually close this hole for good. They don't just catch this specific regression coming back, they catch the next engineer who reaches for a pattern match instead of a list, because the tests were written to fail on exactly that shortcut.

Share this
← All Posts9 min read