fix(sql): enforce the identity the playground claims to record (audit H9) - #433
Conversation
`execute_query` and `get_last_user_query` are annotated
`security(("tailscale-user" = []))`, and the former documents recording each
query "under the caller's identity" — but both wrapped the extractor in
`Result` and `unwrap_or_default()`d it. In release builds, where the dev auth
bypass is compiled out, a failed extraction was swallowed and the request
proceeded, attributed to the empty-string login.
So a request reaching private-server without identity headers from a
non-tailnet client IP — in-cluster traffic bypassing the Tailscale ingress;
`tailnet_guard` only rejects header-less callers whose IP *is* in the tailnet
ranges — could run arbitrary read-only SQL against `RO_DATABASE_URL`, with
the audit row's `tailscale_user` blank.
Both now take the bare extractor like every sibling read endpoint, so a
failed extraction is a 401. The only legitimate use of the `Result` form is
`commons::is_current_user_admin`, which needs to answer "no" rather than
reject, and says so in a comment; these had none.
No new test: the failure path exists only in release builds (the extractor
short-circuits under `debug_assertions`, which is why every endpoint test can
skip the Tailscale headers), so no test binary can reach it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGfH1cdFKPnKpM7ytRThft
Counterpoint: yes in this PR, please |
Sweeps the pattern this PR started on, as asked: every handler whose
`#[utoipa::path]` declares `security(("tailscale-user" = []))` now takes the
extractor, so the spec and the code agree. 24 handlers across 7 modules, plus
the two `sql` ones already in this PR.
`bestool::save_snippet` (M15) was the other `Result` + `unwrap_or_default()`
case: it recorded snippets under an empty-string author when identity failed,
and attribution is the whole audit trail for the SQL a snippet carries.
The rest (M16 and the wider sweep) were read handlers that declared auth and
didn't enforce it. The split was systematic — every mutation enforced, every
read didn't — so this is a real behaviour change rather than a typo fix: in
release builds those reads now 401 without identity headers instead of
answering. It's safe for the SPA, which already calls the enforcing mutation
endpoints through the same ingress with the same headers; what it stops is a
caller that has neither.
No spec change: `security` was already declared on all of them, and the
`tailscale-user` endpoints here don't enumerate 401 in `responses`, so the
committed spec stays byte-identical and `committed_spec_matches_generated`
passes. Documenting 401 would be more accurate but is a codebase-wide
convention change, not this PR's business.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGfH1cdFKPnKpM7ytRThft
|
Done — swept in this PR. 24 more handlers across 7 modules, so every
Two things worth your eye before merging, since this is a wider behaviour change than the original PR:
That makes #445 redundant — it only carried the M15 fix and the M16 write-up. Happy to close it, or leave it for you to.
Generated by Claude Code |
Fixes H9 (high) from the audit in #370.
The bug
execute_queryandget_last_user_queryare annotatedsecurity(("tailscale-user" = [])), andexecute_querydocuments recording each query "under the caller's identity". Both took the extractor asResult<TailscaleUser, AppError>and calledunwrap_or_default()on it.In debug builds the extractor short-circuits to a fake admin, so nothing shows. In release, where that bypass is compiled out, a failed extraction was swallowed and the request proceeded anyway, attributed to the empty-string login.
A request reaching private-server without identity headers from a non-tailnet client IP — in-cluster traffic bypassing the Tailscale ingress, say;
tailnet_guardonly rejects header-less callers whose IP is in the tailnet ranges — could therefore run arbitrary read-only SQL againstRO_DATABASE_URL, with the audit row'stailscale_userblank.Every sibling read endpoint enforces with a bare
_user: TailscaleUser. The one legitimate use of theResultform iscommons::is_current_user_admin, which needs to answer "no" rather than reject and carries a comment saying so; these had none.The fix
Both handlers take the bare extractor, so a failed extraction is a 401 before any work happens. The
user.unwrap_or_default()lines go away, and the recorded identity is now necessarily the caller's.No OpenAPI change:
securitywas already declared, and thetailscale-userendpoints in this codebase don't enumerate 401 inresponses(seeissues.rs), so the committed spec is byte-identical —committed_spec_matches_generatedpasses.On testing
There's no new test, and I don't think an honest one exists. The failure path only occurs in release builds — the extractor returns a fake admin under
debug_assertions, which is exactly why every endpoint test can omit the Tailscale headers — so no test binary can reach it. The audit rates this one "hard" for the same reason.What I did verify: the existing
sqlandopenapi_specsuites pass unchanged.Worth noting the same
security-without-enforcement pattern remains inbestool::save_snippet(M15) and bothsilenced_refslist handlers (M16), and the audit suggests a sweep across every annotated handler. Not in this PR.Generated by Claude Code