wgengine/netstack: reject unserved ports on Service (VIP) IPs (#20363)

A connection to a Tailscale Service IP on a port the service does
not serve was forwarded to the underlying host. `acceptTCP` fell through to
the isTailscaleIP case (a VIP is in the Tailscale IP range), which rewrote
the dial target to 127.0.0.1:<port> and forwardTCP'd the connection onto
whatever unrelated listener happened to be on the host's loopback at that
port.

This is reachable through the service IP by any peer which was granted
access only to the service (dst: svc:foo), so it exposes host ports the
peer has no ACL access to via the machine's regular IP. This happens
when there tailscaled has a Tun interface and the forward bits are set.

In this commit, we added a guard in acceptTCP, before the isTailscaleIP case
that RSTs connections to a VIP service IP on a port with no serve handler.
Served ports return earlier via TCPHandlerForDst, so only unserved ports reach the guard.
Layer 3 services are unaffected: their traffic is released to the host in
injectInbound and never reaches acceptTCP.

Fixes #20362

Signed-off-by: kevinliang10 <kevinliang@tailscale.com>
This commit is contained in:
KevinLiang10
2026-07-10 14:06:15 -04:00
committed by GitHub
parent 7771ce4e58
commit a68be19739
2 changed files with 211 additions and 85 deletions
+11
View File
@@ -1683,6 +1683,17 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
// here instead.
r.Complete(true) // sends a RST
return
case ns.isVIPServiceIP(dialIP):
// TCP to a VIP service IP on a port the service does not serve. A served
// port returns early above (TCPHandlerForDst is non-nil), so reaching here
// means this node has no serve handler for this port. Don't fall through
// to the isTailscaleIP case below (a VIP is in the Tailscale IP range),
// which would rewrite the dial target to 127.0.0.1:<port> and forwardTCP
// the connection onto whatever unrelated service happens to be listening
// on the host's loopback at that port — reachable via the service IP by
// any peer, even one granted access only to the service. Reject with a RST.
r.Complete(true) // sends a RST
return
case isTailscaleIP:
dialIP = ipv4Loopback
}