Skip to content

feat(server): cross_origin config for CORS and the isolation headers - #154

Merged
gmpassos merged 1 commit into
masterfrom
feat/cross-origin-config
Aug 26, 2026
Merged

feat(server): cross_origin config for CORS and the isolation headers#154
gmpassos merged 1 commit into
masterfrom
feat/cross-origin-config

Conversation

@gmpassos

Copy link
Copy Markdown
Contributor

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 shape cache: already uses — 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 api: / static_files: split is not cosmetic: CORS is 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 an APICrossOriginConfig can be passed straight to APIServer. Precedence: constructor → CLI → YAML → default. Each policy accepts none (or an empty value) to omit its header.

New public classes: APICrossOriginConfig, APICORSConfig, APICrossOriginPolicies.

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 — the isolation of same-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 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. Disable with vary_origin: false.

COOP and 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 previously hardcoded — so nothing else changes for an existing server.

allow_origin allowlist

When set, the request Origin is reflected only if it matches; otherwise no Access-Control-Allow-Origin is 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 cookieless server did not apply its guarantee to static files. Set-Cookie was dropped and X-Cookieless-Server: Blocking all cookies added by the API response builder, but a static file response is built by the shelf handler and returned before reaching it — so the header was missing from every static file, and nothing enforced the absence of Set-Cookie there.

Both now happen for static files as well, including the non-2xx responses. removeStaticFileCookies() runs when the headers are configured and again at the last handover of the static Response, so a Set-Cookie introduced after header configuration is still stripped. The two cookieless sites now share APIServer.headerXCookielessServer / headerXCookielessServerValue rather than duplicated literals.

Testing

Full non-DB suite green (794 tests). New end-to-end coverage:

  • defaults: COOP on /index.html, absent on a non-HTML file, no COEP/CORP, reflected origin + Vary: Origin on an API route, no max-age
  • a fully configured server: all three policies, CORP on a non-HTML file, allowed origin, origin outside the allowlist, preflight max-age
  • a cookieless server: /index.html, a non-HTML file, a 404, and an API route

Note on an unrelated pre-existing quirk

_configureAPIRoot() appends each worker's _handleStaticFiles to the shared apiRoot.posApiRequestHandlers, so when two APIServers share one APIRoot the 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 dedicated APIRoot per server, and left alone otherwise as out of scope.

🤖 Generated with Claude Code

https://claude.ai/code/session_0151kpLtMYfkBJb1T822Suiu

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

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 65.21739% with 64 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.31%. Comparing base (4d34116) to head (defea36).

Files with missing lines Patch % Lines
lib/src/bones_api_server.dart 65.21% 64 Missing ⚠️
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     
Flag Coverage Δ
unittests 68.31% <65.21%> (+0.07%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gmpassos
gmpassos merged commit b1fa4c4 into master Aug 26, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant