Skip to content

fix(sql): enforce the identity the playground claims to record (audit H9) - #433

Merged
passcod merged 3 commits into
mainfrom
claude/pr-370-fix-h9-sql-auth
Aug 1, 2026
Merged

fix(sql): enforce the identity the playground claims to record (audit H9)#433
passcod merged 3 commits into
mainfrom
claude/pr-370-fix-h9-sql-auth

Conversation

@passcod

@passcod passcod commented Aug 1, 2026

Copy link
Copy Markdown
Member

Fixes H9 (high) from the audit in #370.

The bug

execute_query and get_last_user_query are annotated security(("tailscale-user" = [])), and execute_query documents recording each query "under the caller's identity". Both took the extractor as Result<TailscaleUser, AppError> and called unwrap_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_guard only rejects header-less callers whose IP is in the tailnet ranges — could therefore run arbitrary read-only SQL against RO_DATABASE_URL, with the audit row's tailscale_user blank.

Every sibling read endpoint enforces with a bare _user: TailscaleUser. The one legitimate use of the Result form is commons::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: security was already declared, and the tailscale-user endpoints in this codebase don't enumerate 401 in responses (see issues.rs), so the committed spec is byte-identical — committed_spec_matches_generated passes.

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 sql and openapi_spec suites pass unchanged.

Worth noting the same security-without-enforcement pattern remains in bestool::save_snippet (M15) and both silenced_refs list handlers (M16), and the audit suggests a sweep across every annotated handler. Not in this PR.


Generated by Claude Code

`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
@passcod

passcod commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Not in this PR

Counterpoint: yes in this PR, please

@passcod
passcod marked this pull request as ready for review August 1, 2026 20:10
claude added 2 commits August 1, 2026 20:35
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

passcod commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Done — swept in this PR. 24 more handlers across 7 modules, so every security-annotated handler in fns/ now actually enforces. Verified by re-running the sweep: zero remaining.

module handlers
backups.rs get, list, group_schedules, type_defaults, stats, run_progress, capabilities
bestool.rs save_snippet (M15)
certificates.rs for_server, for_group, authority
domains.rs zones, for_group, grant_availability
restore_replicas.rs for_group, consumers, checks
server_groups.rs list, server_counts, get, list_archived, search
silenced_refs.rs list_for_server, list_for_group (M16)

save_snippet was the other Result + unwrap_or_default() case — same shape as the two sql ones, recording snippets under an empty-string author when identity failed.

Two things worth your eye before merging, since this is a wider behaviour change than the original PR:

  1. The remaining 22 are all read handlers, and the split was systematic — every mutation already enforced, every read didn't. So it plausibly read as a convention rather than an oversight; I'd flagged that on fix(bestool): don't attribute a snippet to nobody when identity fails (audit M15) #445 and defaulted to leaving them. Enforcing is the audit's direction and the conservative one, and it's safe for the SPA (it already calls the enforcing mutation endpoints through the same ingress with the same headers) — what it stops is a caller with no identity at all. But if any of those reads is deliberately open to something that isn't the SPA, this will 401 it in release. Flag any and I'll drop the annotation instead of adding the extractor.

  2. 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 is byte-identical and committed_spec_matches_generated passes. Documenting the 401 would be more accurate, but that's a codebase-wide convention change rather than this PR's business.

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.

private-server suite passes apart from the domains::* tests, which fail on a different subset each run and fail identically with my change to domains.rs reverted — pre-existing flakiness in my sandbox, not from this.


Generated by Claude Code

@passcod
passcod enabled auto-merge August 1, 2026 20:56
@passcod
passcod added this pull request to the merge queue Aug 1, 2026
Merged via the queue into main with commit 8d68dd7 Aug 1, 2026
8 checks passed
@passcod
passcod deleted the claude/pr-370-fix-h9-sql-auth branch August 1, 2026 21:13
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.

2 participants