Skip to content

fix(dashboard): stale query token no longer vetoes a valid session cookie - #6194

Merged
bolichen97 merged 1 commit into
mainfrom
fix/query-token-cookie-fallback
Aug 28, 2026
Merged

fix(dashboard): stale query token no longer vetoes a valid session cookie#6194
bolichen97 merged 1 commit into
mainfrom
fix/query-token-cookie-fallback

Conversation

@bolichen97

@bolichen97 bolichen97 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. A stale ?token= vetoed a valid session cookie. Token extraction read request.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; after LINK_WINDOW_SECS (300s) that token is dead, so every later reload re-presented a dead credential that beat the live cookie. The log shows dashboard.token_auth → denied / error: token expired from 127.0.0.1.

  2. Crew Companion minted a session every 5 seconds. reconcileOnce() called fetchLocalToken() on every TICK_MS tick 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:

  • App-scoped tokens are excluded from the fallback (claims_an_app_unverified() in token_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.
  • The validated credential is now published as 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_bounds that is the exact ceiling-escape it exists to prevent: bounds read from an attacker-settable query token would drop no_refresh and 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, default false. The QR mint already chose between two shapes; a third is added rather than loosening either existing one:

shape claims bounded by
timed (opt-out) no_refresh a fixed TTL, no renewal
until-restart (default) boot this process's lifetime
persistent (new, opt-in) require_peer the refresh chain's own 30-day MAX_REFRESH_TTL_SECS

Hard-gated on dashboard.tailscale.trust_identity plus a non-empty allowed_logins, and on the sibling qr_session_until_restart being 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 carried require_peer claim (refresh_tokens.py, carried through token_auth.py and onto both halves of every rotated pair) makes api_auth_refresh refuse 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_host import (flake8 F401) and regenerates config-baseline.json for 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:

  1. Refresh accepts a different allowlisted peer. _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 default pin_scope: node a pin is per-device, so this is weaker than bind_token_peer's treatment of ordinary sessions: a stolen refresh cookie is usable by any allowlisted peer, including another device of the same login.
  2. Persistence trusts inactive identity configuration. The gate reads the configured values trust_identity and allowed_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_unverified unit 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.pyapi_auth_me reads 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 no Set-Cookie on the response; the require_peer claim 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 drops boot and carries require_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 expired denials 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 live tailscaled and 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 serve every 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

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated — not done: the remote-and-mobile guide needs a line for dashboard.qr_session_persist_across_restart and its prerequisites. Deliberately deferred to the round that settles the peer-binding design, so the doc is not written twice.
  • No secrets, credentials, or internal references in the diff

@bolichen97
bolichen97 requested a review from a team as a code owner August 27, 2026 02:10
@bolichen97
bolichen97 requested a review from iamwhatever August 27, 2026 02:10
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 27, 2026
@bolichen97
bolichen97 enabled auto-merge (squash) August 27, 2026 02:12
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 4fa56f00d9e4a264e3d169fc52c4abe1fe56f41d — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • The persistent shape ships with both accepted findings weakening its only stated bound: _verified_peer() accepts any allowlisted peer ("checks that some daemon-verified peer resolves… not that it is the same peer"), so a stolen refresh cookie works from any allowlisted device for 30 days, and the gate trusts configured rather than live identity. Off-by-default contains it, but the operator who opts in gets exactly the weak version of the guarantee the setting's own help text sells.
  • No revocation story once minted: require_peer chains carry no boot claim, so turning qr_session_persist_across_restart back OFF only affects new QR mints — existing phone sessions ride out their 30 days unless the operator disables trust_identity entirely. Restart was the revoke; this removes it without a replacement.
  • Reverting the veto fix is now entangled with the feature (one commit, nine items); the deferred doc for the new key compounds this — an operator can enable it today with only the config help text as guidance.

Suggestions

  • Have _verified_peer also read qr_session_persist_across_restart, so switching the flag off refuses all require_peer rotations — a one-knob kill switch that closes the revocation gap within this PR's shape.
  • Land the same-peer pin (carry the pin key on the chain, compare equality) before the setting is documented/advertised; old require_peer chains without a pin can simply be refused, costing one re-scan.

[DESIGN-REVIEWED] 4fa56f0

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ human override accepted

Human judgment by @bolichen97 overrides the GPT 5.6 finding for 4fa56f00d9e4a264e3d169fc52c4abe1fe56f41d; the recorded reason is authoritative for this commit.

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:
/ai-review override gpt 4fa56f00d9e4a264e3d169fc52c4abe1fe56f41d: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 27, 2026
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ human override accepted

Reviewed 4fa56f00d9e4a264e3d169fc52c4abe1fe56f41d — this comment is updated in place on each push.

Human judgment by @bolichen97 overrides the Opus 4.8 finding for 4fa56f00d9e4a264e3d169fc52c4abe1fe56f41d; the recorded reason is authoritative for this commit.

Verdict recorded from an authorized human decision for commit 4fa56f00d9e4a264e3d169fc52c4abe1fe56f41d.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 4fa56f00d9e4a264e3d169fc52c4abe1fe56f41d: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 4fa56f00d9e4a264e3d169fc52c4abe1fe56f41d — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All counts are in hand: no unfixed siblings of the query-then-cookie re-extraction remain (4 request.query.get("token") sites: 3 converted, 1 presence-only bool), and the new symbols each have real consumers. The riding-along feature (persistent QR sessions) has a named harm, so the BLOCK exception does not apply. Final review:

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 ships

Intent: keep a scanned-in phone signed in instead of silently 401-ing after ~30 minutes — a FIX (per its own fix(dashboard): title).

  1. A reload carrying a dead ?token= now signs in with the still-valid cookie — the fix; justified.
  2. The same rule on polled internal paths — the fix; justified.
  3. An expired app token is still refused rather than adopting the user's cookie — justified consequence.
  4. Handlers read the credential the middleware validated (request["auth_token"], 3 readers) — justified consequence.
  5. /api/auth/me reports expiry from that credential, cookie fallback kept — justified.
  6. Companion reuses one token instead of minting every 5s; one re-mint on refusal — the second fix; justified.
  7. New setting: phone sign-in survives gateway restarts (default off) — rides along; declared.
  8. New require_peer claim; renewal refused while no tailnet peer resolves — rides along, part of 7.
  9. Loud warning fallback when persist prerequisites are missing — part of 7.

Watch

  • Items 7–9 ride along in a FIX. Their harm is named (feat(dashboard): scope a phone's dashboard session to the gateway process #5763 restart sign-outs on the insider channel), so they earn existence — but the description concedes both accepted findings "weaken exactly" the identity bound the feature's safety rests on ("a stolen refresh cookie is usable by any allowlisted peer"), with the real fix "follow-up work". The feature ships before its own security argument does.
  • The help text sells the bound the findings void: "the session pins to a verified peer instead" (loader.py:2970 metadata) vs. the description's "not that it is the same peer". An operator reads a per-device pin that is actually a per-allowlist one.

Subtractions

  • Defer items 7–9 (drop qr_session_persist_across_restart, require_peer, _verified_peer, the three-way shape branch in tailnet_mobile.py) to the follow-up that carries the peer-pin key on the chain — landing them together removes the window where the config help overstates the bound. Items 1–6 fix both reported causes on their own.
  • Failing that, shrink the loader.py / config-baseline.json help text claim from "pins to a verified peer" to "requires an allowlisted verified peer" so the shipped words match the shipped check.

[FIRST-PRINCIPLES-REVIEWED] 4fa56f0

@bolichen97
bolichen97 force-pushed the fix/query-token-cookie-fallback branch 2 times, most recently from 5bc644b to 8f36335 Compare August 27, 2026 02:33
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 27, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • BLOCKING — token_auth.py:2425 Cookie fallback authenticates claims from an invalid query tokenfixed in 8f36335eeb24d4d8cf5f17a5a520919f0902c212, at the root rather than at the endpoint.

Bounded cookie + forged ?token= -> cookie fallback -> mobile-link reads forged query claims -> unrestricted credential minted. Fix: Exclude /api/auth/mobile-link from this fallback.

The finding holds, and it named the real mechanism. _caller_bounds re-extracted the caller's token with its own fixed query-then-cookie order — correct only because that order was guaranteed to match the credential the middleware validated. The fallback breaks that guarantee, so a request authenticated by a bounded cookie could have its bounds read from the unverified query token, dropping no_refresh and raising the TTL ceiling to MAX_SESSION_TTL_SECS.

I did not take the suggested fix (exempting /api/auth/mobile-link), because the exemption leaves the class open: the invariant "downstream consumers may re-derive the caller's token" is now false for every consumer, and a grep for request.query.get("token") found a second one already relying on it (server.py:825, the frame-ancestors parent-port reader). Exempting one endpoint would have left that one wrong and made the next such consumer wrong by default.

Instead the middleware now publishes the credential it actually validated as request["auth_token"], at all three authentication points (main flow + both internal-path branches), and the consumers read it:

  • _caller_bounds reads only the published credential and fail-closes to a bounded mint (no_refresh, default ceiling) when none was published — so the unverified read is not merely reordered, it is gone.
  • server.py's frame-ancestors reader prefers the published credential, keeping its existing extraction only as the no-credential fallback.

Two tests, both mutation-verified against the old re-extraction (which fails with KeyError: 'no_refresh', i.e. it really does mint the unbounded credential the finding describes):

  • test_mobile_link_bounds_come_from_the_validated_credential_not_the_query_token — bounded cookie + permissive query token → bounds preserved, ceiling ≤ 600s.
  • test_mobile_link_bounds_fail_closed_when_no_credential_was_publishedno_refresh, no boot.

The pre-existing test_mobile_link_bounds_come_from_the_query_token_not_a_stray_cookie still passes unchanged, so the normal query-token-preferred case is intact.

Also in this revision: black --target-version py310 formatting on the touched files (the Backend Lint red), and a rebase onto current main.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision merge conflict Branch has merge conflicts with its base — author must resolve before merge and removed readiness: checking Automated validation is still running labels Aug 27, 2026
iamwhatever
iamwhatever previously approved these changes Aug 27, 2026
@bolichen97
bolichen97 force-pushed the fix/query-token-cookie-fallback branch from 8f36335 to 784a6be Compare August 27, 2026 05:37
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: passed Eligible automated validation passed for the current revision readiness: checking Automated validation is still running merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Aug 27, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator Author

/ai-review override gpt 784a6be ack on purpose

@github-actions

Copy link
Copy Markdown
Contributor

AI-review override not recorded. Use /ai-review override <fable|gpt|all> <current-sha>: <one-sentence reason>.

@bolichen97
bolichen97 force-pushed the fix/query-token-cookie-fallback branch from 784a6be to 24143d4 Compare August 27, 2026 09:13
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Aug 27, 2026
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 27, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator Author

Scope restored to one PR — and two BLOCKING findings are being accepted, not resolved

Reversing the split announced earlier: dashboard.qr_session_persist_across_restart is back in this PR, and #6369 is redundant. Head is now 5dc31cdc841351577ed61f27acd610f1ad5222e8 — 14 files, +1036/−40, byte-identical in content to the pre-split state.

Read this before approving. Two GPT 5.6 BLOCKING findings are being overridden. They are legitimate, I verified both myself, and neither is fixed:

  1. auth_refresh.py — refresh accepts a different allowlisted peer. _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 default pin_scope: node a pin is per-device (ts:node:<login>|<node>), so this is weaker than the codebase's own standard — bind_token_peer pins ordinary sessions to a specific key. A stolen refresh cookie is usable by any allowlisted peer, including another device of the same login.

  2. tailnet_mobile.py — persistence trusts inactive identity configuration. The gate reads the configured values trust_identity and allowed_logins, not whether identity resolution is actually live. A config that names identity trust while the daemon is not resolving peers still admits the persistent shape, so the gate encodes intent rather than fact.

Why the residual risk is bounded, and where it is not. The setting is false by default and inert unless an operator turns it on and configures tailnet identity trust with a non-empty allowlist — so no existing deployment changes behaviour on merge. What is not bounded is the case this feature is built for: an operator who does enable it gets a 30-day credential whose only stated bound is identity, and both findings weaken exactly that bound. Anyone approving should be deciding on that, not on the default-off framing alone.

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, black --target-version py310 over all 11 changed Python files, the baseline-aware black gate, mypy (1129 files) all clean; 511 tests green across the eight auth/session/config files. The branch is 4 commits behind main and GitHub reports mergeable: true; it is one squashed commit on its base.

@bolichen97

Copy link
Copy Markdown
Collaborator Author

/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.

@bolichen97

Copy link
Copy Markdown
Collaborator Author

/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.

@github-actions

Copy link
Copy Markdown
Contributor

Human judgment recorded

@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for 5dc31cdc841351577ed61f27acd610f1ad5222e8.

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.

This decision applies only to this commit. A new push requires a new judgment.

@github-actions

Copy link
Copy Markdown
Contributor

Human judgment recorded

@bolichen97 marked the fable AI finding as false positive, not applicable, or explicitly accepted for 5dc31cdc841351577ed61f27acd610f1ad5222e8.

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.

This decision applies only to this commit. A new push requires a new judgment.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Aug 27, 2026
…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).
@bolichen97
bolichen97 force-pushed the fix/query-token-cookie-fallback branch from 5dc31cd to 4fa56f0 Compare August 27, 2026 19:40
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 27, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator Author

Round on 4fa56f00d — one real test failure fixed, and the earlier Fable BLOCKs explained

Backend Tests shard 2 (3.10 and Windows both) — fixed. test_error_code_contract.py::test_no_new_error_response_without_a_code flagged auth_refresh.py: missing_code 8 -> 9. The peer_identity_unverified refusal I added returned {"error": "peer_identity_unverified"} — a machine identifier in the field the dashboard renders verbatim into a localized UI, which is exactly what that ratchet exists to prevent. It now returns localizable prose in error and the identifier in code, and the two tests assert ["code"] rather than the whole body. Reproduced locally before fixing and the contract test passes on this head.

The two Fable BLOCKs on the previous head were mine, and were about this PR's description, not its code. Design Review and First Principles both objected that the body still claimed the cross-restart session feature had been split out to #6369 while the diff shipped all of it — a stale description left over from the split that was reverted at the author's request. That was a fair and serious objection: an approver reading it would have believed the feature absent. The body has been rewritten to declare all nine items, and it now carries a "read before approving" section stating the two accepted security findings in full. #6369 is closed as superseded.

Verification on this head: flake8 7.1.0, isort 6.0.0, black --target-version py310 over all 11 changed Python files, the baseline-aware black gate, and mypy (1129 files) all clean; 486 tests green across the auth/session/config/contract files, including the error-code contract itself.

@bolichen97

Copy link
Copy Markdown
Collaborator Author

/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.

@github-actions

Copy link
Copy Markdown
Contributor

Human judgment recorded

@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for 4fa56f00d9e4a264e3d169fc52c4abe1fe56f41d.

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.

This decision applies only to this commit. A new push requires a new judgment.

@bolichen97

Copy link
Copy Markdown
Collaborator Author

/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.

@github-actions

Copy link
Copy Markdown
Contributor

Human judgment recorded

@bolichen97 marked the fable AI finding as false positive, not applicable, or explicitly accepted for 4fa56f00d9e4a264e3d169fc52c4abe1fe56f41d.

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.

This decision applies only to this commit. A new push requires a new judgment.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 27, 2026
@kyleseaman

Copy link
Copy Markdown
Collaborator

The black-screen fix is good: stale ?token= no longer vetoes a live cookie, an expired app token cannot adopt the user cookie, request["auth_token"] is what bounds/me/frame-ancestors read, Companion remints once on 401/403.

The persist shape still needs three things on this PR. _verified_peer() accepts any allowlisted peer, not the one that scanned, and the mint gate reads configured trust_identity + allowed_logins, not a live resolution. A stolen refresh cookie then works from another device of the same login for 30 days, and turning the setting back off does not revoke those chains. The peer-pin redesign (carry the key, compare at the phone’s link→session exchange) can stay follow-up. What this PR still needs:

  1. Say what the check actually is. The help text and the tailnet_mobile comment still read as a per-device pin. Write “requires an allowlisted verified peer,” not a pin to the scanner.
  2. Make the setting a kill switch. If qr_session_persist_across_restart is off, refuse require_peer rotations — and, if you want revoke rather than “renewal stops,” refuse require_peer access tokens too. Restart used to be revoke; this removes it and does not replace it. An already-issued access cookie otherwise lives until its own TTL (up to 20h).
  3. Document the key in the remote-and-mobile guide: the two prerequisites and the two accepted weakenings.

Without (1) the setting lies. Without (2) there is no revocation. Without (3) an operator can turn it on from the config card alone.

@bolichen97
bolichen97 merged commit ad8344e into main Aug 28, 2026
69 of 71 checks passed
@bolichen97
bolichen97 deleted the fix/query-token-cookie-fallback branch August 28, 2026 00:01
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 28, 2026
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.

4 participants