Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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)
}
}
95 changes: 95 additions & 0 deletions internal/proxy/http_host.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package proxy

import (
"bufio"
"bytes"
"io"
"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
}
// Strip port if present. IPv6 hosts in Host headers appear as
// "[::1]:80" so only strip the trailing :port when there is no
// closing bracket after the last colon.
if i := strings.LastIndex(host, ":"); i >= 0 {
if !strings.Contains(host[i:], "]") {
host = host[:i]
}
}
Comment thread
nnemirovsky marked this conversation as resolved.
Outdated
// IPv6 hosts may still be wrapped in [] — strip those.
host = strings.TrimPrefix(host, "[")
host = strings.TrimSuffix(host, "]")
if host == "" {
return "", false
}
return host, true
}
Loading
Loading