Skip to content

Commit c2dadf8

Browse files
committed
fix(policy): unify UDP policy with TCP, fix QUIC shared-IP dedup
1 parent 72de030 commit c2dadf8

5 files changed

Lines changed: 389 additions & 80 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ See `internal/proxy/request_policy.go`, `internal/policy/engine.go` (`EvaluateDe
187187

188188
`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()`.
189189

190+
**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.
191+
190192
### Protocol detection
191193

192194
Two-phase detection: port-based guess first, then byte-level for non-standard ports. Standard ports (443, 22, 25, etc.) route directly on port guess. When port guess returns `ProtoGeneric`, `DetectFromClientBytes` peeks first bytes (TLS, SSH, HTTP) and `DetectFromServerBytes` reads server banner (SMTP, IMAP). Detection path signals SOCKS5 CONNECT success before reading client bytes.
@@ -197,7 +199,7 @@ Two-phase detection: port-based guess first, then byte-level for non-standard po
197199

198200
`CouldBeAllowed(dest, includeAsk)`: when broker configured, Ask-matching destinations resolve via DNS for approval flow. When no broker, Ask treated as Deny at DNS stage to prevent leaking queries.
199201

200-
**DNS approval design**: The DNS interceptor intentionally only blocks explicitly denied domains (returns NXDOMAIN). All other queries (allow, ask, default) are forwarded to the upstream resolver. This is by design. Policy enforcement for "ask" destinations happens at the SOCKS5 CONNECT layer, not at DNS. Blocking DNS for "ask" destinations would prevent the TCP connection from ever reaching the SOCKS5 handler where the approval flow triggers. The DNS layer populates the reverse DNS cache (IP -> hostname) so the SOCKS5 handler can recover hostnames from IP-only CONNECT requests.
202+
**DNS approval design**: The DNS interceptor intentionally only blocks explicitly denied domains (returns NXDOMAIN). All other queries (allow, ask, default) are forwarded to the upstream resolver. This is by design. Policy enforcement for "ask" destinations happens at the SOCKS5 CONNECT layer, not at DNS. Blocking DNS for "ask" destinations would prevent the TCP connection from ever reaching the SOCKS5 handler where the approval flow triggers. The DNS layer populates the reverse DNS cache (IP -> hostname) so the SOCKS5 handler can recover hostnames from IP-only CONNECT requests. DNS uses `IsDeniedDomain`, a separate evaluation path that is independent from the unscoped-rule matching in `EvaluateUDP` / `EvaluateQUICDetailed`. Unscoped rules therefore widen TCP/UDP/QUIC policy without changing DNS behavior.
201203

202204
### Audit logger
203205

internal/policy/engine.go

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,10 @@ func matchRules(rules []compiledRule, dest string, port int) bool {
307307

308308
// matchRulesStrictProto matches rules that explicitly include the given
309309
// protocol in their protocols field. Rules without a protocols field are NOT
310-
// matched. Used by EvaluateUDP and EvaluateQUIC where only protocol-explicit
311-
// rules should apply, preventing unscoped TCP rules from inadvertently
312-
// allowing UDP/QUIC traffic.
310+
// matched. Used by EvaluateUDP and EvaluateQUICDetailed for their
311+
// protocol-scoped first-pass evaluation. Unscoped rules are handled
312+
// separately by matchRulesUnscoped so UDP and QUIC fall back to the same
313+
// transport-agnostic rules that TCP matches via matchRulesWithProto.
313314
func matchRulesStrictProto(rules []compiledRule, dest string, port int, proto string) bool {
314315
for _, r := range rules {
315316
if !r.glob.Match(dest) {
@@ -319,7 +320,7 @@ func matchRulesStrictProto(rules []compiledRule, dest string, port int, proto st
319320
continue
320321
}
321322
// Require explicit protocol match. Rules without a protocols field
322-
// are skipped to prevent TCP-intended rules from matching UDP/QUIC.
323+
// are skipped here; matchRulesUnscoped handles those separately.
323324
if len(r.protocols) == 0 || !r.protocols[proto] {
324325
continue
325326
}
@@ -328,6 +329,28 @@ func matchRulesStrictProto(rules []compiledRule, dest string, port int, proto st
328329
return false
329330
}
330331

332+
// matchRulesUnscoped matches rules that have NO protocols field (unscoped).
333+
// Used as a fallback in EvaluateUDP and EvaluateQUICDetailed after strict
334+
// protocol-scoped matching fails, so that unscoped rules (the common case
335+
// for user-configured destination rules) apply consistently across TCP,
336+
// UDP, and QUIC transports. DNS has its own evaluation path (IsDeniedDomain)
337+
// and is unaffected.
338+
func matchRulesUnscoped(rules []compiledRule, dest string, port int) bool {
339+
for _, r := range rules {
340+
if !r.glob.Match(dest) {
341+
continue
342+
}
343+
if len(r.ports) > 0 && !r.ports[port] {
344+
continue
345+
}
346+
if len(r.protocols) != 0 {
347+
continue
348+
}
349+
return true
350+
}
351+
return false
352+
}
353+
331354
// matchRulesWithProto checks compiled rules against a destination, port, and
332355
// optional explicit protocol. When proto is non-empty it takes precedence over
333356
// the port-based heuristic, allowing header-detected protocols (ws, wss, grpc)
@@ -699,25 +722,47 @@ func (e *Engine) EvaluateDetailedWithProtocol(dest string, port int, proto strin
699722
return e.Default, DefaultVerdict
700723
}
701724

702-
// EvaluateUDP checks a destination and port with UDP-specific semantics.
703-
// Only explicit allow rules produce an Allow verdict. Deny rules take priority
704-
// as usual. Ask rules and the engine default verdict are treated as Deny
705-
// because per-packet approval is impractical. This implements the UDP
706-
// default-deny strategy where UDP traffic requires an explicit allow rule.
725+
// EvaluateUDP checks a destination and port for UDP traffic. Behavior mirrors
726+
// the TCP Evaluate path: deny rules take priority, then allow, then the engine
727+
// default verdict. Ask is not a valid terminal verdict for UDP (per-packet
728+
// approval is impractical), so an Ask default is collapsed to Deny. Callers
729+
// that need Ask semantics for QUIC should use EvaluateQUICDetailed, which
730+
// preserves Ask for the approval flow.
731+
//
732+
// Evaluation order: UDP-scoped deny, UDP-scoped allow, unscoped deny, unscoped
733+
// allow, then the engine's configured default verdict. Unscoped rules (no
734+
// protocols field) are transport-agnostic so a user-configured
735+
// `allow example.com` rule applies to TCP, UDP, and QUIC consistently. DNS is
736+
// the only transport with a separate evaluation path (IsDeniedDomain).
707737
func (e *Engine) EvaluateUDP(dest string, port int) Verdict {
708738
dest = normalizeDestination(dest)
709739
e.mu.RLock()
710740
defer e.mu.RUnlock()
711741
if e.compiled == nil {
712-
return Deny
742+
if e.Default == Ask {
743+
return Deny
744+
}
745+
return e.Default
713746
}
714747
if matchRulesStrictProto(e.compiled.denyRules, dest, port, protoNameUDP) {
715748
return Deny
716749
}
717750
if matchRulesStrictProto(e.compiled.allowRules, dest, port, protoNameUDP) {
718751
return Allow
719752
}
720-
return Deny
753+
if matchRulesUnscoped(e.compiled.denyRules, dest, port) {
754+
return Deny
755+
}
756+
if matchRulesUnscoped(e.compiled.allowRules, dest, port) {
757+
return Allow
758+
}
759+
// Fall back to the engine's configured default verdict. EvaluateUDP has
760+
// no Ask flow (no broker per-packet), so an Ask default collapses to
761+
// Deny. Callers that need Ask must use EvaluateQUICDetailed.
762+
if e.Default == Ask {
763+
return Deny
764+
}
765+
return e.Default
721766
}
722767

723768
// EvaluateQUIC checks a destination and port with QUIC-specific semantics.
@@ -738,9 +783,12 @@ func (e *Engine) EvaluateQUIC(dest string, port int) Verdict {
738783

739784
// EvaluateQUICDetailed returns the verdict and match source for QUIC traffic.
740785
// Unlike EvaluateQUIC, it preserves Ask verdicts so callers can trigger the
741-
// approval flow for per-request policy. Evaluation order: QUIC-specific deny,
742-
// QUIC-specific allow, QUIC-specific ask, then generic deny, allow, ask,
743-
// then engine default verdict.
786+
// approval flow for per-request policy. Evaluation order: QUIC-scoped deny,
787+
// QUIC-scoped allow, QUIC-scoped ask, UDP-scoped deny, UDP-scoped allow,
788+
// UDP-scoped ask, unscoped deny, unscoped allow, unscoped ask, then engine
789+
// default verdict. Unscoped rules (no protocols field) are treated as
790+
// transport-agnostic so user-configured destination rules apply consistently
791+
// across TCP, UDP, and QUIC.
744792
func (e *Engine) EvaluateQUICDetailed(dest string, port int) (Verdict, MatchSource) {
745793
dest = normalizeDestination(dest)
746794
e.mu.RLock()
@@ -768,9 +816,16 @@ func (e *Engine) EvaluateQUICDetailed(dest string, port int) (Verdict, MatchSour
768816
if matchRulesStrictProto(e.compiled.askRules, dest, port, protoNameUDP) {
769817
return Ask, RuleMatch
770818
}
771-
// Use the engine's configured default verdict. Unscoped rules (no
772-
// protocol filter) are NOT matched for QUIC because they are
773-
// TCP-scoped by convention and should not inadvertently allow or
774-
// deny UDP/QUIC traffic.
819+
// Fall back to unscoped rules so transport-agnostic user rules apply.
820+
if matchRulesUnscoped(e.compiled.denyRules, dest, port) {
821+
return Deny, RuleMatch
822+
}
823+
if matchRulesUnscoped(e.compiled.allowRules, dest, port) {
824+
return Allow, RuleMatch
825+
}
826+
if matchRulesUnscoped(e.compiled.askRules, dest, port) {
827+
return Ask, RuleMatch
828+
}
829+
// Use the engine's configured default verdict.
775830
return e.Default, DefaultVerdict
776831
}

internal/policy/engine_test.go

Lines changed: 204 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,10 +1249,11 @@ default = "ask"
12491249
}
12501250
}
12511251

1252-
func TestEvaluateUDP_UnscopedRulesIgnored(t *testing.T) {
1253-
// Rules without explicit protocols must NOT match EvaluateUDP or
1254-
// EvaluateQUIC. This prevents TCP-intended allow rules from
1255-
// inadvertently allowing UDP/QUIC traffic.
1252+
func TestEvaluateUDP_UnscopedRulesMatch(t *testing.T) {
1253+
// Unscoped rules (no protocols field) match UDP and QUIC traffic as
1254+
// well as TCP. This keeps `sluice policy add allow example.com` working
1255+
// consistently across transports. Protocol-scoped rules (protocols=
1256+
// ["udp"] or ["quic"]) still take priority when present.
12561257
eng, err := LoadFromBytes([]byte(`
12571258
[policy]
12581259
default = "deny"
@@ -1270,14 +1271,14 @@ protocols = ["udp"]
12701271
t.Fatalf("load: %v", err)
12711272
}
12721273

