A service allows any `Origin` whose host ends in its own domain name. How does an attacker-registered host pass that check?
answer
- ends with is not is
- prepend characters, keep the suffix
- the rightmost labels decide ownership
- subdomains are a label boundary, not bytes
- compare the whole serialized value
basics
~20 sEnding in a name is not being it. The attacker registers a domain whose name ends with the allowed string, such as evilconf-schedule.example against an allowed conf-schedule.example, so the suffix test passes and the grant is issued to them.
solid answer
~40 sA suffix test asks whether the arriving `Origin` *ends with* the allowed name, and any registrable domain can be made to end with a string by prepending characters to it — `https://evilconf-schedule.example` ends with `conf-schedule.example`. The mirror-image bug is a prefix test, which asks whether the value *starts with* the allowed origin: `https://conf-schedule.example.attacker.test` begins with those bytes and is a host the attacker owns outright. Both are usually written to express "and our subdomains", which is a different set. A test that inspects only part of the value is not a check: compare the complete serialized origin — scheme, host and port — against a fixed set, and if subdomains are genuinely needed, enumerate them or split on the label boundary rather than on bytes.
code
pseudocode · 19 linesfunction isAllowed(originHeader):
# broken: "ends with" admits a longer registered name
if originHeader ends with "conf-schedule.example":
return true
# broken: "starts with" admits anything appended after it
if originHeader starts with "https://conf-schedule.example":
return true
return false
# accepts https://evilconf-schedule.example
# accepts https://conf-schedule.example.attacker.test
function isAllowedExact(originHeader):
allowed = ["https://conf-schedule.example",
"https://sponsor-a.example"]
for each candidate in allowed:
if compare(originHeader, candidate) is equal:
return true
return falsego deeper
Hold on to the distinction itself: a value that ends with a name is not that name, and only comparing the whole value settles it.
Be able to construct the passing host for each broken test — one prepends characters to defeat a suffix test, the other appends labels to defeat a prefix test.
Explain the intent these patterns encode, that they usually mean and our subdomains, and give the label-boundary comparison that expresses it without opening the namespace.
Ask why a widening edit was invisible. A pattern that grows looser under deadline pressure is a review problem, and the answer is that the allowed set is owned data.
## The check that is not a check An origin arrives on the wire as a single value of the shape `scheme://host[:port]` — no path, and nothing else to compare. A service that means to admit a known set of partners has to decide, for each arriving value, whether it is one of them. The two ways this goes wrong look almost identical in code and differ completely in what they admit. - A **suffix test** asks: does the arriving value *end with* our name? - A **prefix test** asks: does the arriving value *start with* our origin? Neither asks the question that matters — *is this value one of the values in our set* — and each leaves an entire space of hosts that satisfy it and belong to somebody else. ## Two broken tests, and the host each admits Take an allowed origin of `https://conf-schedule.example`. | The test | What it admits | Why the attacker can have it | |---|---|---| | Value ends with `conf-schedule.example` | `https://evilconf-schedule.example` | A different registrable domain; anyone may register it | | Value ends with `.example` | Every host under that suffix | The check has become the whole namespace | | Value starts with `https://conf-schedule.example` | `https://conf-schedule.example.attacker.test` | The allowed name is just a label the attacker put in front of their own | | Host matched, scheme ignored | `http://conf-schedule.example` | A plaintext origin now reads a response intended for the secure one | The prefix case is the one people find counter-intuitive. `conf-schedule.example.attacker.test` is not a subdomain of the company at all — the rightmost labels decide who owns a name, and they read `attacker.test`. The string still begins with the allowed origin, which is all the test looked at. ## The intent underneath, and why it is a different set Almost every one of these is written to express **"and our own subdomains"**. That intent is legitimate and expressible; what is not legitimate is expressing it as a byte suffix. `corp.example` and `evilcorp.example` share a suffix and nothing else. If subdomains really are needed, split the host on the label separator and compare label by label, so that what matches is a boundary rather than a substring — or, better, enumerate the handful of subdomains that exist and compare against those. The same reasoning retires a family of near-misses: - An unanchored pattern match, where the allowed name is found *anywhere* in the value. - A pattern anchored at only one end, which is a suffix or prefix test wearing different syntax. - A pattern whose dots were never escaped, so they match any character and `conf-scheduleXexample` passes. - A comparison performed after lowercasing and stripping the scheme, which throws away exactly the part that distinguishes a secure origin from a plaintext one. ## What an exact comparison looks like 1. Hold the allowed origins as a **fixed set of complete serialized values**, each written as it appears on the wire, scheme and host and any non-default port together. 2. Compare the arriving value against each member for equality, with no trimming, no normalising and no pattern syntax involved. 3. On a match, answer with the matched value. On no match, send **no grant header at all** — the absence is the refusal. That routine is boring, and its being boring is the point: there is no space left in it for a host to be *nearly* allowed. ## How these reach production They are rarely written from scratch. The common paths are a partner onboarding that needed one more host before a deadline, an environment sprawl where staging and preview hosts all share a stem and a pattern looked like the tidy way to cover them, and a copy of a widely shared snippet that was never anchored. All three are edits made under time pressure to a value that nobody owns. The technical fix is one equality comparison; the durable fix is that the allowed set is declared data with a reviewer, so that widening it is visible as a change rather than invisible as a looser pattern.
- What should the service answer when the arriving origin matches nothing in the set?No grant header at all. Omitting `Access-Control-Allow-Origin` is the refusal, and the browser withholds the response from the calling script. Answering with an empty value, with the service's own origin, or with a placeholder adds nothing and muddles diagnosis later; the request itself has still been sent and processed either way.
- If a team genuinely needs every subdomain of one name, how do you express that safely?Split the arriving host on the label separator and require the allowed name to occupy whole labels at the right-hand end, so `corp.example` matches `app.corp.example` but not `evilcorp.example`. Compare the scheme and port separately rather than folding them into the same test, and prefer enumerating the subdomains that actually exist when there are only a few.
A checkpoint that admits any pass ending in the company name will wave through the holder of a card reading EX-COMPANY just as readily as a real one.
saying these in an interview costs you the question
- A host ending in our domain name must belong to us.
- An unanchored pattern match is close enough to an exact check.
- Matching only the host is fine; the scheme cannot be changed.
- Attackers cannot register a name that ends with ours.
- Allowing subdomains and matching a suffix are the same thing.