Security: refuse cross-origin POSTs; POST /account/delete is forgeable from a sibling *.d-ma.be origin #48

Closed
opened 2026-10-03 18:47:59 +00:00 by mathias · 1 comment
Owner

P0 of the audio-upload follow-up sprint (milestone v0.31.1). Predates audio upload; found by the sprint's phase-1 investigation of #46 item 3 and re-verified by reading the code.

Problem. There is no CSRF token and no Origin or Sec-Fetch check anywhere. The session cookie is HttpOnly, Secure and SameSite=Lax (internal/web/oidc/oidc.go:296-298). Lax stops a cross-SITE form POST but not a POST from a sibling origin on the same registrable domain (any other *.d-ma.be app), which browsers treat as same-site. handleDeleteAccount (internal/web/account.go, route POST /account/delete) deletes the account and its connections on a bare POST with no confirmation step. Reproduced in a scratch test: with Origin: https://evil.d-ma.be and Sec-Fetch-Site: same-site the request returned 303 /auth/logout and the account was deleted. Exploiting it needs an attacker-controlled page on a sibling origin (an XSS in, or user content on, another *.d-ma.be app), so it is conditional, but the fix is one stdlib wrapper. The same exposure applies to every other state-changing POST (/uploads, /uploads/{id}/chat, settings) and to the delete route planned in #37.

Fix. Wrap the router in the standard library's http.NewCrossOriginProtection() (Go 1.25+, the repo is on 1.26): it refuses a non-safe request whose Sec-Fetch-Site is cross-site or same-site, or whose Origin does not match the host, and lets header-less requests through (non-browser clients). /auth/callback is a GET, so login is unaffected.

Red tests first (name by mechanism)

  • TestCrossOriginProtection_RefusesAForeignOriginPostOnEveryStateChangingRoute: POST to /account/delete, /uploads, /uploads/{id}/chat with Origin: https://evil.d-ma.be and Sec-Fetch-Site: same-site (and cross-site) answers 403 and the row/account survives.
  • TestCrossOriginProtection_AllowsASameOriginPostAndAHeaderlessOne.
  • TestCrossOriginProtection_DoesNotBreakTheOIDCCallbackOrLogin (GET /auth/callback still passes).
  • Mutation to run: remove the wrapper and the first test must fail.

Review: control code (a gate that refuses requests): adversarial pass verified by execution (origin casing and ports, Origin: null, a missing Host, HTTP/2, a GET that mutates state anywhere).