1273-
// Unscoped allow rule must NOT allow UDP traffic.
1274-
if got := eng.EvaluateUDP("api.anthropic.com", 443); got != Deny {
1275-
t.Errorf("EvaluateUDP(unscoped allow) = %v, want Deny", got)
1274+
// Unscoped allow rule now matches UDP traffic.
1275+
if got := eng.EvaluateUDP("api.anthropic.com", 443); got != Allow {
1276+
t.Errorf("EvaluateUDP(unscoped allow) = %v, want Allow", got)
12761277
}
12771278

1278-
// Unscoped allow rule must NOT allow QUIC traffic.
1279-
if got := eng.EvaluateQUIC("api.anthropic.com", 443); got != Deny {
1280-
t.Errorf("EvaluateQUIC(unscoped allow) = %v, want Deny", got)
1279+
// Unscoped allow rule now matches QUIC traffic.
1280+
if got := eng.EvaluateQUIC("api.anthropic.com", 443); got != Allow {
1281+
t.Errorf("EvaluateQUIC(unscoped allow) = %v, want Allow", got)
12811282
}
12821283

12831284
// Explicitly scoped UDP rule must still work.
@@ -1289,6 +1290,199 @@ protocols = ["udp"]
12891290
if got := eng.Evaluate("api.anthropic.com", 443); got != Allow {
12901291
t.Errorf("Evaluate(unscoped allow) = %v, want Allow", got)
12911292
}
1293+
1294+
// Unscoped rule with a port filter still respects the port.
1295+
if got := eng.EvaluateUDP("api.anthropic.com", 80); got != Deny {
1296+
t.Errorf("EvaluateUDP(unscoped allow, wrong port) = %v, want Deny", got)
1297+
}
1298+
}
1299+
1300+
func TestEvaluateUDP_UnscopedAllowMatches(t *testing.T) {
1301+
eng, err := LoadFromBytes([]byte(`
1302+
[policy]
1303+
default = "deny"
1304+
1305+
[[allow]]
1306+
destination = "cloudflare.com"
1307+
ports = [443]
1308+
`))
1309+
if err != nil {
1310+
t.Fatalf("load: %v", err)
1311+
}
1312+
if got := eng.EvaluateUDP("cloudflare.com", 443); got != Allow {
1313+
t.Errorf("EvaluateUDP(unscoped allow) = %v, want Allow", got)
1314+
}
1315+
}
1316+
1317+
func TestEvaluateUDP_DefaultAllow(t *testing.T) {
1318+
// When the engine default is "allow", an unmatched UDP destination
1319+
// returns Allow (just like TCP Evaluate). This mirrors how the engine
1320+
// default is used as the terminal fallback for every transport except
1321+
// DNS (which has its own IsDeniedDomain path).
1322+
eng, err := LoadFromBytes([]byte(`
1323+
[policy]
1324+
default = "allow"
1325+
1326+
[[deny]]
1327+
destination = "blocked.example.com"
1328+
ports = [443]
1329+
`))
1330+
if err != nil {
1331+
t.Fatalf("load: %v", err)
1332+
}
1333+
1334+
// Unknown destination falls back to default "allow".
1335+
if got := eng.EvaluateUDP("unknown.example.com", 443); got != Allow {
1336+
t.Errorf("EvaluateUDP(default=allow, unknown) = %v, want Allow", got)
1337+
}
1338+
1339+
// Explicit deny still takes priority over default allow.
1340+
if got := eng.EvaluateUDP("blocked.example.com", 443); got != Deny {
1341+
t.Errorf("EvaluateUDP(default=allow, denied) = %v, want Deny", got)
1342+
}
1343+
}
1344+
1345+
func TestEvaluateUDP_DefaultDeny(t *testing.T) {
1346+
// When the engine default is "deny", an unmatched UDP destination
1347+
// returns Deny.
1348+
eng, err := LoadFromBytes([]byte(`
1349+
[policy]
1350+
default = "deny"
1351+
1352+
[[allow]]
1353+
destination = "allowed.example.com"
1354+
ports = [443]
1355+
`))
1356+
if err != nil {
1357+
t.Fatalf("load: %v", err)
1358+
}
1359+
1360+
// Unknown destination falls back to default "deny".
1361+
if got := eng.EvaluateUDP("unknown.example.com", 443); got != Deny {
1362+
t.Errorf("EvaluateUDP(default=deny, unknown) = %v, want Deny", got)
1363+
}
1364+
1365+
// Matching allow rule still produces Allow.
1366+
if got := eng.EvaluateUDP("allowed.example.com", 443); got != Allow {
1367+
t.Errorf("EvaluateUDP(default=deny, allowed) = %v, want Allow", got)
1368+
}
1369+
}
1370+
1371+
func TestEvaluateUDP_DefaultAsk(t *testing.T) {
1372+
// EvaluateUDP has no approval flow (per-packet approval is impractical),
1373+
// so an Ask default collapses to Deny. This mirrors the contract of
1374+
// EvaluateQUIC (non-detailed), which collapses Ask to Deny for callers
1375+
// that do not know how to drive the approval flow.
1376+
eng, err := LoadFromBytes([]byte(`
1377+
[policy]
1378+
default = "ask"
1379+
`))
1380+
if err != nil {
1381+
t.Fatalf("load: %v", err)
1382+
}
1383+
1384+
if got := eng.EvaluateUDP("anything.example.com", 443); got != Deny {
1385+
t.Errorf("EvaluateUDP(default=ask) = %v, want Deny (collapsed)", got)
1386+
}
1387+
}
1388+
1389+
func TestEvaluateUDP_NilCompiled(t *testing.T) {
1390+
// Engine with nil compiled state returns the engine default (Ask
1391+
// collapses to Deny). Zero-value Engine has Default=Allow.
1392+
eng := &Engine{}
1393+
if got := eng.EvaluateUDP("anything.com", 443); got != Allow {
1394+
t.Errorf("EvaluateUDP(nil compiled, default=Allow) = %v, want Allow", got)
1395+
}
1396+
1397+
eng2 := &Engine{Default: Deny}
1398+
if got := eng2.EvaluateUDP("anything.com", 443); got != Deny {
1399+
t.Errorf("EvaluateUDP(nil compiled, default=Deny) = %v, want Deny", got)
1400+
}
1401+
1402+
eng3 := &Engine{Default: Ask}
1403+
if got := eng3.EvaluateUDP("anything.com", 443); got != Deny {
1404+
t.Errorf("EvaluateUDP(nil compiled, default=Ask) = %v, want Deny (collapsed)", got)
1405+
}
1406+
}
1407+
1408+
func TestEvaluateQUICDetailed_UnscopedAllowMatches(t *testing.T) {
1409+
eng, err := LoadFromBytes([]byte(`
1410+
[policy]
1411+
default = "deny"
1412+
1413+
[[allow]]
1414+
destination = "cloudflare.com"
1415+
ports = [443]
1416+
`))
1417+
if err != nil {
1418+
t.Fatalf("load: %v", err)
1419+
}
1420+
v, src := eng.EvaluateQUICDetailed("cloudflare.com", 443)
1421+
if v != Allow || src != RuleMatch {
1422+
t.Errorf("EvaluateQUICDetailed(unscoped allow) = (%v, %v), want (Allow, RuleMatch)", v, src)
1423+
}
1424+
}
1425+
1426+
func TestEvaluateQUICDetailed_UnscopedDenyPriority(t *testing.T) {
1427+
// Unscoped deny beats unscoped allow for QUIC, matching the behavior
1428+
// of TCP evaluation.
1429+
eng, err := LoadFromBytes([]byte(`
1430+
[policy]
1431+
default = "allow"
1432+
1433+
[[allow]]
1434+
destination = "overlap.example.com"
1435+
ports = [443]
1436+
1437+
[[deny]]
1438+
destination = "overlap.example.com"
1439+
ports = [443]
1440+
`))
1441+
if err != nil {
1442+
t.Fatalf("load: %v", err)
1443+
}
1444+
v, src := eng.EvaluateQUICDetailed("overlap.example.com", 443)
1445+
if v != Deny || src != RuleMatch {
1446+
t.Errorf("EvaluateQUICDetailed(unscoped deny over unscoped allow) = (%v, %v), want (Deny, RuleMatch)", v, src)
1447+
}
1448+
}
1449+
1450+
func TestEvaluateQUICDetailed_ProtocolScopedTakesPriority(t *testing.T) {
1451+
// An explicit QUIC deny rule must beat an unscoped allow rule, and an
1452+
// explicit UDP allow must beat an unscoped deny for QUIC evaluation.
1453+
eng, err := LoadFromBytes([]byte(`
1454+
[policy]
1455+
default = "deny"
1456+
1457+
[[allow]]
1458+
destination = "quic-deny.example.com"
1459+
ports = [443]
1460+
1461+
[[deny]]
1462+
destination = "quic-deny.example.com"
1463+
ports = [443]
1464+
protocols = ["quic"]
1465+
1466+
[[deny]]
1467+
destination = "udp-allow.example.com"
1468+
ports = [443]
1469+
1470+
[[allow]]
1471+
destination = "udp-allow.example.com"
1472+
ports = [443]
1473+
protocols = ["udp"]
1474+
`))
1475+
if err != nil {
1476+
t.Fatalf("load: %v", err)
1477+
}
1478+
// QUIC-scoped deny wins over unscoped allow.
1479+
if v, src := eng.EvaluateQUICDetailed("quic-deny.example.com", 443); v != Deny || src != RuleMatch {
1480+
t.Errorf("EvaluateQUICDetailed(quic deny) = (%v, %v), want (Deny, RuleMatch)", v, src)
1481+
}
1482+
// UDP-scoped allow wins over unscoped deny.
1483+
if v, src := eng.EvaluateQUICDetailed("udp-allow.example.com", 443); v != Allow || src != RuleMatch {
1484+
t.Errorf("EvaluateQUICDetailed(udp allow) = (%v, %v), want (Allow, RuleMatch)", v, src)
1485+
}
12921486
}
12931487

12941488
func TestEvaluate_TCPMetaProtocol(t *testing.T) {

0 commit comments

Comments
 (0)