Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 49 additions & 12 deletions internal/policy/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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] {
Expand All @@ -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] {
Expand All @@ -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] {
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -478,15 +515,15 @@ 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
}
}

// 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
}
}
Expand All @@ -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
}
}
Expand Down
79 changes: 79 additions & 0 deletions internal/policy/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
97 changes: 97 additions & 0 deletions internal/proxy/http_host.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading