fix(dashboard): stale query token no longer vetoes a valid session cookie - #6194
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound root-cause fix for the veto bug, but a 30-day-credential feature rides along with its identity bound admittedly weakened and no per-feature revocation knob. Watch
Suggestions
[DESIGN-REVIEWED] 4fa56f0 |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @bolichen97 overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ human override acceptedReviewed Human judgment by @bolichen97 overrides the Opus 4.8 finding for Verdict recorded from an authorized human decision for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All counts are in hand: no unfixed siblings of the query-then-cookie re-extraction remain (4 First-Principles-Verdict: CONCERNS A restart-survival feature rides along in a token-veto fix, and its own description concedes its only security bound is not yet enforced. What this change shipsIntent: keep a scanned-in phone signed in instead of silently 401-ing after ~30 minutes — a FIX (per its own
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 4fa56f0 |
5bc644b to
8f36335
Compare
The finding holds, and it named the real mechanism. I did not take the suggested fix (exempting Instead the middleware now publishes the credential it actually validated as
Two tests, both mutation-verified against the old re-extraction (which fails with
The pre-existing Also in this revision: |
8f36335 to
784a6be
Compare
|
/ai-review override gpt 784a6be ack on purpose |
|
AI-review override not recorded. Use |
784a6be to
24143d4
Compare
Scope restored to one PR — and two BLOCKING findings are being accepted, not resolvedReversing the split announced earlier: Read this before approving. Two GPT 5.6 BLOCKING findings are being overridden. They are legitimate, I verified both myself, and neither is fixed:
Why the residual risk is bounded, and where it is not. The setting is The correct fix, deliberately not in this PR. Carry the peer pin key on the chain and compare it for full-string equality, establishing it at the link→session exchange (where the phone first presents itself) rather than at the QR mint (which runs on the desktop and would bind the wrong peer); and have the gate require a live resolution rather than a configured value. Worth its own review round. For the record, this feature produced five consecutive security findings, each surfaced only after the previous was fixed: app-token fallback bypass, chain lost its peer binding, check sited after the grace-replay exit, presence-not-identity, and configured-not-live. That pattern is the argument for the redesign above rather than a sixth patch. Verification on this head: flake8 7.1.0, isort 6.0.0, |
|
/ai-review override gpt 5dc31cd: Both findings are real and are accepted knowingly by the author, NOT disputed as false positives. The affected feature ships default OFF and is inert unless an operator sets trust_identity plus a non-empty allowed_logins. Residual accepted risk: an allowlisted peer other than the one that scanned can use the chain, and the gate reads configured intent rather than a live identity resolution. The binding redesign is tracked as follow-up. Merge still requires a second reviewer's approval. |
|
/ai-review override fable 5dc31cd: Author's explicit decision to ship the two black-screen causes and the opt-in persistent session together rather than split. The Companion caching is not a rider but a second measured cause of the same symptom: its 5s mint cadence turned the 50-slot nonce ring over every few minutes and evicted the phone's QR link before its own window lapsed, 91 mints per 5 minutes measured, so the veto fix alone does not remove the defect. The persistent shape is declared scope debt, default off. |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Human judgment recorded@bolichen97 marked the fable AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
…okieProblem: token extraction gave the `?token=` query param absoluteprecedence over the session cookie. Re-opening a bookmarked orpreviously-scanned link replays the long-expired link token in the URL,and that dead token vetoed the still-valid session cookie the link hadbeen exchanged for: the request 401'd and the user was told theirsession expired while holding a perfectly valid credential.Fix: when the query token fails validation and a session cookie ispresent, validate the cookie and use it if valid -- i.e. treat theinvalid query token as absent rather than fatal. Applied to bothextraction sites: the main auth flow (which then takes the normalcookie path: no token->session exchange, peer-pin check runs on thecookie) and the internal-path helper (_extract_and_validate_token).This grants nothing a cookie-only request would not already get; itonly removes the one-vote veto. When the cookie is missing or alsoinvalid, the original query-token failure reason stands so the denialnames the credential the caller actually presented. A VALID querytoken keeps today's precedence and its token->session exchange.An APP token is excluded from the fallback (new in this revision,addressing the GPT 5.6 blocking finding). An installed app's UI isserved from this same origin, so the browser attaches the dashboarduser's session cookie alongside the app's own `?token=`. Fallingback there would swap the app's scoped identity for the user's own:`app_name` comes back empty, `_enforce_app_scope` degrades to ano-op, and an app whose token merely EXPIRED would silently gain theuser's full API reach. Both extraction sites now refuse the fallbackwhen the query credential CLAIMS an app, so an expired app token isrefused as such and the app re-exchanges its secret. The claim is readunverified via `claims_an_app_unverified`, which is sound in exactlythis direction: it can only ever make the decision stricter, so aforged `app` value buys a refusal rather than a grant.The fallback broke an implicit contract that downstream consumersrelied on: handlers that read signed claims out of the caller's owntoken re-extracted it with their own fixed query-then-cookie order,correct only because that order was guaranteed to match what themiddleware validated. With the fallback it no longer does, so arequest authenticated by a BOUNDED cookie could have its bounds readfrom an unverified, attacker-settable query token -- dropping`no_refresh` and raising the mobile-link TTL ceiling to the maximum.Rather than exempt one endpoint, the middleware now publishes thecredential it actually validated as `request["auth_token"]`, at allthree authentication points (main flow + both internal-path branches),and the two consumers read that instead of re-extracting:`_caller_bounds` (mobile-link mint) uses it and fail-closes to abounded mint when none was published, and the frame-ancestors readerprefers it. That makes the invariant hold for every consumer insteadof patching the one that was found.Also drops the now-unused `_cookie_port_from_host` import from`handlers/_shared.py`, which flake8 flagged F401 after`_caller_bounds` stopped re-deriving the cookie name.Tests: fallback grants on the main flow and the internal path(both mutation-verified: they fail 403!=200 against the unpatchedcode), no-cookie and both-dead stay denied, valid query token stillwins and still exchanges; an expired APP token is refused on bothsites rather than adopting the user cookie (both mutation-verified:they return 200 without the exception); the unverified app-claimreader answers False for app-less and malformed payloads so it canonly withhold the fallback; mobile-link bounds follow the validatedcredential over a permissive query token and fail closed when nothingwas published (both mutation-verified: the old re-extraction mints anunbounded credential, KeyError: 'no_refresh').Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io> Also lands two changes the reporter asked for on top of the veto fix, both aimed at one outcome: a phone that is scanned once stays usable. 1. The Crew Companion reconcile poll now REUSES its dashboard token instead of minting a fresh one every 5-second tick. Each mint was a full link->session exchange: it registered a nonce in the bounded 50-slot ring, issued a 30-day refresh chain, and appended the consumed nonce to the persisted denylist. At that cadence the ring turned over every few minutes and evicted OTHER pending one-time links -- a phone-access QR among them, before its own 5-minute window had even lapsed, which is why a scanned link could die faster than its documented lifetime. It also meant dozens of live full-privilege sign-in links existed at any moment purely as a side effect of asking whether an app is enabled. probeEnabled() now reports 401/403 as `unauthorized` distinctly from `unknown` so a refused cached token is re-minted exactly once rather than either wedging the poll or reverting to mint-per-tick; shutdown drops the cache with the poll that owned it. 2. A THIRD QR session shape, `dashboard.qr_session_persist_across_restart` (default OFF), issues the refresh chain with NO boot claim, so one scan survives a gateway restart and is bounded by the chain's own 30-day lifetime. The existing default is untouched: the boot bound is a hard revoke needing no recorded state and was chosen deliberately, so this is opt-in. The opt-in is GATED on daemon-verified tailnet identity (`dashboard.tailscale.trust_identity` with a non-empty `allowed_logins`), and the gate is what makes the shape offerable rather than merely convenient. Behind `tailscale serve` every request reaches the gateway from 127.0.0.1 (#1762), so with identity trust off the pin is `ip:127.0.0.1` for every tailnet client and the cookie is a bearer credential any of them could replay. A session ending at the next restart bounds that exposure; one outliving the process does not. Both refusals (identity trust off, or the timed shape in force so there is no chain to carry over) log a WARNING naming the remedy rather than silently downgrading -- honouring the flag invisibly would leave the operator believing the phone survives restarts and finding out only by being signed out. The caller-bounds cap from #6033 still wins over the configured shape, and that also caps the persistent shape, which is correct rather than a gap: a credential must not outlive the session that authorized it. Reaching the persistent shape therefore also needs the authorizing session to be unbounded, which the desktop local-bootstrap mint is -- `/api/token/local` carries neither `boot` nor `no_refresh`. The three session-shape config reads are now INDEPENDENT, each with its own conservative default, instead of one all-or-nothing try block. Coupling them meant a config object missing any one attribute discarded the other two, so adding this shape silently took the existing opt-out away -- the opposite of "an unreadable override falls back to the default". Tests: persistent shape drops the boot bound; it is refused (staying boot-bound) without identity trust; it is refused when the timed shape is in force; Companion reuses its token across ticks and re-mints exactly once on a 401 (both mutation-verified -- removing the cache fails the reuse test).
5dc31cd to
4fa56f0
Compare
Round on
|
|
/ai-review override gpt 4fa56f0: Both findings are real and are accepted knowingly by the author, NOT disputed as false positives. The affected feature ships default OFF and is inert unless an operator sets trust_identity plus a non-empty allowed_logins. Residual accepted risk: an allowlisted peer other than the one that scanned can use the chain, and the gate reads configured intent rather than a live identity resolution. The binding redesign is tracked as follow-up. Merge still requires a second reviewer's approval. |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
|
/ai-review override fable 4fa56f0: Author's explicit decision to ship the two black-screen causes and the opt-in persistent session together rather than split. The Companion caching is not a rider but a second measured cause of the same symptom: its 5s mint cadence turned the 50-slot nonce ring over every few minutes and evicted the phone's QR link before its own window lapsed, 91 mints per 5 minutes measured, so the veto fix alone does not remove the defect. The description now declares all nine items and both accepted findings. |
Human judgment recorded@bolichen97 marked the fable AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
|
The black-screen fix is good: stale The persist shape still needs three things on this PR.
Without (1) the setting lies. Without (2) there is no revocation. Without (3) an operator can turn it on from the config card alone. |
Problem / Motivation
A phone signed in by scanning the tailnet QR code goes blank after roughly 30–40 minutes and has to be re-scanned. Reloading the page it was already authenticated on returns 401.
Two independent causes, both confirmed from the security event log rather than inferred from the source:
A stale
?token=vetoed a valid session cookie. Token extraction readrequest.query.get("token") or request.cookies.get(...), so the query parameter had absolute precedence. The QR URL keeps the link token in the phone's address bar; afterLINK_WINDOW_SECS(300s) that token is dead, so every later reload re-presented a dead credential that beat the live cookie. The log showsdashboard.token_auth → denied / error: token expiredfrom 127.0.0.1.Crew Companion minted a session every 5 seconds.
reconcileOnce()calledfetchLocalToken()on everyTICK_MStick with no cache. Each mint is a full link→session exchange, so each one consumes a slot in the bounded 50-slot nonce ring — measured 91 mints in 5 minutes, turning the ring over every ~3–4 minutes and evicting other pending links, including the phone's QR, before their own 5-minute window lapsed.Separately, a gateway restart signs the phone out by design (#5763). That is the right default, but on the insider auto-update channel it is frequent enough to be the dominant reason a working session ends, and there is currently no way to opt out.
Why it matters
Cause 1 makes the phone unusable as a second screen: the session is alive but every reload is refused, and the only workaround is to know to delete the query string from the address bar by hand. Cause 2 evicts pending one-time links for everyone on the machine, not just the phone — a Slack challenge link minted in the same window can be gone before it is clicked.
What changed (motivation → approach → change)
Scope is deliberately wider than the two causes, and the author owns that. An earlier revision of this description said the persistent-session feature had been split out to #6369. That is no longer true and the description was wrong for a period — the feature is in this PR, #6369 is closed as superseded, and this section now declares everything the diff ships. Nine items:
Cause 1 → the cookie is used when the query token is invalid (items 1–5). Both extraction sites now fall back instead of vetoing. Two consequences had to be handled rather than assumed away:
claims_an_app_unverified()intoken_auth.py). Without this, an expired app token would adopt the user's cookie and skip_enforce_app_scope— an app surface silently promoted to full user authority. The claim is read unverified, which is sound because it can only ever make the decision stricter.request["auth_token"], and the three places that re-derived it read that instead:_caller_bounds(handlers/_shared.py),api_auth_me(handlers/auth_refresh.py) and the frame-ancestors port read (server.py). Re-deriving with a fixed query-then-cookie order is no longer equivalent to what the middleware validated, so each site could have read an unverified value. For_caller_boundsthat is the exact ceiling-escape it exists to prevent: bounds read from an attacker-settable query token would dropno_refreshand raise the TTL ceiling to the maximum. Each site falls back to the old order only when nothing was published, and fails closed the way an unreadable payload already did.Cause 2 → the Companion reuses its token (item 6). A module-level cache plus
tokenForProbe(forceMint);probeEnabled()now maps 401/403 to"unauthorized", distinct from"unknown", so a refused token triggers exactly one re-mint rather than a mint per tick.shutdownCrewCompanion()clears the cache.Restart survival → a third, opt-in session shape (items 7–9). New
dashboard.qr_session_persist_across_restart, defaultfalse. The QR mint already chose between two shapes; a third is added rather than loosening either existing one:no_refreshbootrequire_peerMAX_REFRESH_TTL_SECSHard-gated on
dashboard.tailscale.trust_identityplus a non-emptyallowed_logins, and on the siblingqr_session_until_restartbeing ON; both refusals log a WARNING naming the missing prerequisite and fall back to boot-bound. Removing the boot bound removes the only thing bounding the session, so a new carriedrequire_peerclaim (refresh_tokens.py, carried throughtoken_auth.pyand onto both halves of every rotated pair) makesapi_auth_refreshrefuse to use such a chain while no daemon-verified peer resolves — checked ahead of all three exits that hand back a credential (grace replay, reuse detection, mint), and ahead of reuse detection specifically so an unverified caller cannot revoke a legitimate session by replaying one consumed token.#6033's caller-bounds cap still wins over the configured shape and caps the persistent one too, which is correct: a credential must not outlive the session that authorized it.
Also drops a now-unused
_cookie_port_from_hostimport (flake8 F401) and regeneratesconfig-baseline.jsonfor the new key.Two accepted security findings — read before approving
GPT 5.6 raised two BLOCKING findings against items 7–9 that are overridden, not fixed. They are legitimate; I verified both:
_verified_peer()checks that some daemon-verified peer resolves and is allowlisted, not that it is the same peer the session was established for. Under the defaultpin_scope: nodea pin is per-device, so this is weaker thanbind_token_peer's treatment of ordinary sessions: a stolen refresh cookie is usable by any allowlisted peer, including another device of the same login.trust_identityandallowed_logins, not whether identity resolution is actually live, so a config naming identity trust while the daemon is not resolving peers still admits the persistent shape.The setting is off by default and inert unless an operator enables it and configures identity trust, so no existing deployment changes behaviour on merge. What is not bounded is the case the feature exists for: an operator who turns it on gets a 30-day credential whose only stated bound is identity, and both findings weaken exactly that bound. The correct fix — carry the peer pin key on the chain and compare it for equality, establish it at the link→session exchange rather than the QR mint, and require a live resolution rather than a configured value — is follow-up work.
Tests
test/test_token_auth.py— the cookie is used when the query token is expired; an app-claiming query token does not adopt the cookie (×2);claims_an_app_unverifiedunit coverage.test/test_mobile_login_link.py— link bounds come from the validated credential, not the query token; they fail closed when nothing was published.test/test_auth_refresh_handlers_cov80.py—api_auth_mereads the published credential and falls back when none was published; an identity-bound chain is refused (and not revoked) when no peer resolves, including on the grace-replay path with noSet-Cookieon the response; therequire_peerclaim survives rotation, is absent by default, and fails closed on an undecodable payload.test/test_tailnet_mobile.py— the existing caller-bounds tests updated to model the published credential; the persistent shape dropsbootand carriesrequire_peer; it is refused without identity trust and refused when the timed shape is in force.website/electron/crew-companion/test/tokenReuse.test.js(new) — the token is reused across ticks; a 401 causes exactly one re-mint.Mutation-verified: both fallback guards, the Companion cache, and the grace-replay peer check (with the check removed that test fails
assert 200 == 401, i.e. it demonstrably exercises the path that serves a cached credential).Manual verification
Reproduced the original symptom on a real tailnet before the fix (phone blank after ~35 minutes,
token expireddenials in the event log) and confirmed the nonce churn at 91 mints / 5 minutes. Post-fix verification over a multi-hour window is still outstanding. Items 7–9 are unverified on real hardware: the identity path needs a livetailscaledand a second device, and neither the happy path nor the refusal of a non-allowlisted peer has been exercised outside unit tests.Screenshots / video
N/A — no user-visible UI change beyond the new setting's own label and help text, which is config-driven.
Related Issues
No linked issue: reported directly in chat. Related to #1762 (behind
tailscale serveevery request arrives from 127.0.0.1, so the token is the only real credential) and #5763 (which introduced the boot-bound session items 7–9 make optional).Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)dashboard.qr_session_persist_across_restartand its prerequisites. Deliberately deferred to the round that settles the peer-binding design, so the doc is not written twice.