diff --git a/tsweb/debug_test.go b/tsweb/debug_test.go index b46a3a3f3..79c686b6b 100644 --- a/tsweb/debug_test.go +++ b/tsweb/debug_test.go @@ -8,7 +8,9 @@ import ( "io" "net/http" "net/http/httptest" + "net/netip" "runtime" + "slices" "strings" "testing" ) @@ -206,3 +208,82 @@ func ExampleDebugHandler_Section() { fmt.Fprintf(w, "%#v", r) }) } + +func TestParseTrustedCIDRs(t *testing.T) { + tests := []struct { + name string + raw string + want []netip.Prefix + }{ + { + name: "empty", + raw: "", + want: nil, + }, + { + name: "single_v4", + raw: "10.0.0.0/8", + want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + }, + { + name: "multiple", + raw: "10.0.0.0/8,172.16.0.0/12", + want: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + }, + }, + { + name: "spaces_trimmed", + raw: " 10.0.0.0/8 , 192.168.0.0/16 ", + want: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.0.0/16"), + }, + }, + { + name: "ipv6", + raw: "fd00::/8", + want: []netip.Prefix{netip.MustParsePrefix("fd00::/8")}, + }, + { + name: "trailing_comma", + raw: "10.0.0.0/8,", + want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTrustedCIDRs(tt.raw) + if !slices.Equal(got, tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestAllowDebugAccessTrustedCIDRContains(t *testing.T) { + // Verify that parsed CIDRs correctly match/reject IPs. + cidrs := parseTrustedCIDRs("10.0.0.0/8,192.168.1.0/24,fd00::/8") + + tests := []struct { + ip string + want bool + }{ + {"10.1.2.3", true}, + {"10.255.255.255", true}, + {"192.168.1.50", true}, + {"192.168.2.1", false}, + {"172.16.0.1", false}, + {"8.8.8.8", false}, + {"fd00::1", true}, + {"fe80::1", false}, + } + for _, tt := range tests { + ip := netip.MustParseAddr(tt.ip) + if got := cidrsContain(cidrs, ip); got != tt.want { + t.Errorf("CIDRs contain %s = %v, want %v", tt.ip, got, tt.want) + } + } +} diff --git a/tsweb/tsweb.go b/tsweb/tsweb.go index c73010783..101512b89 100644 --- a/tsweb/tsweb.go +++ b/tsweb/tsweb.go @@ -13,6 +13,7 @@ import ( "expvar" "fmt" "io" + "log" "maps" "net" "net/http" @@ -54,6 +55,50 @@ func IsProd443(addr string) bool { return port == "443" || port == "https" } +// debugTrustedCIDRs is the envknob for TS_DEBUG_TRUSTED_CIDRS, a +// comma-separated list of CIDR ranges (e.g. "10.0.0.0/8,172.16.0.0/12") +// whose source IPs are allowed to access debug endpoints without Tailscale +// authentication. This will supersede both IsTailscaleIP() and +// TS_ALLOW_DEBUG_IP. +var debugTrustedCIDRs = envknob.RegisterString("TS_DEBUG_TRUSTED_CIDRS") + +// trustedCIDRs returns the parsed CIDR prefixes from TS_DEBUG_TRUSTED_CIDRS. +var trustedCIDRs = sync.OnceValue(func() []netip.Prefix { + return parseTrustedCIDRs(debugTrustedCIDRs()) +}) + +// parseTrustedCIDRs parses a comma-separated list of CIDR prefixes. +// It fatals on invalid entries, consistent with other envknob parsing. +func parseTrustedCIDRs(raw string) []netip.Prefix { + if raw == "" { + return nil + } + var prefixes []netip.Prefix + for _, s := range strings.Split(raw, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + pfx, err := netip.ParsePrefix(s) + if err != nil { + log.Fatalf("invalid CIDR in TS_DEBUG_TRUSTED_CIDRS: %q: %v", s, err) + } + prefixes = append(prefixes, pfx) + } + return prefixes +} + +// cidrsContain checks if the source IP is associated with one of the +// provided cidrs. +func cidrsContain(cidrs []netip.Prefix, ip netip.Addr) bool { + for _, pfx := range cidrs { + if pfx.Contains(ip) { + return true + } + } + return false +} + // AllowDebugAccess reports whether r should be permitted to access // various debug endpoints. func AllowDebugAccess(r *http.Request) bool { @@ -75,6 +120,9 @@ func AllowDebugAccess(r *http.Request) bool { if tsaddr.IsTailscaleIP(ip) || ip.IsLoopback() || ipStr == envknob.String("TS_ALLOW_DEBUG_IP") { return true } + if cidrsContain(trustedCIDRs(), ip) { + return true + } return false }