diff --git a/CLAUDE.md b/CLAUDE.md index bf8817f..1099f72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,6 +274,10 @@ See `internal/proxy/request_policy.go`, `internal/policy/engine.go` (`EvaluateDe `LoadFromStore` reads rules from SQLite, compiles glob patterns into regexes, produces read-only Engine snapshot. `Evaluate(dest, port)` checks deny first, then allow, then ask, falling back to default verdict. Mutations go through the store, then a new Engine is compiled and atomically swapped via `srv.StoreEngine()`. SIGHUP also rebuilds the binding resolver and swaps it via `srv.StoreResolver()`. +**Destination matching: glob and CIDR.** A rule's `destination` is interpreted as a CIDR when it contains a `/` (e.g. `192.168.0.0/16`, `2001:db8::/32`) and as a glob otherwise (e.g. `*.tailscale.com`, `api.openai.com`, `10.0.0.5`). CIDR rules use IP containment via `net.IPNet.Contains`; glob rules use the existing `[^.]*` / `(.*\.)?` matcher. A CIDR rule only matches destinations that parse as an IP, so `example.com` cannot accidentally match `0.0.0.0/0`; conversely a glob rule only matches its compiled string pattern, so `192.168.0.*` works for the 256 hosts in `192.168.0.0/24` but does not magically extend to other subnets. Compile errors are loud (invalid CIDR mask fails `compileRules` rather than silently matching nothing). + +**Hostname recovery for IP-only CONNECT requests.** Two peek paths run before dial when the SOCKS5 layer received a bare IP and a hostname rule could plausibly match: `[SNI-DEFER]` for TLS ports (443, 8443, 993, 995, 465) reads the TLS ClientHello and extracts SNI; `[HTTP-HOST-DEFER]` for plain HTTP ports (80, 8080) reads the request prefix up to `\r\n\r\n` and extracts the `Host:` header. Both feed the recovered hostname back into `EvaluateDetailed` and update `ctxKeyFQDN` so the dial uses the hostname for upstream selection. The HTTP path is what makes `*.tailscale.com:80` rules match tailscale's bare-IP DERP latency probes without flooding the approval channel with one prompt per IP. Bytes consumed during the peek are prepended to the relay reader via `io.MultiReader` so the upstream sees the full request. + **Unscoped rules match all transports.** A rule without a `protocols` field (the common case for CLI-added rules like `sluice policy add allow cloudflare.com --ports 443`) matches TCP, UDP, and QUIC traffic. `EvaluateUDP` and `EvaluateQUICDetailed` first check protocol-scoped rules (`matchRulesStrictProto` with `protocols=["udp"]`/`["quic"]`) and fall back to unscoped rules (`matchRulesUnscoped`) before the engine's configured default verdict. UDP and QUIC use the same default as TCP; there is no hidden "UDP default-deny" override. `EvaluateUDP` collapses an Ask default to Deny because per-packet approval is impractical, while `EvaluateQUICDetailed` preserves Ask for the QUIC per-request approval flow. Protocol-scoped rules (`protocols=["tcp"]`, `["udp"]`, `["quic"]`, etc.) still apply only to their declared protocol. DNS has its own evaluation path via `IsDeniedDomain`, so the unscoped-rule fallback for UDP/QUIC does not affect DNS query handling. ### Protocol detection diff --git a/internal/policy/engine.go b/internal/policy/engine.go index a146ac8..d67b346 100644 --- a/internal/policy/engine.go +++ b/internal/policy/engine.go @@ -27,11 +27,35 @@ func isUDPFamilyProto(proto string) bool { } type compiledRule struct { + // Exactly one of glob or cidr is set per rule. cidr is set when + // the rule's destination is a CIDR like "192.168.1.0/24" or a + // bare IP-with-mask like "10.0.0.1/32"; glob is set for hostname + // patterns and bare IPs without a mask. The matchDestination + // method dispatches on which is non-nil. glob *Glob + cidr *net.IPNet ports map[int]bool protocols map[string]bool } +// matchDestination reports whether dest matches this rule's destination. +// CIDR rules use IP containment (the rule "10.0.0.0/8" matches "10.1.2.3"). +// Glob rules use the existing glob matcher. A CIDR rule matches only when +// dest parses as an IP — a hostname like "example.com" can never match a +// CIDR rule even if the rule's CIDR happens to cover the IP that hostname +// resolves to elsewhere, because policy evaluation happens with the +// destination string the SOCKS5 layer received. +func (r compiledRule) matchDestination(dest string) bool { + if r.cidr != nil { + ip := net.ParseIP(dest) + if ip == nil { + return false + } + return r.cidr.Contains(ip) + } + return r.glob != nil && r.glob.Match(dest) +} + // portToProtocol maps well-known ports to protocol names for protocol-scoped // rule matching. Returns "" for non-standard ports where the protocol is // ambiguous. @@ -256,11 +280,6 @@ func compileRules(rules []Rule) ([]compiledRule, error) { if r.Destination == "" { return nil, fmt.Errorf("rule has empty destination") } - dest := canonicalizeDestination(r.Destination) - g, err := CompileGlob(dest) - if err != nil { - return nil, fmt.Errorf("compile rule %q: %w", r.Destination, err) - } ports := make(map[int]bool, len(r.Ports)) for _, p := range r.Ports { if p < 1 || p > 65535 { @@ -272,6 +291,24 @@ func compileRules(rules []Rule) ([]compiledRule, error) { for _, p := range r.Protocols { protocols[p] = true } + // A destination containing "/" is unambiguously CIDR intent. + // Globs and DNS hostnames do not contain forward slashes, so + // the slash is a clean discriminator. Use net.ParseCIDR so + // invalid masks fail loudly at compile time rather than + // silently matching nothing at runtime. + if strings.Contains(r.Destination, "/") { + _, ipnet, err := net.ParseCIDR(r.Destination) + if err != nil { + return nil, fmt.Errorf("rule %q: invalid CIDR: %w", r.Destination, err) + } + out = append(out, compiledRule{cidr: ipnet, ports: ports, protocols: protocols}) + continue + } + dest := canonicalizeDestination(r.Destination) + g, err := CompileGlob(dest) + if err != nil { + return nil, fmt.Errorf("compile rule %q: %w", r.Destination, err) + } out = append(out, compiledRule{glob: g, ports: ports, protocols: protocols}) } return out, nil @@ -313,7 +350,7 @@ func matchRules(rules []compiledRule, dest string, port int) bool { // transport-agnostic rules that TCP matches via matchRulesWithProto. func matchRulesStrictProto(rules []compiledRule, dest string, port int, proto string) bool { for _, r := range rules { - if !r.glob.Match(dest) { + if !r.matchDestination(dest) { continue } if len(r.ports) > 0 && !r.ports[port] { @@ -337,7 +374,7 @@ func matchRulesStrictProto(rules []compiledRule, dest string, port int, proto st // and is unaffected. func matchRulesUnscoped(rules []compiledRule, dest string, port int) bool { for _, r := range rules { - if !r.glob.Match(dest) { + if !r.matchDestination(dest) { continue } if len(r.ports) > 0 && !r.ports[port] { @@ -361,7 +398,7 @@ func matchRulesUnscoped(rules []compiledRule, dest string, port int) bool { // TCP-based connection, mirroring how EvaluateUDP/EvaluateQUIC treat "udp". func matchRulesWithProto(rules []compiledRule, dest string, port int, proto string) bool { for _, r := range rules { - if !r.glob.Match(dest) { + if !r.matchDestination(dest) { continue } if len(r.ports) > 0 && !r.ports[port] { @@ -416,7 +453,7 @@ func (e *Engine) IsDeniedDomain(dest string) bool { return false } for _, r := range e.compiled.denyRules { - if r.glob.Match(dest) { + if r.matchDestination(dest) { return true } } @@ -478,7 +515,7 @@ func (e *Engine) CouldBeAllowed(dest string, includeAsk bool) bool { // only deny that protocol, so DNS must still be resolved for other // protocols to work. for _, r := range e.compiled.denyRules { - if len(r.ports) == 0 && len(r.protocols) == 0 && r.glob.Match(dest) { + if len(r.ports) == 0 && len(r.protocols) == 0 && r.matchDestination(dest) { return false } } @@ -486,7 +523,7 @@ func (e *Engine) CouldBeAllowed(dest string, includeAsk bool) bool { // If any allow rule matches (ignoring ports), the destination // might be allowed on some port. for _, r := range e.compiled.allowRules { - if r.glob.Match(dest) { + if r.matchDestination(dest) { return true } } @@ -495,7 +532,7 @@ func (e *Engine) CouldBeAllowed(dest string, includeAsk bool) bool { // but only when an approval broker is available. if includeAsk { for _, r := range e.compiled.askRules { - if r.glob.Match(dest) { + if r.matchDestination(dest) { return true } } diff --git a/internal/policy/engine_test.go b/internal/policy/engine_test.go index 18acca8..9db832f 100644 --- a/internal/policy/engine_test.go +++ b/internal/policy/engine_test.go @@ -2239,3 +2239,82 @@ func TestMatchSourceString(t *testing.T) { t.Errorf("MatchSource(99).String() = %q, want %q", MatchSource(99).String(), "unknown") } } + +func TestCompileRules_CIDR(t *testing.T) { + rules := []Rule{ + {Destination: "192.168.0.0/16", Ports: []int{443}}, + {Destination: "10.0.0.5/32", Ports: []int{443}}, + {Destination: "2001:db8::/32", Ports: []int{443}}, + } + out, err := compileRules(rules) + if err != nil { + t.Fatalf("compile failed: %v", err) + } + if len(out) != 3 { + t.Fatalf("got %d rules, want 3", len(out)) + } + for i, r := range out { + if r.cidr == nil { + t.Errorf("rule %d: cidr is nil", i) + } + if r.glob != nil { + t.Errorf("rule %d: glob should be nil for CIDR rule", i) + } + } +} + +func TestCompileRules_InvalidCIDRRejected(t *testing.T) { + rules := []Rule{{Destination: "10.0.0.0/99"}} + _, err := compileRules(rules) + if err == nil { + t.Fatal("expected error on invalid CIDR mask") + } +} + +func TestMatchDestination_CIDRContainment(t *testing.T) { + rules := []Rule{ + {Destination: "192.168.0.0/16", Ports: []int{443}}, + } + out, err := compileRules(rules) + if err != nil { + t.Fatalf("compile failed: %v", err) + } + r := out[0] + cases := []struct { + ip string + want bool + }{ + {"192.168.1.5", true}, + {"192.168.255.255", true}, + {"192.169.0.1", false}, + {"10.0.0.1", false}, + // Hostname strings never match CIDR rules even if the host + // would resolve to a covered IP. Policy is evaluated against + // the destination string the SOCKS5 layer received, and we + // don't perform implicit DNS at this layer. + {"example.com", false}, + } + for _, c := range cases { + if got := r.matchDestination(c.ip); got != c.want { + t.Errorf("matchDestination(%q) = %v, want %v", c.ip, got, c.want) + } + } +} + +func TestEvaluate_CIDRRule(t *testing.T) { + eng, err := LoadFromBytes([]byte(` +default = "deny" +[[allow]] +destination = "10.0.0.0/8" +ports = [443] +`)) + if err != nil { + t.Fatalf("load failed: %v", err) + } + if v := eng.Evaluate("10.5.6.7", 443); v != Allow { + t.Errorf("10.5.6.7:443 should be Allow, got %v", v) + } + if v := eng.Evaluate("11.5.6.7", 443); v != Deny { + t.Errorf("11.5.6.7:443 should be Deny (default), got %v", v) + } +} diff --git a/internal/proxy/http_host.go b/internal/proxy/http_host.go new file mode 100644 index 0000000..af0149a --- /dev/null +++ b/internal/proxy/http_host.go @@ -0,0 +1,97 @@ +package proxy + +import ( + "bufio" + "bytes" + "io" + "net" + "net/http" + "strings" +) + +// peekHTTPHost reads enough bytes from r to parse the HTTP/1.x request line +// and Host header, returning the peeked buffer and the host. Like peekSNI +// but for plain HTTP (e.g. ports 80, 8080). The caller prepends the buffer +// to subsequent reads so the upstream sees the full request. +// +// Returns an empty host when the bytes are not a valid HTTP/1.x request +// (binary protocol, partial data, malformed). In that case the caller should +// fall back to IP-based policy. Reads are bounded by maxBytes to avoid +// hanging on slow clients or very long header sets. +func peekHTTPHost(r io.Reader, maxBytes int) ([]byte, string, error) { + buf := make([]byte, 0, maxBytes) + tmp := make([]byte, 4096) + + for len(buf) < maxBytes { + // Cap each read so a single big chunk does not push buf + // past maxBytes. + want := maxBytes - len(buf) + if want > len(tmp) { + want = len(tmp) + } + n, err := r.Read(tmp[:want]) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + // Quick reject: HTTP/1.x request lines start with a method like + // GET/POST/HEAD/etc. Method tokens are uppercase ASCII letters. + // If the first byte is not in the [A-Z] range, this is not HTTP. + // Returning early on the first read avoids waiting maxBytes worth + // of data for a binary protocol that happens to be on port 80. + if len(buf) >= 1 && (buf[0] < 'A' || buf[0] > 'Z') { + return buf, "", nil + } + // Look for end of headers. http.ReadRequest needs the full header + // section before it returns; calling it on partial data yields + // io.ErrUnexpectedEOF, which we treat as "keep reading". + if idx := bytes.Index(buf, []byte("\r\n\r\n")); idx >= 0 { + host, ok := extractHTTPHost(buf[:idx+4]) + if ok { + return buf, host, nil + } + return buf, "", nil + } + if err != nil { + if len(buf) > 0 { + return buf, "", nil + } + return nil, "", err + } + } + return buf, "", nil +} + +// extractHTTPHost parses an HTTP/1.x request prefix terminated by \r\n\r\n +// and returns the Host header value with any port stripped. The fast-path +// uses net/http's parser, which handles obs-fold, mixed case, multiple +// Host header rules, and request-line validation in one pass. +func extractHTTPHost(prefix []byte) (string, bool) { + req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(prefix))) + if err != nil { + return "", false + } + host := req.Host + if host == "" { + host = req.Header.Get("Host") + } + host = strings.TrimSpace(host) + if host == "" { + return "", false + } + // net.SplitHostPort handles every shape Host can legitimately + // take: "example.com:80" -> ("example.com", "80"), + // "[::1]:80" -> ("::1", "80"), "[::1]" with no port -> error, + // and bare IPv6 like "2001:db8::1" -> error ("too many colons"). + // Falling back to the trimmed host on error avoids the previous + // LastIndex(":") approach that mishandled bare IPv6 by stripping + // the final hextet as if it were a port. + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.TrimPrefix(host, "[") + host = strings.TrimSuffix(host, "]") + if host == "" { + return "", false + } + return host, true +} diff --git a/internal/proxy/http_host_test.go b/internal/proxy/http_host_test.go new file mode 100644 index 0000000..fe2c81a --- /dev/null +++ b/internal/proxy/http_host_test.go @@ -0,0 +1,228 @@ +package proxy + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "strings" + "testing" +) + +func TestExtractHTTPHost_BasicGet(t *testing.T) { + prefix := []byte("GET /derp/probe HTTP/1.1\r\nHost: derp10b.tailscale.com\r\nUser-Agent: tailscale\r\n\r\n") + host, ok := extractHTTPHost(prefix) + if !ok { + t.Fatal("expected ok=true") + } + if host != "derp10b.tailscale.com" { + t.Errorf("got %q, want derp10b.tailscale.com", host) + } +} + +func TestExtractHTTPHost_StripsPort(t *testing.T) { + prefix := []byte("GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n") + host, ok := extractHTTPHost(prefix) + if !ok || host != "example.com" { + t.Errorf("got %q ok=%v, want example.com ok=true", host, ok) + } +} + +func TestExtractHTTPHost_IPv6WithPort(t *testing.T) { + // IPv6 in Host header: [::1]:80. Should strip the :80, leave ::1 (no brackets). + prefix := []byte("GET / HTTP/1.1\r\nHost: [::1]:80\r\n\r\n") + host, ok := extractHTTPHost(prefix) + if !ok || host != "::1" { + t.Errorf("got %q ok=%v, want ::1 ok=true", host, ok) + } +} + +func TestExtractHTTPHost_UnbracketedIPv6(t *testing.T) { + // Bare IPv6 without brackets is invalid per RFC but seen in the + // wild. The previous LastIndex(":") logic chopped the final + // hextet as if it were a port. SplitHostPort errors on this + // shape, so the original host should pass through unmodified. + prefix := []byte("GET / HTTP/1.1\r\nHost: 2001:db8::1\r\n\r\n") + host, ok := extractHTTPHost(prefix) + if !ok || host != "2001:db8::1" { + t.Errorf("got %q ok=%v, want 2001:db8::1 ok=true (bare IPv6 must not be truncated)", host, ok) + } +} + +func TestExtractHTTPHost_MissingHost(t *testing.T) { + // HTTP/1.0 allowed missing Host. Should return ok=false rather than + // silently allowing an empty hostname through to policy. + prefix := []byte("GET / HTTP/1.0\r\n\r\n") + host, ok := extractHTTPHost(prefix) + if ok { + t.Errorf("got %q ok=%v, want ok=false", host, ok) + } +} + +func TestExtractHTTPHost_BinaryGarbage(t *testing.T) { + // Random bytes that happen to start with 'G' but are not HTTP. + // The parser should reject and we fall back to IP-based policy. + prefix := []byte{'G', 0x00, 0xff, 0x10, '\r', '\n', '\r', '\n'} + host, ok := extractHTTPHost(prefix) + if ok { + t.Errorf("got %q ok=%v, want ok=false on garbage", host, ok) + } +} + +func TestPeekHTTPHost_FullRequest(t *testing.T) { + body := "GET /probe HTTP/1.1\r\nHost: derp.tailscale.com\r\nAccept: */*\r\n\r\n" + r := strings.NewReader(body) + buf, host, err := peekHTTPHost(r, 4096) + if err != nil { + t.Fatalf("err: %v", err) + } + if host != "derp.tailscale.com" { + t.Errorf("got host %q", host) + } + if !bytes.Equal(buf, []byte(body)) { + t.Errorf("buf should preserve all read bytes; got %d bytes", len(buf)) + } +} + +func TestPeekHTTPHost_NotHTTP(t *testing.T) { + // First byte is 0x16 (TLS handshake) — should bail out fast with + // empty host, returning the buffered bytes for replay. + r := bytes.NewReader([]byte{0x16, 0x03, 0x01, 0x00, 0x42}) + buf, host, err := peekHTTPHost(r, 4096) + if err != nil { + t.Fatalf("err: %v", err) + } + if host != "" { + t.Errorf("expected empty host on non-HTTP, got %q", host) + } + if len(buf) == 0 { + t.Errorf("expected peeked bytes to be returned for replay") + } +} + +func TestPeekHTTPHost_Truncated(t *testing.T) { + // Headers never terminate. peek should return empty host without + // hanging once it hits EOF, with the buffered bytes for replay. + body := "GET / HTTP/1.1\r\nHost: example.com\r\n" + r := strings.NewReader(body) + buf, host, err := peekHTTPHost(r, 4096) + if err != nil && !errors.Is(err, io.EOF) { + t.Fatalf("unexpected err: %v", err) + } + if host != "" { + t.Errorf("expected empty host on truncated headers, got %q", host) + } + if len(buf) == 0 { + t.Errorf("expected buffered bytes for replay") + } +} + +func TestPeekHTTPHost_RespectsMaxBytes(t *testing.T) { + // Long Host header value that exceeds maxBytes triggers the cap. + // peek must return without blocking even if no \r\n\r\n is found. + long := strings.Repeat("X", 8192) + body := "GET / HTTP/1.1\r\nHost: " + long + r := strings.NewReader(body) + buf, host, err := peekHTTPHost(r, 1024) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if host != "" { + t.Errorf("expected empty host when headers exceed cap, got %q", host) + } + if len(buf) > 1024 { + t.Errorf("buffer exceeded maxBytes: %d", len(buf)) + } +} + +func TestAttestHostFromCache_Match(t *testing.T) { + // Cache populated by a prior agent DNS query: derp.tailscale.com + // resolved to 192.0.2.10. Subsequent Host: derp.tailscale.com + // arriving on a SOCKS5 CONNECT to 192.0.2.10 is attested without + // any further DNS work. + di := NewDNSInterceptor(nil, nil, "") + di.StoreReverse("192.0.2.10", "derp.tailscale.com") + s := &Server{dnsInterceptor: di} + if !s.attestHostFromCache("derp.tailscale.com", net.ParseIP("192.0.2.10")) { + t.Fatal("cache hit should attest") + } +} + +func TestAttestHostFromCache_DifferentHost(t *testing.T) { + // Cache says 192.0.2.10 -> attacker.example.com. A claim of + // Host: bank.example.com on that IP must NOT be attested off + // the cache, even though the IP is in the cache. + di := NewDNSInterceptor(nil, nil, "") + di.StoreReverse("192.0.2.10", "attacker.example.com") + s := &Server{dnsInterceptor: di} + if s.attestHostFromCache("bank.example.com", net.ParseIP("192.0.2.10")) { + t.Fatal("cache hit for a different host must not attest a different-Host claim") + } +} + +func TestAttestHostFromCache_NilInputs(t *testing.T) { + s := &Server{dnsInterceptor: NewDNSInterceptor(nil, nil, "")} + if s.attestHostFromCache("", net.ParseIP("1.2.3.4")) { + t.Error("empty host must not attest") + } + if s.attestHostFromCache("example.com", nil) { + t.Error("nil dest IP must not attest") + } + emptyServer := &Server{} + if emptyServer.attestHostFromCache("example.com", net.ParseIP("1.2.3.4")) { + t.Error("nil DNS interceptor must not attest") + } +} + +// stubLookup returns a canned result regardless of the host. Used to +// exercise hostResolvesToIP without performing any real DNS queries. +func stubLookup(ips ...string) func(context.Context, string, string) ([]net.IP, error) { + out := make([]net.IP, len(ips)) + for i, s := range ips { + out[i] = net.ParseIP(s) + } + return func(_ context.Context, _, _ string) ([]net.IP, error) { + return out, nil + } +} + +func TestHostResolvesToIP_LookupMatch(t *testing.T) { + // Cache empty, stubbed resolver returns the dest IP among the + // answers. Should be attested. + s := &Server{ + dnsInterceptor: NewDNSInterceptor(nil, nil, ""), + lookupIP: stubLookup("203.0.113.5", "192.0.2.10"), + } + if !s.hostResolvesToIP(context.Background(), "good.example.com", net.ParseIP("192.0.2.10")) { + t.Fatal("forward-lookup match should attest") + } +} + +func TestHostResolvesToIP_LookupMismatch(t *testing.T) { + // Stub returns a result set that does NOT include the dest IP. + // Spoofing claim — must not attest. + s := &Server{ + dnsInterceptor: NewDNSInterceptor(nil, nil, ""), + lookupIP: stubLookup("203.0.113.5"), + } + if s.hostResolvesToIP(context.Background(), "spoof.example.com", net.ParseIP("192.0.2.10")) { + t.Fatal("forward-lookup mismatch must not attest") + } +} + +func TestHostResolvesToIP_LookupError(t *testing.T) { + // Resolver returns an error (NXDOMAIN, timeout, etc). Treated + // as deny-equivalent — sluice cannot tell a transient failure + // from a poisoned resolver, so the strict default applies. + errResolver := func(_ context.Context, _, _ string) ([]net.IP, error) { + return nil, net.UnknownNetworkError("test stub error") + } + s := &Server{ + dnsInterceptor: NewDNSInterceptor(nil, nil, ""), + lookupIP: errResolver, + } + if s.hostResolvesToIP(context.Background(), "example.com", net.ParseIP("192.0.2.10")) { + t.Fatal("lookup error must not attest") + } +} diff --git a/internal/proxy/server.go b/internal/proxy/server.go index 2ded07a..b16551c 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -98,6 +98,12 @@ type Server struct { // mutations. oauthMetasMu sync.Mutex oauthMetasCache []store.CredentialMeta + + // lookupIP is the forward DNS lookup the HTTP Host spoofing + // guard uses when the reverse cache cannot attest the binding. + // nil means "use net.DefaultResolver.LookupIP". Tests inject a + // stub so the unit test does not perform a real DNS query. + lookupIP func(ctx context.Context, network, host string) ([]net.IP, error) } type contextKey string @@ -108,6 +114,7 @@ const ( ctxKeyFallbackAddrs contextKey = "fallbackAddrs" ctxKeyFQDN contextKey = "fqdn" ctxKeySNIDeferred contextKey = "sniDeferred" // true when policy check deferred for SNI peeking + ctxKeyHTTPHostDeferred contextKey = "httpHostDeferred" // true when policy check deferred for HTTP Host peeking ctxKeyPerRequestPolicy contextKey = "perRequestPolicy" // *RequestPolicyChecker for per-HTTP-request policy checks ctxKeySkipPerRequest contextKey = "skipPerRequest" // true when connection matched an explicit allow rule ) @@ -138,6 +145,21 @@ func isTLSPort(port int) bool { } } +// isPlainHTTPPort returns true for ports that typically carry plain +// (non-TLS) HTTP/1.x traffic. Used to enable Host-header peeking on +// SOCKS5 CONNECT requests that arrive with a bare IP. Without the +// peek, hostname-based allow rules cannot match the connection and +// the policy engine resolves to its default verdict (often Ask) on +// every distinct IP behind a hostname rule. +func isPlainHTTPPort(port int) bool { + switch port { + case 80, 8080: + return true + default: + return false + } +} + // policyResolver performs DNS resolution only for destinations that could be // allowed by policy. Definitely-denied destinations return nil IP (no DNS // lookup) to prevent leaks. For potentially-allowed destinations, DNS failures @@ -312,6 +334,32 @@ func (r *policyRuleSet) Allow(ctx context.Context, req *socks5.Request) (context return ctx, true } + // HTTP Host deferral: same idea as SNI deferral but for plain HTTP. + // When the SOCKS5 layer received a bare IP and a hostname-based rule + // could plausibly match, defer the policy check until the connect + // handler can peek the request's Host header. Without this, every + // new IP behind a hostname rule (e.g. tailscale's DERP latency + // probes hitting dozens of derp[N].tailscale.com IPs) generates an + // approval prompt that can't be silenced short of allowing each IP. + // + // Require a broker before deferring. The peek inside handleConnect + // must send SOCKS5 RepSuccess before it can read the request bytes, + // which means a deferred Ask-with-no-broker would manifest as + // success-then-reset on the client side instead of a clean + // RepHostUnreachable. When no broker is configured, fall through to + // the IP-based path so the Ask->Deny collapse happens before + // SOCKS5 success goes out, matching how go-socks5 reports failure + // for the non-deferred Ask-without-broker case. + if ipOnly && verdict != policy.Allow && verdict != policy.Deny && isPlainHTTPPort(port) && r.broker != nil { + log.Printf("[HTTP-HOST-DEFER] %s:%d (deferring policy for Host header peek)", dest, port) + proto := DetectProtocol(port) + ctx = context.WithValue(ctx, ctxKeyProtocol, proto) + ctx = context.WithValue(ctx, ctxKeyFQDN, dest) + ctx = context.WithValue(ctx, ctxKeyHTTPHostDeferred, true) + ctx = context.WithValue(ctx, ctxKeyEngine, eng) + return ctx, true + } + // Determine the effective outcome. allowed := false effectiveVerdict := verdict @@ -1342,6 +1390,39 @@ func (s *Server) handleConnect(ctx context.Context, writer io.Writer, request *s return s.relayData(clientReader, writer, target) } + // HTTP Host-deferred connections: peek the request's Host header + // (mirrors the SNI flow above but for plaintext HTTP on port 80). + // Hostname recovery here lets `*.tailscale.com:80` rules match the + // dozens of bare-IP DERP probes that would otherwise spam the + // approval channel. + if deferred, _ := ctx.Value(ctxKeyHTTPHostDeferred).(bool); deferred { + bindAddr := &net.TCPAddr{IP: request.DestAddr.IP, Port: request.DestAddr.Port} + if sendErr := socks5.SendReply(writer, statute.RepSuccess, bindAddr); sendErr != nil { + return fmt.Errorf("failed to send reply: %w", sendErr) + } + if conn, ok := writer.(net.Conn); ok { + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) //nolint:errcheck + } + + var allow bool + clientReader, ctx, allow = s.httpHostPolicyCheckBeforeDial(ctx, request) + if !allow { + return nil + } + + if conn, ok := writer.(net.Conn); ok { + conn.SetReadDeadline(time.Time{}) //nolint:errcheck + } + + target, err := s.dial(ctx, "tcp", request.DestAddr.String()) + if err != nil { + return fmt.Errorf("connect to %v failed: %w", request.RawDestAddr, err) + } + defer target.Close() //nolint:errcheck + + return s.relayData(clientReader, writer, target) + } + // Normal (non-deferred) path: dial first, then relay. target, err := s.dial(ctx, "tcp", request.DestAddr.String()) if err != nil { @@ -1435,8 +1516,13 @@ func (s *Server) sniPolicyCheckBeforeDial(ctx context.Context, request *socks5.R } sni = strings.TrimRight(sni, ".") - dest := request.DestAddr.String() - ipStr := strings.Split(dest, ":")[0] + // request.DestAddr.IP is the parsed net.IP, so .String() yields a + // clean address-only form without the trailing port. The previous + // strings.Split(dest, ":") approach mishandled IPv6 destinations: + // request.DestAddr.String() emits IPv6 as "[::1]:80", and splitting + // on ":" yields "[" or partial values that corrupt logs and the + // reverse-DNS cache key. + ipStr := request.DestAddr.IP.String() port := request.DestAddr.Port log.Printf("[SNI] %s -> %s:%d (recovered hostname via TLS ClientHello)", ipStr, sni, port) @@ -1496,6 +1582,197 @@ func (s *Server) sniPolicyCheckBeforeDial(ctx context.Context, request *socks5.R } } +// httpHostPolicyCheckBeforeDial peeks the first bytes from the client to +// extract the HTTP/1.x Host header, re-evaluates policy with the recovered +// hostname, and updates the context FQDN so the dial uses the hostname for +// upstream selection. Mirrors sniPolicyCheckBeforeDial; the only differences +// are which peek function runs and which protocol's "host pinning" semantic +// applies to the recovered name. +func (s *Server) httpHostPolicyCheckBeforeDial(ctx context.Context, request *socks5.Request) (io.Reader, context.Context, bool) { + buf, host, err := peekHTTPHost(request.Reader, 8192) + if err != nil || host == "" { + // Peek failed: binary protocol on port 80, truncated + // headers, peek timeout, or otherwise unparsable HTTP. We + // must NOT fall through with allow=true unconditionally — + // the deferral path runs for Ask verdicts, so a free pass + // here would silently upgrade Ask into an allow. We must + // also NOT collapse the verdict to outright deny, because + // the original Ask semantic for the IP destination still + // needs to be honored when an operator is at the broker. + // + // Attach a per-request checker bound to the IP and return + // allow=true with the buffered bytes. The downstream dial + // step calls CheckAndConsume on the checker, which + // broker-prompts the operator for the bare IP. If they + // approve, dial proceeds; if they deny, dial fails and the + // connection closes. This mirrors what the non-deferred + // Ask-with-broker path does when an Ask verdict reaches + // dial — exactly the behavior we deferred away from. The + // deferral guard above already required broker != nil, so + // the checker has somewhere to send the prompt. + hexPrefix := "" + if len(buf) >= 6 { + hexPrefix = fmt.Sprintf(" first6=%02x", buf[:6]) + } + log.Printf("[HTTP-HOST-PEEK] no Host extracted, falling back to IP-based ask flow (err=%v, bufLen=%d, host=%q%s)", err, len(buf), host, hexPrefix) + checker := NewRequestPolicyChecker( + s.rules.engine, s.rules.broker, + WithPersist(s.rules.buildPersistFunc()), + ) + ctx = context.WithValue(ctx, ctxKeyPerRequestPolicy, checker) + if len(buf) > 0 { + return io.MultiReader(bytes.NewReader(buf), request.Reader), ctx, true + } + return request.Reader, ctx, true + } + + host = strings.TrimRight(host, ".") + // request.DestAddr.IP is the parsed net.IP, so .String() yields a + // clean address-only form without the trailing port. The previous + // strings.Split(dest, ":") approach mishandled IPv6 destinations: + // request.DestAddr.String() emits IPv6 as "[::1]:80", and splitting + // on ":" yields "[" or partial values that corrupt logs and the + // reverse-DNS cache key. + ipStr := request.DestAddr.IP.String() + port := request.DestAddr.Port + + // Evaluate policy on the recovered hostname BEFORE running the + // spoofing check. Two reasons: + // 1. If the verdict is Deny, the connection is rejected + // regardless of whether the Host claim is real, and we save + // a forward DNS lookup that would otherwise leak the + // hostname to the resolver. + // 2. If the verdict is the no-broker default (Ask collapsed to + // Deny), we skip the lookup for the same reason. + eng, _ := ctx.Value(ctxKeyEngine).(*policy.Engine) + if eng == nil { + eng = s.rules.engine.Load() + } + verdict, matchSource := eng.EvaluateDetailed(host, port) + reader := io.MultiReader(bytes.NewReader(buf), request.Reader) + + if verdict == policy.Deny { + log.Printf("[HTTP-HOST->DENY] %s:%d (hostname %s matched deny rule)", ipStr, port, host) + return nil, ctx, false + } + if verdict == policy.Ask && s.rules.broker == nil { + log.Printf("[HTTP-HOST->DENY] %s:%d (hostname %s: ask treated as deny, no broker)", ipStr, port, host) + return nil, ctx, false + } + + // Spoofing guard. The Host header is client-controlled and not + // cryptographically bound to the destination IP. With TLS, an + // SNI/cert mismatch fails the upstream handshake and the bypass + // attempt dies on its own; for plain HTTP there is no equivalent + // integrity check, so an agent could connect to an arbitrary IP + // and claim Host: to slip past hostname-based + // policy. Forward-resolve the recovered host and require the + // dial target to be one of the resolved IPs before trusting the + // Host. On confirmed mismatch (DNS resolved but the dest IP is + // not in the result set) deny outright — surfacing this as Ask + // in the broker would just channel the spoofed hostname back to + // the operator, which is exactly the manipulation the attacker + // wanted. DNS lookup failure is also treated as deny because + // sluice cannot distinguish a transient resolver hiccup from a + // poisoned resolver, and the safer default is the strict one. + if !s.hostResolvesToIP(ctx, host, request.DestAddr.IP) { + log.Printf("[HTTP-HOST->DENY] %s:%d (Host %q does not resolve to %s; possible spoofing)", ipStr, port, host, ipStr) + return nil, ctx, false + } + + log.Printf("[HTTP-HOST] %s -> %s:%d (recovered hostname via HTTP Host header)", ipStr, host, port) + + ctx = context.WithValue(ctx, ctxKeyFQDN, host) + if s.dnsInterceptor != nil { + s.dnsInterceptor.StoreReverse(ipStr, host) + } + + switch verdict { + case policy.Allow: + log.Printf("[HTTP-HOST->ALLOW] %s:%d (hostname %s matched allow rule)", ipStr, port, host) + if matchSource == policy.RuleMatch { + ctx = context.WithValue(ctx, ctxKeySkipPerRequest, true) + } else if s.rules.broker == nil { + ctx = context.WithValue(ctx, ctxKeySkipPerRequest, true) + } else { + checker := NewRequestPolicyChecker( + s.rules.engine, s.rules.broker, + WithPersist(s.rules.buildPersistFunc()), + ) + ctx = context.WithValue(ctx, ctxKeyPerRequestPolicy, checker) + } + return reader, ctx, true + case policy.Ask: + // Deny + Ask-without-broker were filtered out before the + // spoofing check above. Reaching this branch means there is a + // broker, so attach a per-request checker and let the approval + // flow run on the first HTTP request. + log.Printf("[HTTP-HOST->DEFER] %s:%d (hostname %s: approval deferred to per-request)", ipStr, port, host) + checker := NewRequestPolicyChecker( + s.rules.engine, s.rules.broker, + WithPersist(s.rules.buildPersistFunc()), + ) + ctx = context.WithValue(ctx, ctxKeyPerRequestPolicy, checker) + return reader, ctx, true + default: + log.Printf("[HTTP-HOST->DENY] %s:%d (hostname %s: default deny)", ipStr, port, host) + return nil, ctx, false + } +} + +// attestHostFromCache reports whether sluice's DNS interceptor reverse +// cache binds dest to host. The cache is populated when the DNS layer +// answers a query the agent itself made, so a hit is the strongest +// available signal that host -> dest is real and not attacker-claimed. +// Pure-cache check; never touches the network. Returned separately +// from hostResolvesToIP so the cache logic can be unit-tested without +// pulling in a live resolver. +func (s *Server) attestHostFromCache(host string, dest net.IP) bool { + if dest == nil || host == "" || s.dnsInterceptor == nil { + return false + } + cached := s.dnsInterceptor.ReverseLookup(dest.String()) + if cached == "" { + return false + } + return strings.EqualFold(strings.TrimRight(cached, "."), host) +} + +// hostResolvesToIP reports whether host can be attested as binding to +// dest, either via the DNS interceptor's reverse cache or via a fresh +// forward DNS lookup bounded by hostResolveTimeout. Used by the HTTP +// Host peek path to defeat Host-spoofing attempts where an agent +// connects to an arbitrary IP and claims a permitted hostname in the +// request. Returns false on lookup error or mismatch so the caller +// treats unverifiable Host claims as suspect rather than trusting +// the client. +func (s *Server) hostResolvesToIP(ctx context.Context, host string, dest net.IP) bool { + if dest == nil || host == "" { + return false + } + if s.attestHostFromCache(host, dest) { + return true + } + resolver := s.lookupIP + if resolver == nil { + resolver = net.DefaultResolver.LookupIP + } + lookupCtx, cancel := context.WithTimeout(ctx, hostResolveTimeout) + defer cancel() + ips, err := resolver(lookupCtx, "ip", host) + if err != nil { + return false + } + for _, ip := range ips { + if ip.Equal(dest) { + return true + } + } + return false +} + +const hostResolveTimeout = 2 * time.Second + // then dispatches datagrams to the DNSInterceptor (port 53) or UDPRelay // (all other ports). The handler blocks until the TCP control connection // closes, at which point all UDP sessions are cleaned up.