Cross-origin access
Published
A CORS options object is short, and almost all of its risk sits in one key. That key has a default, the default is a wildcard, and a renamed key is indistinguishable from a key you never wrote. We measured what that costs by renaming each name in turn and reading the preflight response headers rather than any local variable.
What the sample actually does
The file configures cross-origin access for an API: a single permitted origin in an array, credentials enabled, two methods, a two-item request-header allowlist, one exposed response header, and a ten-minute preflight cache. It then runs three preflight requests through the middleware - one from the real application, one from an attacker origin, and one from a decommissioned staging host.
The middleware is copied in unprotected and implements the documented defaults of the widely used package it models: origin defaults to '*', methods defaults to a six-verb list, an absent allowedHeaders means reflect whatever the client asked for, and an absent credentials simply omits the header.
Every verdict below is read off the response headers, because the browser is the party that enforces CORS and the headers are all it ever sees. Protection alone, on five presets, produced identical headers to the unprotected run.
One rename turns an allowlist into a wildcard
Renaming origin moved our attacker line from allowed=false with no headers to allowed=true with access-control-allow-origin=*. The decommissioned staging host was admitted too. The application's own requests carried on working exactly as before, which is why nothing would have drawn attention to it.
This is the single worst result in the area and it comes from the package behaving correctly. A CORS middleware with no origin configured is a middleware asked to allow everyone, and that is a reasonable default for the public, unauthenticated APIs these libraries are often used for. It is a catastrophic default for the API you wrote an allowlist for.
There is one wrinkle worth knowing. A browser refuses to use a wildcard together with credentials, so cookie-authenticated cross-origin calls still fail. An API authenticated with a bearer token in an Authorization header is not protected by that rule at all, and those are the APIs most likely to be carrying an allowlist in the first place.
The rest of the object, and which way each one fails
Renaming methods restored the six-verb default, and our delete-preflight-approved line moved from false to true. Preflight approval is not authorisation - your handler still has to accept the request - but it removes a layer you had deliberately placed and it widens what a browser will attempt on your behalf.
Renaming allowedHeaders is more interesting than it looks. The header allowlist collapsed to whatever the middleware could still derive, and when we renamed both allowedHeaders and the request field it falls back to reading, the allowlist header disappeared entirely. That second measurement is the point: the fallback name was completely inert while the allowlist was configured, and became load-bearing the moment the allowlist was gone. An arm that measures unchanged can simply be code that never ran.
Renaming credentials failed in the safe direction - the header vanished and credentialed cross-origin requests stopped working, loudly, in the browser. Renaming maxAge removed preflight caching, which costs latency and nothing else. Renaming exposedHeaders made a response header your client reads unreadable, which shows up as an undefined value on the client rather than a network error.
The whole options object at once
With a pattern covering all six keys, the attacker origin was admitted with a wildcard, the six-verb method list came back, the credentials header disappeared, the preflight cache was gone, and the attacker's preflight was answered with access-control-allow-headers=authorization,x-admin - the reflected value of whatever that caller asked for.
That last detail deserves a moment. With no allowlist, the middleware echoes the requested headers back, so the response tells the caller that any header they name is acceptable. Combined with a wildcard origin, the policy has inverted: instead of describing who may call you, it describes nobody at all.
The API kept working perfectly for the real application throughout. Every arm in this article leaves your own front end functioning, which is the only reason any of it would reach production.
What did not move, and why that is not reassurance
One arm measured unchanged: the request-header field the middleware falls back on when no allowlist is configured. It never ran, because an allowlist was configured. We reported it as blind rather than clean and then built the paired measurement described above to make it run - which is where the disappearing allowlist header came from.
The contrast arm is the object the middleware hands back. Renaming the names on that result - the header map, the allowed flag, the status - produced an immediate TypeError on the first request. Loud, unmissable, and a much better outcome than anything else in this article.
That asymmetry holds across the whole series. Data flowing into a library that has defaults degrades quietly. Data flowing back out of it, which your code reads by name, crashes. The quiet direction is the one that reaches production.
What to do about it
Exclude the CORS option names from your member pattern: origin, credentials, methods, allowedHeaders, exposedHeaders, maxAge, preflightContinue and optionsSuccessStatus, plus the field names on whatever request object your middleware reads.
Then write an integration test that asserts a rejection rather than a success. Send a preflight from an origin that must not be allowed and require that no Access-Control-Allow-Origin header comes back. Every arm in this article passes a test that checks the real front end still works; only a negative test fails.
If you can, express the allowlist as data rather than as an object key. An origin list read from configuration is string data on both sides, and a policy assembled from that data cannot lose its allowlist to a renaming pattern.
Frequently asked questions
Does obfuscation change my CORS headers?
Not by itself. Protection alone produced identical preflight responses on all five presets we tested. The headers changed only when member renaming matched the keys of the options object the middleware reads.
How does an origin allowlist become a wildcard?
A renamed key looks exactly like a key that was never supplied, and the documented default for the origin option is a wildcard. The middleware is behaving correctly; it simply has no allowlist to enforce.
Does enabling credentials protect me from the wildcard?
Partly, and only for cookie-based authentication, because browsers refuse to combine a wildcard with credentials. An API authenticated with a bearer token in an Authorization header gets no protection from that rule.
What happens to the request-header allowlist?
It reverts to the middleware's fallback, which is to reflect whatever headers the caller asked for. In our whole-object measurement the attacker's preflight came back approving the exact header list that caller had requested.
Which CORS options fail safely?
Credentials and the exposed-header list both fail towards breakage: the header disappears and the browser or your client notices. The preflight cache costs only latency. The dangerous ones are origin and methods, where absence means permitted.
What test would have caught this?
A negative test. Send a preflight from an origin that must be refused and assert that no Access-Control-Allow-Origin header is returned. Tests that confirm your own front end still works passed on every arm we measured.
What should I exclude from member renaming?
origin, credentials, methods, allowedHeaders, exposedHeaders, maxAge, preflightContinue and optionsSuccessStatus, together with the field names on the request object your middleware inspects and the result object it returns.
Related reading