feat: pin tunnel sessions to daemon-verified tailnet peer (#1762) - #2411
Conversation
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings that meet the bar. The PR is a large but carefully-constructed identity-pinning feature: peer resolution is fail-closed at every RFC condition, the blocking No findings. [OPUS-REVIEWED] 304fa9e Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS A real, RFC-specified defect (tunnel-shared session pin, misattributed audit) fixed at its root with fail-closed identity, opt-in narrowing-only config, and fully reversible in-memory state. Watch
Note for the human reviewer: the CHANGELOG hunks in the [DESIGN-REVIEWED] 304fa9e |
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: |
d1eaae5 to
a8e1ca0
Compare
|
🤖 Kiro Crew Auto-Pipeline — response to the review round on GPT 5.6 blocking findings1. "Tailnet allowlist fails open when peer verification fails" — rebutted (by design, per RFC).
2. "Refresh rotation transfers the node pin to the requester" — rebutted as in-scope behaviour; hardening tracked in #2417. 3. "Request-triggered execution trusts user-writable CLI locations" — accepted and fixed in Design Review suggestions (both applied in
|
a8e1ca0 to
19e2713
Compare
|
🤖 Kiro Crew Auto-Pipeline — response to the GPT round on "Missing session bindings are accepted after restart" — accepted and fixed in The other two findings are unchanged from the previous round and were rebutted with evidence in the comment above (#2411 (comment)): the allowlist-on-daemon-failure behaviour is the RFC's explicit degrade-not-deny design (candidacy is attacker-controlled for local forgers, and |
Behind every documented tunnel (cloudflared, ngrok, tailscale serve) the
session IP pin binds to the tunnel's loopback address, so one pin is
satisfied by anyone behind the same tunnel and the audit trail records
127.0.0.1. Implements RFC rfc-tailnet-dashboard-access Phase 3 (§2–§3.1):
- dashboard/tailnet.py: resolve_forwarded_peer() — a peer resolves only
when the immediate peer is loopback, trust_identity is on with a
non-empty allowed_logins, X-Forwarded-For carries exactly one address
inside the tailnet ranges, and tailscale whois (vetted CLI paths, hard
timeout, bounded TTL cache on the dedicated subprocess executor)
confirms it; a Tailscale-User-Login header is corroboration only and a
disagreement rejects. Transient daemon failures cache ~2s so a startup
blip clears fast. POSIX-only per RFC OQ4; Windows degrades to the token
path.
- dashboard/token_auth.py: the session pin generalises to a peer key —
ip:<addr> byte-for-byte today, ts:node:<login>|<node> /
ts:login:<login> for a verified peer ('|' is forbidden inside
components, so keys cannot collide; ACL-tagged nodes are always
node-scoped). allowed_logins enforced at the auth decision, including
the /api/auth/refresh bypass; the peer-pin check also runs on the
internal cookie-auth branches; pin-mismatch denials name device
identity or an unverified identity, never a misleading 'IP mismatch';
resolution is gated on the request presenting a credential so
unauthenticated requests never reach the daemon; SEL audit attributes
the resolved login and records the identity bind.
- handlers/auth_refresh.py: a rotated access token re-binds to the
verified peer key so rotation cannot launder a node pin.
- server.py: an enterprise ceiling pinning capabilities.tailnet_origin
off also forces identity trust off at startup (audited).
- config/loader.py: trust_identity/allowed_logins/pin_scope with
narrowing-only validation (trust with an empty allowlist is refused;
an unrecognised pin_scope falls back to node).
- is_direct_local_request() is unchanged: a verified peer still gets
read_only on the config surfaces (RFC §5, regression-tested).
- Docs: guide opt-in instructions, RFC status, token-auth spec.
Adversarially reviewed pre-push by a dual model-pinned fleet; all
blocking findings and advisories addressed.
Closes #1762
Post-review CI round: spawn-audit allowlist follows the _run_json_detail
rename; peer-resolution tests pin IS_POSIX so the Windows shard runs the
same matrix; _cli_path refuses gateway-user-writable CLI locations
(Homebrew prefixes are not root-owned); the trust construction and
governance force-off live in one shared tailnet.governed_tailnet_trust()
helper for both startup surfaces; config metadata notes the
restart-to-apply behaviour. Refresh-chain peer binding tracked as a
follow-up (#2417).
Restart hardening: the in-memory binding map empties on restart, so the
first request that carries a VERIFIED peer identity re-claims the pin
for an unbound surviving cookie (SEL-audited); unbound cookies without a
verified peer keep today's semantics.
The re-pin lives in the shared pin check so the internal cookie-auth
branches get it too, and the CLI trust check refuses group/world-writable
paths for everyone and requires root ownership when running as root.
19e2713 to
304fa9e
Compare
|
🤖 Kiro Crew Auto-Pipeline — response to the GPT round on "Restarted cookies remain unbound on mixed internal routes" — accepted and fixed. The first-use re-pin now lives inside the shared "Root bypass defeats the planted-binary defense" — accepted and fixed. The trust check no longer has a root exemption: group/world-writable binaries or directories are refused for everyone, and a root-run gateway additionally requires root ownership of both (for root the Refresh rotation (third round of the same finding) — the rebuttal stands as posted twice above with RFC citations: rotation binding to the presenting, daemon-verified, allowlisted peer is a strict tightening over the never-bound baseline (independently confirmed by the Opus lane), the stolen-refresh-cookie residual is the refresh cookie's pre-existing documented bearer property, and full chain binding is tracked in #2417 because it changes the persisted chain format and the refresh availability contract (RFC OQ2). Requiring "the existing session pin to match before rotating" is not implementable at this endpoint: the refresh handler never sees the old access token (the refresh cookie is the only credential presented), which is exactly why the chain-level binding in #2417 is the right vehicle. |
|
🤖 Kiro Crew Auto-Pipeline — final response on the remaining GPT finding ( The proposed fix — "deny unbound node-scoped cookies and refresh rotation until the original peer key can be verified" — is impossible by construction and forbidden by the design:
Cross-lane evidence: the Opus CI lane reviewed the same heads and found no blocking issues, explicitly falsifying this class ("strict tightening, not a removed guard"); the Design lane returned PASS. Overriding per the process below; the override and this rationale remain on the record for the human merger. |
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. |
…#1762) (kirodotdev#2411) Behind every documented tunnel (cloudflared, ngrok, tailscale serve) the session IP pin binds to the tunnel's loopback address, so one pin is satisfied by anyone behind the same tunnel and the audit trail records 127.0.0.1. Implements RFC rfc-tailnet-dashboard-access Phase 3 (§2–§3.1): - dashboard/tailnet.py: resolve_forwarded_peer() — a peer resolves only when the immediate peer is loopback, trust_identity is on with a non-empty allowed_logins, X-Forwarded-For carries exactly one address inside the tailnet ranges, and tailscale whois (vetted CLI paths, hard timeout, bounded TTL cache on the dedicated subprocess executor) confirms it; a Tailscale-User-Login header is corroboration only and a disagreement rejects. Transient daemon failures cache ~2s so a startup blip clears fast. POSIX-only per RFC OQ4; Windows degrades to the token path. - dashboard/token_auth.py: the session pin generalises to a peer key — ip:<addr> byte-for-byte today, ts:node:<login>|<node> / ts:login:<login> for a verified peer ('|' is forbidden inside components, so keys cannot collide; ACL-tagged nodes are always node-scoped). allowed_logins enforced at the auth decision, including the /api/auth/refresh bypass; the peer-pin check also runs on the internal cookie-auth branches; pin-mismatch denials name device identity or an unverified identity, never a misleading 'IP mismatch'; resolution is gated on the request presenting a credential so unauthenticated requests never reach the daemon; SEL audit attributes the resolved login and records the identity bind. - handlers/auth_refresh.py: a rotated access token re-binds to the verified peer key so rotation cannot launder a node pin. - server.py: an enterprise ceiling pinning capabilities.tailnet_origin off also forces identity trust off at startup (audited). - config/loader.py: trust_identity/allowed_logins/pin_scope with narrowing-only validation (trust with an empty allowlist is refused; an unrecognised pin_scope falls back to node). - is_direct_local_request() is unchanged: a verified peer still gets read_only on the config surfaces (RFC §5, regression-tested). - Docs: guide opt-in instructions, RFC status, token-auth spec. Adversarially reviewed pre-push by a dual model-pinned fleet; all blocking findings and advisories addressed. Closes kirodotdev#1762 Post-review CI round: spawn-audit allowlist follows the _run_json_detail rename; peer-resolution tests pin IS_POSIX so the Windows shard runs the same matrix; _cli_path refuses gateway-user-writable CLI locations (Homebrew prefixes are not root-owned); the trust construction and governance force-off live in one shared tailnet.governed_tailnet_trust() helper for both startup surfaces; config metadata notes the restart-to-apply behaviour. Refresh-chain peer binding tracked as a follow-up (kirodotdev#2417). Restart hardening: the in-memory binding map empties on restart, so the first request that carries a VERIFIED peer identity re-claims the pin for an unbound surviving cookie (SEL-audited); unbound cookies without a verified peer keep today's semantics. The re-pin lives in the shared pin check so the internal cookie-auth branches get it too, and the CLI trust check refuses group/world-writable paths for everyone and requires root ownership when running as root.
Summary
Implements RFC
rfc-tailnet-dashboard-accessPhase 3 (§2–§3.1). Behind every documented tunnel (cloudflared, ngrok,tailscale serve) the session IP pin binds fromrequest.remote, which is the tunnel's loopback address — so one pin is satisfied by anyone behind the same tunnel for up to the 20h session TTL, and the SEL audit trail records127.0.0.1as the caller. Phase 1 (PR #1761) made that state visible; this PR repairs it for the one provider whose local daemon can verify a peer.What changes
dashboard/tailnet.py—resolve_forwarded_peer()(RFC §2, all conditions fail-closed): peer resolves only when the immediate peer is loopback,trust_identityis on with a non-emptyallowed_logins,X-Forwarded-Forcarries exactly one address (multi-value = reject, never first/last) inside the tailnet ranges, andtailscale whois --json(vetted absolute CLI paths, hard timeout, bounded TTL cache, dedicated subprocess executor) resolves it.Tailscale-User-Loginis corroboration only — a disagreement is a rejection. Transient daemon failures (spawn error/timeout) cache ~2s so a startup blip clears fast; definitive answers keep the 30s TTL. POSIX-only per RFC OQ4 — Windows degrades to the token path.dashboard/token_auth.py— peer-keyed session pin (RFC §3/§3.1):ip:<addr>byte-for-byte today's behaviour for every non-Tailscale path;ts:node:<login>|<node>(default) /ts:login:<login>for a verified peer. The|separator is forbidden inside components (identity charset allowlist), so keys cannot collide. An ACL-tagged node (tagged-deviceslogin) is always node-scoped — login scope would collapse the pin across the whole tagged fleet.allowed_loginsis enforced at the auth decision (the early deny also covers the/api/auth/refreshmiddleware bypass), the pin check also runs on the internal cookie-auth branches, and mismatch denials name device identity / unverified identity rather than a misleading "IP mismatch". Resolution is gated on the request presenting a credential, so an unauthenticated local caller can never drive daemon spawns with headers. SEL: caller attribution to the resolved login + onetailnet_peer_bindrow per session.handlers/auth_refresh.py: a rotated access token re-binds to the verified peer key, so rotation cannot launder a node pin into an unbound token.server.py: an enterprise ceiling pinningcapabilities.tailnet_originoff also forces identity trust off at startup (audited governance decision) — a storedtrust_identity: truecannot keep whois calls alive under a policy that forbids the integration.config/loader.py:trust_identity/allowed_logins/pin_scopewith narrowing-only load validation: trust with an empty allowlist is refused (never silently permissive); an unrecognisedpin_scopefalls back tonode.is_direct_local_request()unchanged — a verified peer still receivesread_only: trueon the messaging config surfaces (regression-tested).Adversarial review (pre-push)
The issue required adversarial review before landing. A dual model-pinned reviewer fleet (GPT + Opus lanes mirroring the repo's CI reviewers) ran against the working tree with the explicit attack list (XFF injection, multi-value XFF, header/whois disagreement, daemon-absent fallback, timeout fail-closed, tagged-node login-scope collapse, allowlist bypass). All blocking findings were fixed before this PR: internal-path pin bypass, refresh-rotation pin laundering, governance force-off gap, shared-executor starvation + unauthenticated daemon spawn, 30s negative-cache lockout with misattributed reason — plus advisories (key-separator ambiguity, identity charset allowlist, SEL bind row,
_log_authattribution).Tested
test/test_tailnet_peer.py(new): full RFC §2 resolution matrix, daemon failure modes at the subprocess seam, cache TTL/boundedness, pin-key shapes + tagged override + collision-resistance, config load validation, RFC §5 read-only regression, refresh re-bind.test/test_token_auth.py: middleware integration — identity bind, node/login-scope replay semantics, allowlist deny, credential gate, internal mixed-path pin enforcement, unverified-identity denial reason, byte-for-byte non-Tailscale behaviour (whois never called, posture SHARED preserved).Closes #1762