skip to content

Why is copying the incoming `Origin` request header into `Access-Control-Allow-Origin` not a dynamic allow-list?

level: middleimportance: must knowfreq 68%

answer

  1. no comparison happens anywhere
  2. an echo is not a check
  3. every origin, handed its own name
  4. the attacker's page gets the same grant
  5. select from a fixed set, never derive

basics

~20 s

Reflection admits every origin rather than a list of them. The server compares the arriving value with nothing, so an attacker's page receives a grant identical to a partner's, and script on that page reads the response body.

solid answer

~40 s

A dynamic allow-list compares the arriving `Origin` against a fixed set and answers only on a match. Reflection skips the comparison entirely: whatever byte string arrives is echoed into `Access-Control-Allow-Origin`, so the grant looks specific while admitting every origin that asks. A page on a host the attacker controls makes the same cross-origin call, is handed back its own origin, and a conforming browser then releases the response body to that page's script, along with any header named in `Access-Control-Expose-Headers`. If the service also answers `Access-Control-Allow-Credentials: true`, the visitor's ambient cookies are attached first, so what the attacker reads is that user's authenticated data rather than anonymous data. The fix is selection, not echo: compare the whole serialized origin against a fixed set and send no grant header when nothing matches.

code

http · 15 lines
http
GET /v1/sessions HTTP/1.1
Host: api.conf-schedule.example
Origin: https://sponsor-a.example

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://sponsor-a.example

GET /v1/sessions HTTP/1.1
Host: api.conf-schedule.example
Origin: https://attacker.test

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://attacker.test

go deeper

for a junior

Remember the direction of travel: the browser sends Origin, the server answers Access-Control-Allow-Origin, and only the second one is a grant.

for a middle

Explain that reflection contains no comparison, so every caller passes the browser's check with its own value, and say what the attacker's script then reads.

for a senior

Show the production shape: a growing list of embedding sites, one origin per response, and the selection-from-data implementation that keeps the convenience without the grant to everyone.

for a principal

Frame it as ownership of a grant surface. Reflection is what appears when no one owns the allowed set, so the durable fix is where that set lives, not which line of code reads it.

## What a grant is, and who acts on it By default a conforming browser will happily **send** a cross-origin request and then refuse to hand the response to the script that asked for it. `Access-Control-Allow-Origin` is how a server says otherwise: it names the one origin whose script may read *this* response. The browser compares that value against the origin of the document that made the call, and releases the body only on a match. Two consequences run through everything below: - **The server grants; the browser enforces.** A server never blocks a cross-origin read. It either issues a grant or stays silent, and the browser is what turns silence into a failure. - **The grant is per response.** There is no registration step, no session and no memory. Whatever the server writes on this response is the entire decision. ## Reflection is a comparison that never happens The reflex implementation reads the incoming `Origin` request header and writes the same bytes into the `Access-Control-Allow-Origin` response header. It is usually described as a dynamic allow-list, and it is not a list at all — nowhere in it does the arriving value get compared with anything. The test the browser performs (*does this grant name me?*) is therefore guaranteed to pass for every caller, because every caller was handed its own name back. So the set of origins admitted by such a service is not "the partners we onboarded". It is **every origin that asks**, including origins that did not exist when the code was written. ## Why the reflex looks reasonable in the room The usual setting is a widget — say a conference schedule embedded on sponsor sites — where the list of embedding hosts is unknown at build time and grows every week. One response can name only one origin, so choosing per request feels forced on you. It is: the correct version of that choice is **selection from a fixed set**, and reflection is what selection degrades into when nobody owns the set. Each step is defensible on its own; the result is a grant to the whole web. ## What the attacker's page actually obtains 1. They host a page on a domain they registered and get a logged-in user to open it. 2. Script on that page issues an ordinary cross-origin request to the service. 3. The service reflects, so the response names the attacker's own origin. 4. The browser releases the response to that script, which forwards it anywhere. What comes out: - **The response body in full** — whatever that endpoint returns to the caller as the request was actually made. - **Any response header named in `Access-Control-Expose-Headers`**, which widens what script may read beyond the small default set. - **Authenticated data, if the service also answers `Access-Control-Allow-Credentials: true`**, because the browser then attaches the visitor's ambient cookies before the service ever decides anything. - **Nothing extra for a non-browser client.** Enforcement lives in the browser, so a client that never applies the rule was never restricted by it. Reflection does not weaken a server's access control; it weakens the browser's protection of *the user*. ## Three ways to answer, and what each grants | The server does | What it actually grants | Where it is defensible | |---|---|---| | Echoes the arriving `Origin` | Every origin, one at a time | Effectively nowhere, once the response is worth reading | | Answers the wildcard `*` | Every origin, uncredentialed reads only | Data any client could already retrieve anonymously | | Matches a fixed set, answers the matched value | Exactly that set | The normal case | ## Writing the check - Keep the allowed origins as **declared configuration data**, not as a value derived from the request. - Compare the **complete serialized origin** — scheme, host and port together. A test that inspects only part of the value is not a check. - On no match, **send no grant header at all**. The refusal is the absence of the header; script sees a network-level failure with no status to read. - Decide **per resource**, not per service: an endpoint returning a public schedule and one returning attendee records do not deserve the same grant. - Treat adding an origin as a change to reviewed data, with an owner, rather than a one-line patch in whichever service was blocking someone this morning.

  • Once the grant is in place, what can script on the attacker's page actually read?
    The response body in full, as the service returned it for that request, plus the response headers script may read — the small default set, widened by anything the service names in `Access-Control-Expose-Headers`. If the service also answers `Access-Control-Allow-Credentials: true`, the request carried the visitor's ambient cookies, so the body read is that user's own data.
  • Does reflection make the service any weaker against a client that is not a browser?
    No. The grant is enforced by the browser on behalf of the page that made the call; a client that does not implement the rule reads every response regardless of what `Access-Control-Allow-Origin` says. CORS was never server-side access control, so reflection costs nothing there — it costs the browser's protection of a logged-in user.
  • If the list of embedding sites genuinely changes weekly, how do you avoid reflection?
    Keep the set outside the code as data the service loads, and select from it per request — the per-request choice is unavoidable, the missing comparison is not. Adding an origin then becomes a reviewed change to a declared list rather than an edit to request-handling logic, and the unmatched case still answers with no grant header.

It is a guest list that the doorman writes by copying each arriving guest's name onto it as they walk up. Everyone is on the list, and the list looks convincingly specific afterwards.

saying these in an interview costs you the question

  • Reflecting the Origin header back is just a dynamic allow-list.
  • An attacker cannot forge Origin, so echoing it is safe.
  • Nothing leaks unless the service also allows credentials.
  • Reflection is fine on endpoints that only read data.
  • CORS stops non-browser clients from reading the API.