feat(server): cross_origin config for CORS and the isolation headers - #154
Merged
Conversation
Every cross-origin response header was hardcoded in `APIServer.setCORS`,
and the cross-origin isolation headers (`COOP`, `COEP`, `CORP`) were not
sent at all. Adds a top-level `cross_origin` config entry, mirroring the
shape of `cache`: a family split by the kind of response it applies to.
cross_origin:
api:
allow_origin: ''
allow_methods: 'GET,HEAD,PUT,POST,PATCH,DELETE,OPTIONS'
allow_headers: 'Content-Type, Access-Control-Allow-Headers, Authorization'
allow_credentials: true
expose_headers: 'Content-Length, Content-Type, Last-Modified, X-Access-Token, X-Access-Token-Expiration'
max_age: 0
vary_origin: true
static_files:
opener_policy: 'same-origin-allow-popups'
embedder_policy: ''
resource_policy: ''
The split is not cosmetic: `CORS` is set on API responses only, and the
isolation policies on static file responses only, so the two never meet
on the same response. Every entry is also a command-line option, and an
`APICrossOriginConfig` can be passed straight to `APIServer`.
Two behavior changes:
- Static `text/html` responses now carry `Cross-Origin-Opener-Policy:
same-origin-allow-popups`. It severs the opener relationship with a
cross-origin document that opened the page, while still letting the
popups the page itself opens keep a handle back to it — what
popup-based sign-in flows need. A page served by this server that is
itself opened as a cross-origin popup and calls `window.opener` must
opt out with `opener_policy: none`.
- API responses now carry `Vary: Origin`.
`Access-Control-Allow-Origin` reflects the request `Origin`, and
without `Vary` a shared cache is free to serve one origin's value to
another.
`COOP`/`COEP` are gated on `text/html`, since they are document headers,
while `CORP` applies to every static file. `COEP` and `CORP` default to
disabled, and every other default is the value that was hardcoded, so
nothing else changes for an existing server.
`allow_origin` also gained allowlist support: when set, the request
`Origin` is reflected only if it matches. Left empty it keeps reflecting
any origin, which together with `Access-Control-Allow-Credentials: true`
lets any site make credentialed calls and read the responses — the
browser only rejects that pairing for a literal `*`.
Also fixes a `cookieless` server not applying its guarantee to static
files: `Set-Cookie` was dropped and `X-Cookieless-Server` added by the
API response builder, which a static file response never reaches. Both
now happen for static files as well, including the non-2xx ones, and
`removeStaticFileCookies` runs again at the last handover of the
`Response`, so a `Set-Cookie` introduced after the headers are
configured is still stripped.
Verified by the full non-DB suite (794 tests), including new end-to-end
coverage of the defaults, of a fully configured server, and of the
cookieless static file responses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151kpLtMYfkBJb1T822Suiu
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #154 +/- ##
==========================================
+ Coverage 68.24% 68.31% +0.07%
==========================================
Files 66 66
Lines 22160 22322 +162
==========================================
+ Hits 15122 15250 +128
- Misses 7038 7072 +34
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every cross-origin response header was hardcoded in
APIServer.setCORS, and the cross-origin isolation headers (COOP,COEP,CORP) were not sent at all — there was no way to change any of it short of rewriting the response.The config entry
A top-level
cross_origin:family, mirroring the shapecache:already uses — split by the kind of response it applies to:The
api:/static_files:split is not cosmetic:CORSis set on API responses only, the isolation policies on static file responses only, and the two never meet on the same response.Every entry is also a command-line option (
--cross-origin-api-max-age,--cross-origin-static-files-opener-policy, …), and anAPICrossOriginConfigcan be passed straight toAPIServer. Precedence: constructor → CLI → YAML → default. Each policy acceptsnone(or an empty value) to omit its header.New public classes:
APICrossOriginConfig,APICORSConfig,APICrossOriginPolicies.Behavior changes
Static
text/htmlresponses now carryCross-Origin-Opener-Policy: same-origin-allow-popups. It severs the opener relationship with a cross-origin document that opened the page — the isolation ofsame-origin— while still allowing the popups the page itself opens to keep a handle back to it. That is what popup-based sign-in flows need (Sign in with Google, OAuth popups): the popup reports its result by calling back into its opener.A page served by this server that is itself opened as a cross-origin popup and calls
window.opener(an OAuth callback landing page) must opt out withopener_policy: none.API responses now carry
Vary: Origin.Access-Control-Allow-Originreflects the requestOrigin, and withoutVarya shared cache is free to serve one origin's value to another. Disable withvary_origin: false.COOPandCOEPare gated ontext/html, since they are document headers, whileCORPapplies to every static file.COEPandCORPdefault to disabled, and every other default is the value that was previously hardcoded — so nothing else changes for an existing server.allow_originallowlistWhen set, the request
Originis reflected only if it matches; otherwise noAccess-Control-Allow-Originis sent and the browser blocks the cross-origin read.Worth flagging for anyone leaving it empty: reflecting any origin together with
Access-Control-Allow-Credentials: true— the behavior before this PR, and still the default — lets any site make credentialed calls to the API and read the responses. The browser only rejects that pairing for a literal*, and reflecting sidesteps it.Cookieless fix
A
cookielessserver did not apply its guarantee to static files.Set-Cookiewas dropped andX-Cookieless-Server: Blocking all cookiesadded by the API response builder, but a static file response is built by theshelfhandler and returned before reaching it — so the header was missing from every static file, and nothing enforced the absence ofSet-Cookiethere.Both now happen for static files as well, including the non-
2xxresponses.removeStaticFileCookies()runs when the headers are configured and again at the last handover of the staticResponse, so aSet-Cookieintroduced after header configuration is still stripped. The two cookieless sites now shareAPIServer.headerXCookielessServer/headerXCookielessServerValuerather than duplicated literals.Testing
Full non-DB suite green (794 tests). New end-to-end coverage:
COOPon/index.html, absent on a non-HTML file, noCOEP/CORP, reflected origin +Vary: Originon an API route, nomax-ageCORPon a non-HTML file, allowed origin, origin outside the allowlist, preflightmax-age/index.html, a non-HTML file, a404, and an API routeNote on an unrelated pre-existing quirk
_configureAPIRoot()appends each worker's_handleStaticFilesto the sharedapiRoot.posApiRequestHandlers, so when twoAPIServers share oneAPIRootthe first-registered one answers static files for both — with its configuration, regardless of which port the request arrived on. It surfaced while writing these tests; worked around there with a dedicatedAPIRootper server, and left alone otherwise as out of scope.🤖 Generated with Claude Code
https://claude.ai/code/session_0151kpLtMYfkBJb1T822Suiu