Why does comparing the Origin request header against an allowlist with a prefix test let an attacker's host pass?
answer
- the value has no path and no slash
- hosts are owned right to left
- starts-with admits a registered look-alike
- set membership, never pattern matching
- default port omitted, non-default required
basics
~10 sAn Origin value is a serialized scheme, host and optional port, and an attacker can register a host that starts with yours, such as booking.example.com.evil.test. Only byte-exact comparison against a fixed allowlist refuses it.
solid answer
~50 sThe field's value is serialized as `scheme "://" host [ ":" port ]` — nothing after the authority. A prefix test asks whether the received string *starts with* your origin, and `https://booking.example.com.evil.test` starts with `https://booking.example.com`, so an attacker registers that name and walks through. A suffix test is no better: checking that the value ends with `example.com` admits `https://notexample.com`. A substring test admits both. The correct comparison is byte-exact against a small fixed set of serialized origins, which also means the entries must be written the way a user agent serializes them: no trailing slash, no path, and no `:443` on an `https` entry, because the default port is omitted. Write one of those and the entry silently never matches — and the usual wrong fix is to loosen the comparison to a prefix test.
code
http · 6 linesPOST /api/bookings/91/cancel HTTP/1.1
Host: booking.example.com
Origin: https://booking.example.com.evil.test
Cookie: session=8f2c1b...
Content-Type: application/x-www-form-urlencoded
Content-Length: 0go deeper
Remember that the header value is scheme, host and optional port with nothing after it, and that the server compares it to a written-out list rather than searching inside it.
Explain why starts-with admits a registered look-alike host and ends-with admits an unrelated one, and name the entry-spelling mistakes that make an exact comparison refuse your own traffic.
Describe the repair sequence you have watched go wrong: a mis-spelled entry, an urgent loosening to a prefix test, and a hole that legitimate traffic never reveals.
Argue for how wide the set of origins allowed to initiate unsafe requests should be across an estate, since every entry converts another host's compromise into yours.
## The shape of the value decides the shape of the comparison An `Origin` request header field carries `scheme "://" host [ ":" port ]` and stops there. No path, no query, no fragment, no trailing slash, and the default port for the scheme left off — `https://booking.example.com`, never `https://booking.example.com:443/` and never `https://booking.example.com/bookings`. Because the value is short and closed, the set of acceptable values is a **small, finite list of strings you already know**. That is an unusually comfortable position for a security decision, and it is exactly the position a fuzzy comparison throws away. ## Three ways the comparison is written wrong 1. **Prefix.** "Does the received value start with `https://booking.example.com`?" A host name is read left to right, but ownership is decided right to left. `https://booking.example.com.evil.test` starts with your origin and is a host the attacker registers and controls. It passes. 2. **Suffix.** "Does the received value end with `example.com`?" `https://notexample.com` ends with it. So does `https://evil-example.com`. Both pass. 3. **Substring.** "Does the received value contain `booking.example.com`?" Admits everything the first two do, plus `https://evil.test` if the attacker can get the string into any position the test inspects. Each of these is written by someone who wanted the check to keep working across a staging host, a port change or a new subdomain, and each converts a closed set into an open one. | Comparison | Passes `https://booking.example.com.evil.test` | Passes `https://notexample.com` | |---|---|---| | starts-with `https://booking.example.com` | yes | no | | ends-with `example.com` | no | yes | | contains `booking.example.com` | yes | no | | byte-exact against the allowlist | no | no | ## Writing the allowlist entries correctly Exact comparison only helps if the entries are spelled the way the user agent serializes an origin. Four ways an entry silently never matches anything: - **A trailing slash.** `https://booking.example.com/` is not a serialized origin, so it can never equal a received value. - **A path.** `https://booking.example.com/app` has the same problem, one level worse. - **An explicit default port.** `https://booking.example.com:443` never matches, because the user agent omits the default port. A non-default port, such as `https://booking.example.com:8443`, is part of the origin and *must* appear in the entry. - **The wrong scheme.** `http://booking.example.com` and `https://booking.example.com` are different origins. An entry written with the wrong one refuses your own client. The host is compared in its lower-case form; the scheme likewise. Everything else is a byte comparison against the entry, and the whole check should be a set membership test rather than any kind of pattern match. ## The failure mode this produces in practice The dangerous sequence is not the attack — it is the repair. Someone writes the allowlist entry with a trailing slash. The scripted booking client's own unsafe requests start being refused, on every environment, immediately. Under pressure the check is loosened to a prefix test so that "anything under our domain works", the refusals stop, and the hole is now open and invisible: legitimate traffic passes, the logs are quiet, and nothing surfaces the change until someone registers a look-alike host. So treat a check that suddenly refuses your own traffic as a **spelling bug in the allowlist**, never as evidence that the comparison is too strict. ## What this check is and is not - It is a provenance test: was this unsafe request initiated by a document served from an origin you recognise? - It is not a grant to a cross-origin caller. Deciding whether another origin may *read* a response is a separate mechanism with its own response headers, and reusing one decision for the other is how an allowlist ends up far wider than the forgery check needs. - Keep the list of origins that may *initiate* an unsafe request as short as the deployment allows. Every extra entry is a host whose compromise becomes your compromise. - A value that does not match is refused. A value that matches is permitted to proceed to the real defence, not excused from it.
- Your allowlist entry is written https://booking.example.com/ and every unsafe request is refused. What happened?A serialized origin never carries a trailing slash, so the entry cannot equal any received value and the check refuses everything, including your own client. Fix the entry. The tempting wrong fix — relaxing to a prefix test so that "anything under our domain works" — opens the check to a registered look-alike host.
- The booking client runs on https://booking.example.com:8443 in staging. Does the port belong in the allowlist entry?Yes. Scheme, host and port together make the origin, and a non-default port is serialized into the value, so the entry must carry it. Only the scheme's default port is omitted — which is why writing `:443` on an `https` entry has the opposite effect and never matches.
- Is it safe to allow any subdomain of your own registrable domain to initiate unsafe requests?Only if you operate every one of them. Each entry makes that host's compromise your compromise, and a wildcard over the domain is an open-ended promise about hosts that may be created later. Enumerate the origins the deployment actually uses and keep the set short.
saying these in an interview costs you the question
- Says a starts-with check is fine because the attacker cannot use your domain.
- Writes the allowlist entry with a trailing slash or a path.
- Adds :443 to an https allowlist entry and wonders why nothing matches.
- Loosens the comparison to a prefix test when legitimate traffic is refused.
- Treats http and https forms of the same host as one origin.
- Matches on the host only and ignores the scheme entirely.