Acceptance: the three tests red first, green after; task check and golangci-lint run ./... exit 0 (run both: task check does not gate lint until #41); a recorded gatekeeper verdict in this issue; ADR note and SemVer patch tag v0.31.1.

Also file: the flash cookie sets no Secure flag (cosmetic: it carries only a message code).


Created via git-mcp on behalf of @mathias

**P0 of the audio-upload follow-up sprint (milestone v0.31.1). Predates audio upload; found by the sprint's phase-1 investigation of #46 item 3 and re-verified by reading the code.** **Problem.** There is no CSRF token and no Origin or Sec-Fetch check anywhere. The session cookie is HttpOnly, Secure and `SameSite=Lax` (`internal/web/oidc/oidc.go:296-298`). Lax stops a cross-SITE form POST but not a POST from a sibling origin on the same registrable domain (any other `*.d-ma.be` app), which browsers treat as same-site. `handleDeleteAccount` (`internal/web/account.go`, route `POST /account/delete`) deletes the account and its connections on a bare POST with no confirmation step. Reproduced in a scratch test: with `Origin: https://evil.d-ma.be` and `Sec-Fetch-Site: same-site` the request returned `303 /auth/logout` and the account was deleted. Exploiting it needs an attacker-controlled page on a sibling origin (an XSS in, or user content on, another `*.d-ma.be` app), so it is conditional, but the fix is one stdlib wrapper. The same exposure applies to every other state-changing POST (`/uploads`, `/uploads/{id}/chat`, settings) and to the delete route planned in #37. **Fix.** Wrap the router in the standard library's `http.NewCrossOriginProtection()` (Go 1.25+, the repo is on 1.26): it refuses a non-safe request whose `Sec-Fetch-Site` is cross-site or same-site, or whose `Origin` does not match the host, and lets header-less requests through (non-browser clients). `/auth/callback` is a GET, so login is unaffected. **Red tests first (name by mechanism)** - `TestCrossOriginProtection_RefusesAForeignOriginPostOnEveryStateChangingRoute`: POST to `/account/delete`, `/uploads`, `/uploads/{id}/chat` with `Origin: https://evil.d-ma.be` and `Sec-Fetch-Site: same-site` (and `cross-site`) answers 403 and the row/account survives. - `TestCrossOriginProtection_AllowsASameOriginPostAndAHeaderlessOne`. - `TestCrossOriginProtection_DoesNotBreakTheOIDCCallbackOrLogin` (GET `/auth/callback` still passes). - Mutation to run: remove the wrapper and the first test must fail. **Review:** control code (a gate that refuses requests): adversarial pass verified by execution (origin casing and ports, `Origin: null`, a missing `Host`, HTTP/2, a `GET` that mutates state anywhere). **Acceptance:** the three tests red first, green after; `task check` and `golangci-lint run ./...` exit 0 (run both: `task check` does not gate lint until #41); a recorded gatekeeper verdict in this issue; ADR note and SemVer patch tag v0.31.1. Also file: the flash cookie sets no `Secure` flag (cosmetic: it carries only a message code). --- _Created via git-mcp on behalf of @mathias_
mathias added this to the v0.31.1 - security and robustness milestone 2026-10-03 18:47:59 +00:00
mathias added the risk/highsize/xstype/fix labels 2026-10-03 18:47:59 +00:00
Author
Owner

Shipped in v0.31.1 (merge e037c6b, PR #55, image e037c6b live). Adversarial review verdicts (agent reviews, round 1 BLOCK on other findings, round 2 APPROVE) are on PR #55; the reviewer's 33 requests against the real Router found no way around the gate (foreign/suffix/userinfo/null Origin, every Sec-Fetch-Site value, other methods, method override all refused; /auth/* login/callback/logout unaffected).

Verified live on production (unauthenticated, 2026-10-03):

  • POST /account/delete with Origin: https://evil.d-ma.be -> 403
  • same with Origin: null -> 403
  • same with Origin: https://evil.d-ma.be + Sec-Fetch-Site: same-site -> 403
  • Origin: https://tapir.d-ma.be -> 302 (past the gate, to login), so NPM/Traefik preserve Host and legitimate same-origin posts are not refused
  • no headers -> 302; GET /welcome -> 200

Open edge noted by the reviewer, not a regression: YouTube connect/callback are GET and state-protected, so this control does not cover them (pre-existing). Closing.


Created via git-mcp on behalf of @mathias

Shipped in v0.31.1 (merge e037c6b, PR #55, image e037c6b live). Adversarial review verdicts (agent reviews, round 1 BLOCK on other findings, round 2 APPROVE) are on PR #55; the reviewer's 33 requests against the real Router found no way around the gate (foreign/suffix/userinfo/null Origin, every Sec-Fetch-Site value, other methods, method override all refused; `/auth/*` login/callback/logout unaffected). **Verified live on production** (unauthenticated, 2026-10-03): - `POST /account/delete` with `Origin: https://evil.d-ma.be` -> 403 - same with `Origin: null` -> 403 - same with `Origin: https://evil.d-ma.be` + `Sec-Fetch-Site: same-site` -> 403 - `Origin: https://tapir.d-ma.be` -> 302 (past the gate, to login), so NPM/Traefik preserve Host and legitimate same-origin posts are not refused - no headers -> 302; `GET /welcome` -> 200 Open edge noted by the reviewer, not a regression: YouTube connect/callback are GET and state-protected, so this control does not cover them (pre-existing). Closing. --- _Created via git-mcp on behalf of @mathias_
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/tapir#48