fix(web): make the Tailscale trust path usable, and stop it trusting carrier NAT (AGT-4294) - #614
Merged
Conversation
…carrier NAT (AGT-4294) Two defects on the same trust boundary. One made the path unusable; the other trusted clients that should never have been trusted. A trusted request reaches `POST /api/exec`, so the second is a security defect. **Nobody could register.** `isAuthorizedTailscalePeer` accepted only the IPv6 ULA form, but `tailscale status` prints the CGNAT IPv4 address and MagicDNS is commonly off — and `authorizedTailscalePeers`' own doc comment has always given a CGNAT address as a valid entry. Measured live today: browsing the daemon's CGNAT URL returns the page shell and 403 on every `/api/*` call, so the dashboard loads empty and asks for a token. Three more fail-closed spellings had the same symptom: `detectTailscaleIP` scanned IPv4 interfaces and then asked a predicate that only accepts IPv6, so it always returned undefined and the startup banner — the one place an operator reads how to connect — fell through to "token required"; the banner printed only the ULA; and `100.x:3847`, `::ffff:100.x`, `[addr]` and `[addr]:port` were all dropped silently. **Range membership is not identity.** `100.64.0.0/10` is also carrier-grade NAT (LTE, Starlink, many fixed ISPs) and a stock Kubernetes pod range (100.96/12). A daemon on a tethered uplink or in a pod would trust a listed peer address that reached it over the carrier network. The tailnet interface is now identified by the ULA it carries — that prefix is assigned by exactly one thing — and only Tailscale's own addresses on that interface count as a local end. Taking the whole interface would have let an on-link attacker land a rogue `fd7a:115c:a1e0::/48` router advertisement on `en0` and thereby promote that machine's plain LAN address to a trusted local end, reachable with no Origin header at all. With no ULA anywhere the check fails closed. That condition is not "IPv6 is disabled", which is what an earlier draft assumed — it is "no ULA right now", which `tailscale down` or a tailscaled restart satisfies on an ordinary dual-stack host, and the automatic fallback would then have degraded silently and permanently thirty seconds later. A host that genuinely cannot run IPv6 opts in with OPENSWARM_TAILSCALE_ALLOW_RANGE_LOCAL_END=true. Address spellings are canonicalised in one place, because the shape predicates strip `::ffff:` before matching and an identity compare that did not strip it would pass the shape check and then fail the allowlist — which is the same split that made the path unusable to begin with. Review: layer 2 (independent subagent), four rounds. R1 found the range MAJOR, R2 the silent fallback, R3 the interface-wide enrolment plus five smaller items; R4 returned APPROVE with no findings. Each fix is pinned by a mutant that kills tests. tsc --noEmit exit 0 · src/support/ 777 passed / 3 skipped, exit 0 · build exit 0 · oxlint 0 warnings on the changed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Two defects on the same trust boundary. One made the path unusable; the other trusted clients that should never have been trusted. A trusted request reaches
POST /api/exec, so the second is a security defect.Measured today, on the deployed build
Browsing the daemon's own CGNAT URL from a tailnet peer:
/usagepage/api/usagedatahttp://100.95.200.28:3847http://[fd7a:115c:a1e0::bc01:c823]:3847The page shell loads and every number on it 403s, so the dashboard renders empty and asks for a token.
tailscale statusprints the CGNAT address, MagicDNS is commonly off, andauthorizedTailscalePeers' own doc comment has always given a CGNAT address as a valid entry — but only the ULA was accepted as a peer.Three more fail-closed spellings had the identical symptom:
detectTailscaleIPscanned IPv4 interfaces and then asked a predicate that only accepts IPv6 — always undefined, so the startup banner (the one place an operator reads how to connect) fell through to "token required" even on a Tailscale-only daemon.100.x:3847,::ffff:100.x,[addr],[addr]:port— including the exact bracketed spelling this daemon prints in its own banner — were dropped silently.Range membership is not identity
100.64.0.0/10is also carrier-grade NAT (LTE, Starlink, many fixed ISPs) and a stock Kubernetes pod range (100.96/12). A daemon on a tethered uplink or inside a pod would trust a listed peer address that reached it over the carrier network.The tailnet interface is now identified by the ULA it carries — that prefix is assigned by exactly one thing — and only Tailscale's own addresses on that interface count as a local end. Enrolling the whole interface would let an on-link attacker land a rogue
fd7a:115c:a1e0::/48router advertisement onen0and thereby promote that machine's plain LAN address to a trusted local end, reachable with noOriginheader at all.With no ULA anywhere the check fails closed. That condition is not "IPv6 is disabled" — it is "no ULA right now", which
tailscale downor a tailscaled restart satisfies on an ordinary dual-stack host, and an automatic fallback would have degraded silently and permanently thirty seconds later. A host that genuinely cannot run IPv6 opts in withOPENSWARM_TAILSCALE_ALLOW_RANGE_LOCAL_END=true— a choice an operator makes rather than a state they fall into.Review
Layer 2 (independent subagent), four rounds. R1 found the range MAJOR; R2 the silent fallback; R3 the interface-wide enrolment plus five smaller items; R4 returned APPROVE with no findings. Layer 1 (
openswarm review) is not run on my own commits per standing policy.Each fix is pinned by a mutant that kills tests:
[addr]:portstripMath.abson the cache clock deltaGate
tsc --noEmitexit 0 ·src/support/777 passed / 3 skipped, exit 0 ·npm run buildexit 0 ·oxlint0 warnings on the changed files.Closes AGT-4294.
🤖 Generated with Claude Code