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_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).
**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
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.
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_
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.beapp), which browsers treat as same-site.handleDeleteAccount(internal/web/account.go, routePOST /account/delete) deletes the account and its connections on a bare POST with no confirmation step. Reproduced in a scratch test: withOrigin: https://evil.d-ma.beandSec-Fetch-Site: same-sitethe request returned303 /auth/logoutand 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.beapp), 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 whoseSec-Fetch-Siteis cross-site or same-site, or whoseOrigindoes not match the host, and lets header-less requests through (non-browser clients)./auth/callbackis a GET, so login is unaffected.Red tests first (name by mechanism)
TestCrossOriginProtection_RefusesAForeignOriginPostOnEveryStateChangingRoute: POST to/account/delete,/uploads,/uploads/{id}/chatwithOrigin: https://evil.d-ma.beandSec-Fetch-Site: same-site(andcross-site) answers 403 and the row/account survives.TestCrossOriginProtection_AllowsASameOriginPostAndAHeaderlessOne.TestCrossOriginProtection_DoesNotBreakTheOIDCCallbackOrLogin(GET/auth/callbackstill passes).Review: control code (a gate that refuses requests): adversarial pass verified by execution (origin casing and ports,
Origin: null, a missingHost, HTTP/2, aGETthat mutates state anywhere).Acceptance: the three tests red first, green after;
task checkandgolangci-lint run ./...exit 0 (run both:task checkdoes 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
Secureflag (cosmetic: it carries only a message code).Created via git-mcp on behalf of @mathias
Shipped in v0.31.1 (merge
e037c6b, PR #55, imagee037c6blive). 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/deletewithOrigin: https://evil.d-ma.be-> 403Origin: null-> 403Origin: https://evil.d-ma.be+Sec-Fetch-Site: same-site-> 403Origin: https://tapir.d-ma.be-> 302 (past the gate, to login), so NPM/Traefik preserve Host and legitimate same-origin posts are not refusedGET /welcome-> 200Open 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