Skip to content

fix(web): make the Tailscale trust path usable, and stop it trusting carrier NAT (AGT-4294) - #614

Merged
unohee merged 1 commit into
mainfrom
fix/cgnat-allowlist
Sep 10, 2026
Merged

fix(web): make the Tailscale trust path usable, and stop it trusting carrier NAT (AGT-4294)#614
unohee merged 1 commit into
mainfrom
fix/cgnat-allowlist

Conversation

@unohee

@unohee unohee commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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:

URL /usage page /api/usage data
http://100.95.200.28:3847 200 403
http://[fd7a:115c:a1e0::bc01:c823]:3847 200 200

The page shell loads and every number on it 403s, so the dashboard renders empty and asks for a token. tailscale status prints the CGNAT address, MagicDNS is commonly off, and authorizedTailscalePeers' 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:

  • detectTailscaleIP scanned 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.
  • The banner printed only the ULA, leaving the address operators actually use undiscoverable.
  • 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/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 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::/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" — it is "no ULA right now", which tailscale down or 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 with OPENSWARM_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:

mutant tests killed
enrol every address on the interface 3
drop the [addr]:port strip 2
latch the no-ULA warning forever 1
drop Math.abs on the cache clock delta 1

Gate

tsc --noEmit exit 0 · src/support/ 777 passed / 3 skipped, exit 0 · npm run build exit 0 · oxlint 0 warnings on the changed files.

Closes AGT-4294.

🤖 Generated with Claude Code

…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>
@unohee
unohee merged commit 3cda3c5 into main Sep 10, 2026
13 of 14 checks passed
@unohee
unohee deleted the fix/cgnat-allowlist branch September 10, 2026 11:02
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.

1 participant