Skip to content

Serve no CORS headers from the brain - #476

Merged
onel merged 2 commits into
devfrom
fix/475-cors-origin-reflection
Sep 11, 2026
Merged

onel merged 2 commits into
devfrom
fix/475-cors-origin-reflection

Conversation

@onel

@onel onel commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

What & why

internal/api.withCORS wrapped the whole handler chain on both profiles. It reflected whatever Origin arrived, set Access-Control-Allow-Credentials: true, and answered every OPTIONS with 204. It is removed.

On hosted that is a live hole. Apps are served at <slug>.<box-id>.malmo.network and the dashboard at <box-id>.malmo.network. malmo.network is not on the Public Suffix List — the same fact ENVIRONMENT.md # Certs uses to explain the shared Let's Encrypt budget — so those hosts are same-site. The owner's malmo_session cookie being SameSite=Lax therefore does not stop a fetch from an app origin to the dashboard host, and the reflected header let the app read the answer. Any app on the box could act as the owner against the dashboard API, POST /api/v1/auth/elevate/challenge included.

Two places in the tree already asserted this was impossible, which is what makes it a defect rather than a tradeoff:

  • AUTH.md # Re-authentication: "minting one needs an authenticated JSON POST to the box's own API, which a cross-origin page cannot make."
  • internal/api/confirm.go: "the JSON content type forces a preflight the brain does not answer."

The brain answered it. The hosted confirm step's whole cross-site argument rested on a refusal that was returning 204.

Removed rather than narrowed to an allowlist. Nothing calls this API cross-origin in any lane: web-ui/src/api.ts fetches relative paths, Caddy serves the dashboard and the brain on one host in production, and web-ui/vite.config.ts proxies /api to the brain with changeOrigin in dev. The middleware's comment said it existed for the Vite dev server; the proxy had already made that untrue. An allowlist keeps a knob that is only ever set wrong, for a caller that does not exist.

Found by Greptile on the v0.12.0 release PR (#474), in code already merged to dev. This is the first of three stacked fixes from that review; #474 stays open until all three land.

Spec(s) touched

docs/specs/AUTH.md # Re-authentication for destructive actions. The sentence that assumed a cross-origin page cannot POST here now states it as a guarantee the brain keeps, with the same-site reasoning and the PSL fact named, so a future CORS layer contradicts a spec line rather than slipping in. No DECISIONS.md entry: nothing locked flipped, the code was simply not doing what the spec said.

What was tested

  • New internal/api/cors_test.go, two tests. A foreign Origin on a real 200 gets none of the four Access-Control-* headers back; a preflight OPTIONS on the challenge route is not answered 204.
  • Both were run against the old middleware first and fail there, echoing https://photos.box-1.malmo.network back verbatim in Access-Control-Allow-Origin with Access-Control-Allow-Credentials: true. They are regression tests, not assertions that happen to hold.
  • The 200 case uses the appliance login picker, which answers without a session, so header absence is asserted on a success rather than on a 401 that could hide a header attached further down the chain.
  • make check green, exit 0.

Known gaps & deviations

  • The blanket OPTIONS 204 goes with it. Intended, and no malmo code sent one. Same-origin requests never preflight, so make dev is unaffected — but a developer calling the brain directly from a browser page instead of through the Vite proxy would now see a preflight fail. Called out because it is the one behaviour change someone could trip over.
  • Not exercised on a booted box. In-process API test only. Proving the attack end to end needs a hosted box with an app installed, and the cloud lane has no such assertion. Named as follow-up in the progress entry.
  • Only the brain is covered. Caddy's own headers on app routes are untouched, and app-to-app CORS stays the apps' business per SPEC.md # Origins.
  • Does not narrow what a same-origin compromised page can do. A dashboard XSS is a different boundary.

Platform gaps

None.

Definition of done

  • Behavior works in the inner loop (make dev), and integration-tested against the real system if it touches one.
  • Tests added at the right layer; make check green.
  • No test deleted or newly skipped: the diff adds one test file and deletes none.
  • Progress entry written (docs/progress/cors-origin-reflection.md); progress index updated.
  • Catalog change: n/a.
  • Catalog change, new published field: n/a.
  • Spec doc updated (AUTH.md); no DECISIONS.md entry needed.
  • No section-sign symbol, no hard-wrapped markdown, log/slog only, conventions per CLAUDE.md.
  • Branch off dev, PR into dev.

Closes #475

withCORS wrapped the whole handler chain on both profiles: it reflected
whatever Origin arrived, set Access-Control-Allow-Credentials: true, and
answered every OPTIONS with 204.

On hosted that is a live hole. Apps are <slug>.<box-id>.malmo.network and
the dashboard is <box-id>.malmo.network, and malmo.network is not on the
Public Suffix List, so the two are same-site. The owner's SameSite=Lax
session cookie therefore rides a fetch from any app to the dashboard host,
and the reflected header let the app read the reply -- including the reply
to POST /auth/elevate/challenge.

Two places already asserted the opposite: AUTH.md # Re-authentication says
a cross-origin page cannot make an authenticated JSON POST here, and
confirm.go says the JSON content type forces a preflight the brain does not
answer. The hosted confirm step's cross-site argument rested on that
refusal, which was in fact a 204.

Removed rather than narrowed to an allowlist, because nothing calls this
API cross-origin in any lane. The dashboard fetches relative paths, Caddy
serves it and the brain on one host in production, and the Vite dev server
proxies /api to the brain -- which had already made the middleware's own
stated reason for existing untrue.

Closes #475
@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with the previous documentation finding fixed and no new actionable issues identified.

Reviews (2) · Last reviewed commit: "Say what actually protects the challenge..."

Comment thread docs/specs/AUTH.md Outdated
Review pointed out that both the spec sentence and the confirm.go comment
described the wrong mechanism, and that the new AUTH.md guarantee repeated
it.

elevateChallenge takes no body and requires no JSON content type, so the
POST is a simple request in CORS terms. A same-site app page can send it
with the owner's cookie and a challenge is minted. The protection was never
that the request cannot be sent -- it is that the reply cannot be read, and
a challenge nobody reads is inert: single-use, bound to its user, and
expiring unspent.

The preflight still matters, for the requests that do trigger one: every
JSON PUT and PATCH on this API. The test comment now says which half covers
which.
@onel
onel merged commit f841bc0 into dev Sep 11, 2026
2 checks passed
@onel
onel deleted the fix/475-cors-origin-reflection branch September 11, 2026 14:26
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.

Brain reflects any Origin with credentials, defeating the hosted confirm boundary

1 participant