Repository navigation
fix(security): trust a CGNAT peer only if it arrived over the tailnet - #811
Merged
Merged
Conversation
100.64.0.0/10 was classified 'operator' unconditionally, reasoning that Tailscale peers are cryptographically authenticated. But that range is RFC 6598 shared carrier-grade NAT space used by real ISPs -- it is not Tailscale's. With the app in remote mode (bound 0.0.0.0) and reached over a carrier-NAT path, the direct socket peer can be a 100.64.x stranger. This gate guards requireDestructive (reset / restore / sandbox-disable / password change) and the forwarded-origin derivation in setup.ts, which has no password step-up at all. Asking 'is this HOST on a tailnet?' is the wrong question: Tailscale is the standard workaround FOR a CGNAT ISP, so the hosts behind carrier NAT and the hosts on a tailnet are largely the SAME hosts -- a host-global check answers yes for precisely the population at risk. The question is per-connection: did THIS connection arrive over the tailnet? socket.localAddress answers it. A tailnet peer lands on one of our own tailnet addresses; a carrier-segment stranger lands on the physical nic. A missing localAddress fails closed. Which addresses are ours is decided per INTERFACE, and an interface only counts as Tailscale's if it is named tailscale* or carries an address in Tailscale's registered fd7a:115c:a1e0::/48 ULA. That keeps out an unrelated VPN, which may legitimately be handed RFC 6598 space on a tun/wg device, and it is the only way to identify the device on macOS, where Tailscale is a bare utun<N>. An address-less (down / logged-out) tailscale0 confers nothing, since nothing can arrive on it. WAYLAND_TAILSCALE_CGNAT_OPERATOR forces the rule on/off for setups reached via a subnet router, where the local node holds no tailnet address of its own. Fixes #529
FerroxLabs
enabled auto-merge (squash)
July 11, 2026 11:26
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.
100.64.0.0/10 was classified 'operator' unconditionally, reasoning that Tailscale
peers are cryptographically authenticated. But that range is RFC 6598 shared
carrier-grade NAT space used by real ISPs -- it is not Tailscale's. With the app
in remote mode (bound 0.0.0.0) and reached over a carrier-NAT path, the direct
socket peer can be a 100.64.x stranger. This gate guards requireDestructive
(reset / restore / sandbox-disable / password change) and the forwarded-origin
derivation in setup.ts, which has no password step-up at all.
Asking 'is this HOST on a tailnet?' is the wrong question: Tailscale is the
standard workaround FOR a CGNAT ISP, so the hosts behind carrier NAT and the
hosts on a tailnet are largely the SAME hosts -- a host-global check answers yes
for precisely the population at risk. The question is per-connection: did THIS
connection arrive over the tailnet? socket.localAddress answers it. A tailnet
peer lands on one of our own tailnet addresses; a carrier-segment stranger lands
on the physical nic. A missing localAddress fails closed.
Which addresses are ours is decided per INTERFACE, and an interface only counts
as Tailscale's if it is named tailscale* or carries an address in Tailscale's
registered fd7a:115c:a1e0::/48 ULA. That keeps out an unrelated VPN, which may
legitimately be handed RFC 6598 space on a tun/wg device, and it is the only way
to identify the device on macOS, where Tailscale is a bare utun. An
address-less (down / logged-out) tailscale0 confers nothing, since nothing can
arrive on it.
WAYLAND_TAILSCALE_CGNAT_OPERATOR forces the rule on/off for setups reached via a
subnet router, where the local node holds no tailnet address of its own.
Fixes #529