feat(gateway): transparent auth for verified tailnet peers - #6652
feat(gateway): transparent auth for verified tailnet peers#6652nodomain wants to merge 1 commit into
Conversation
9df6b25 to
60e3faa
Compare
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
1 similar comment
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
…owlist When trust_identity is enabled and the request arrives from a daemon-verified peer on the allowed_logins list, issue a boot-bound session cookie directly without requiring a prior token login. The session is pinned to the peer identity and dies on gateway restart (same security model as QR sessions). Closes kirodotdev#6132
60e3faa to
6ac1564
Compare
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
1 similar comment
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
bolichen97
left a comment
There was a problem hiding this comment.
Description / code mismatch
The Description presents this as a session granted to a "daemon-verified peer" arriving "through tailscale serve", but the code grants it to any caller that can reach the loopback port and name an allowlisted tailnet address in a forwarded header, and it lands that new admission path without amending the RFC that lists it as a non-goal or the spec that documents token auth as the only way a session starts.
1. Transparent auth makes a forwarded-header address the sole credential, so any local process can obtain a full dashboard session
The Description says —
When
trust_identityis enabled andallowed_loginsis non-empty, a request arriving throughtailscale servefrom a daemon-verified peer on the allowlist now receives a boot-bound session cookie directly, without requiring a prior token login.
and, under Security model:
All conditions must be met: trust enabled + allowlist non-empty + peer verified + peer on list
The code does — the new block at src/kiro_crew/dashboard/token_auth.py:2447-2572 runs on a request that presents no credential at all. Its gate is src/kiro_crew/dashboard/token_auth.py:2461-2466; identity is assigned at src/kiro_crew/dashboard/token_auth.py:2509-2512 and the session cookie is set at src/kiro_crew/dashboard/token_auth.py:2534-2542. "Arriving through tailscale serve" is not a condition the code can check: nothing about the request proves it came from the proxy. What resolve_forwarded_peer() actually requires (src/kiro_crew/dashboard/tailnet.py:1052-1106) is a loopback immediate peer, exactly one X-Forwarded-For value, that value inside the tailnet ranges, and a tailscale whois that resolves it. Every one of those is satisfiable by any local process: the immediate peer is loopback for any local connection, and the header is attacker-chosen. "Peer verified" therefore means "the address the caller typed resolves in the daemon", not "the caller is that peer".
Two further divergences from the stated model:
- The Description says the block sits "between the logout bypass and the token-extraction block". That placement is what defeats the existing invariant: main only resolves a peer once a request presents a credential, and the reason is stated in-code at
src/kiro_crew/dashboard/token_auth.py:2133-2142— "a credential-less request (static assets, probes) can never bind or satisfy a pin, so resolving identity for it would only hand an unauthenticated local caller a header-driven daemon spawn". That gate (src/kiro_crew/dashboard/token_auth.py:2129-2151) is untouched by the diff; the new block simply reachesresolve_forwarded_peerbefore it, so an unauthenticated caller can now spray distinct addresses on any non-bypassed/apipath and force onetailscale whoissubprocess per cache miss, each holding_whois_lockand asubprocess_executor()worker for the daemon timeout. The Description does not mention that this changes the credential-less contract. - The block re-spells
trust_identity and allowed_loginsinline instead of askingTailnetTrust.enforces_identity, whose documented purpose is the opposite (src/kiro_crew/dashboard/tailnet.py:927-935): "The one predicate every gate asks, so 'may this be pinned', 'may this rotate' and 'may this authenticate' cannot answer differently."
Risk — unauthenticated local auth bypass. With trust_identity on and a non-empty allowlist, any process that can open a TCP connection to the gateway's loopback port — a second user on a shared host, a malicious npm/pip postinstall running as the operator, any local service reachable through socat or ssh -R — sends one credential-less request with X-Forwarded-For: <allowlisted device's 100.x address> (readable locally from tailscale status) and receives a session cookie as that login with is_dashboard_user=True. The boot binding and peer pin do not help: the cookie is pinned to the same forged identity the caller keeps asserting, so it satisfies _extract_and_validate_token plus _check_pin on the next hop and /api/chat, /api/spawn and the rest of the agent surface follow.
Required change — do not let the forwarded-header identity be the only credential. Either keep a bearer in the loop the way the shipped phone-access flow does (docs/system-specs/features/dashboard-token-auth.md:148-152: "The initial QR URL is a claimless, five-minute enrollment bearer"), or make the Serve transport itself provable — a dedicated listener or unix socket the proxy alone connects to, or a secret only the proxy holds — before granting. If the intended trust model really is "any loopback caller that can name an allowlisted tailnet address is that user", state that plainly in the Description and in the spec rather than describing it as a verified peer arriving through tailscale serve.
2. A new admission path lands against an RFC non-goal, with no spec or RFC amendment and no audit-vocabulary entry
The Description says —
src/kiro_crew/dashboard/token_auth.py— New code path in the auth middleware, inserted between the logout bypass and the token-extraction block
The code does — it adds a fifth way to obtain a dashboard session, and the RFC this feature comes from lists that as out of scope. docs/request-for-change/rfc-tailnet-dashboard-access.md:176-177, § Non-goals: "Replacing token auth. Identity trust is an additional, opt-in pin — the token path remains the only path when identity cannot be established", with goal 4 at docs/request-for-change/rfc-tailnet-dashboard-access.md:159: "Every failure mode falls back to the existing token path. Nothing this RFC adds can lock a user out or grant access on ambiguity." The PR makes identity trust an admission path rather than a pin, and amends neither the RFC nor the spec. The spec still tells the next reader that every session starts from a bearer — docs/system-specs/features/dashboard-token-auth.md:5: "An aiohttp middleware validates the token on every GATED request (query param or cookie fallback) and sets a session cookie on first use". The in-code invariant at src/kiro_crew/dashboard/token_auth.py:2133-2142 is reversed for the trust_identity + allowlist configuration, and the test that pinned it (test_credential_less_request_never_reaches_the_daemon) is rescoped to trust_identity=False rather than replaced by one that pins the new rule. Separately, the block emits a tailnet_transparent_auth SEL operation that exists nowhere else in the tree — main emits only tailnet_peer_auth (src/kiro_crew/dashboard/token_auth.py:2161, :2195) and tailnet_peer_bind (:2279, :2830) — and the spec's SEL table at docs/system-specs/features/dashboard-token-auth.md:650-657 stays silent on all three, so the audit vocabulary remains incomplete for a third tailnet operation.
Risk — the repository's same-commit spec obligation is not met for a change to the authentication contract. A reader of dashboard-token-auth.md still believes no session can begin without a bearer, the RFC still says the token path is the only path when identity cannot be established, and SEL alerting keyed on documented outcome names does not see tailnet_transparent_auth. The divergent local predicate is the drift enforces_identity exists to prevent, even though its current direction happens to be the safe one: a partially-parsed allowlist under identity_unknown with trust_identity off skips the block.
Required change — either amend docs/request-for-change/rfc-tailnet-dashboard-access.md and docs/system-specs/features/dashboard-token-auth.md in this same commit to document credential-less transparent auth — its preconditions, its threat model against a local forger of X-Forwarded-For, and the new tailnet_transparent_auth SEL rows — or drop the credential-less grant. Ask the shared tailnet_trust.enforces_identity predicate instead of re-deriving trust_identity and allowed_logins locally.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
@nodomain Thanks for keeping this open. Here is where it stands against current Already landed: the session shape this PR mints into is on Still missing, and only this PR provides it: credential-less admission itself. Nothing on Could you narrow the PR to that remaining piece and rebase? Three concrete blockers:
Docs need a pass too: Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
Summary
Implements transparent authentication for verified tailnet peers, closing #6132.
When
trust_identityis enabled andallowed_loginsis non-empty, a request arriving throughtailscale servefrom a daemon-verified peer on the allowlist now receives a boot-bound session cookie directly, without requiring a prior token login.Changes
src/kiro_crew/dashboard/token_auth.py— New code path in the auth middleware, inserted between the logout bypass and the token-extraction block:mc_token_*/mc_refresh_*cookies)resolve_forwarded_peer()login_allowed()— denies if not listedbootclaim =current_boot_id()) so the session dies on gateway restartbind_token_peer()and sets the session cookie + refresh chaintest/test_token_auth.py— Updated existingtest_credential_less_request_never_reaches_the_daemon(now scoped totrust_identity=False) and added 4 new tests:test_transparent_auth_issues_session_cookie— happy pathtest_transparent_auth_denies_peer_not_on_allowlist— deny pathtest_transparent_auth_skipped_when_credential_present— normal flow preservedtest_transparent_auth_skipped_when_no_allowlist— guard conditionSecurity model
peer_pin_key()— replay from a different node is rejectedrequire_peer=Trueso rotation preserves the bindingTesting
326 tests pass (264 token_auth + 62 tailnet_peer), including 5 new/updated tests covering the transparent auth paths.