Skip to content

Commit 421579e

Browse files
authored
fix(policy): use engine default verdict for QUIC (#29)
* fix(policy): use engine default verdict for QUIC instead of hardcoded deny EvaluateQUICDetailed was hardcoded to return Deny as the default verdict, ignoring the engine's configured default. When default is "ask", QUIC traffic to unmatched destinations was silently dropped instead of triggering approval. Now uses e.Default so QUIC respects the same default as TCP. * docs: add QUIC full flow fixes plan * feat(proxy): extract SNI from QUIC Initial packets for hostname-based policy * feat(proxy): deduplicate QUIC broker requests with bounded packet buffer * fix(proxy): verify and document QUIC response relay path * fix(test): use IPv4-only listeners in httptest servers * test(e2e): add comprehensive protocol coverage for WebSocket, gRPC, QUIC, DNS, IMAP/SMTP * chore: mark Task 6 acceptance criteria verified * docs: update CLAUDE.md with QUIC SNI extraction and move plan to completed * fix: address review phase 1 findings * fix: address review phase 2 code smell findings * fix(proxy): address review phase 4 critical findings * fix(proxy): handle real-world QUIC Initial packets in SNI extraction * fix(proxy): handle real-world QUIC frame parsing and document SNI fragmentation * feat(cli): add --protocols flag to policy add command * feat(proxy): accumulate CRYPTO data across QUIC Initial packets to extract SNI * fix(policy): unify UDP policy with TCP, fix QUIC shared-IP dedup * style: fix golangci-lint issues in QUIC SNI code * fix(e2e): remove broken WS credential injection test (upstream limitation) * fix(proxy): forward modified headers on WebSocket upgrade Bumps go-mitmproxy fork to include the header forwarding fix so that addon-modified headers (credential injection, custom headers) reach the upstream WS server during the handshake. Restores TestWebSocket_CredentialInjectionInUpgradeHeaders which now passes end-to-end. * chore: bump go-mitmproxy fork to drop Chinese comment * fix(test): address SSH jump host test flakiness
1 parent 28dfdf9 commit 421579e

29 files changed

Lines changed: 5136 additions & 195 deletions

CLAUDE.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ go test ./... -v -timeout 30s
1111

1212
## E2e Tests
1313

14-
End-to-end tests live in `e2e/` and use build tags. They start a real sluice binary, configure policies, make connections through the proxy, and verify credential injection, MCP gateway flows, and audit log integrity.
14+
End-to-end tests live in `e2e/` and use build tags. They start a real sluice binary, configure policies, make connections through the proxy, and verify credential injection, MCP gateway flows, and audit log integrity. Protocol coverage: HTTP/HTTPS, SSH, MCP, WebSocket, gRPC, QUIC/HTTP3, DNS, and IMAP/SMTP.
1515

1616
Build tags:
1717
- `e2e` -- required for all e2e tests
@@ -150,6 +150,7 @@ Extends phantom swap to handle OAuth credentials bidirectionally. Static credent
150150
- `internal/vault/phantom.go` -- `GeneratePhantomToken` for MITM phantom strings
151151
- `internal/proxy/oauth_index.go` -- Token URL index for response matching
152152
- `internal/proxy/oauth_response.go` -- Response interception, phantom swap, async vault persistence
153+
- `internal/proxy/quic_sni.go` -- `ExtractQUICSNI` decrypts QUIC Initial to extract SNI hostname
153154
- `internal/container/docker.go` -- `InjectEnvVars` implementation for Docker backend
154155
- `internal/container/types.go` -- `ContainerManager` interface with `InjectEnvVars`
155156
- `internal/store/migrations/000002_credential_meta.up.sql` -- Schema for credential metadata
@@ -165,23 +166,29 @@ Extends phantom swap to handle OAuth credentials bidirectionally. Static credent
165166
| SSH | Jump host, key from vault | N/A | Per-connection (channels belong to one session) |
166167
| IMAP/SMTP | AUTH command proxy, phantom password swap | N/A | Per-connection (one mailbox session) |
167168
| DNS | N/A | Deny-only (NXDOMAIN). See DNS design note below. | Per-query deny, other verdicts resolved at SOCKS5 |
168-
| QUIC/HTTP3 | HTTP/3 MITM via quic-go | Full HTTP/3 request/response | Per-request (each HTTP/3 request triggers policy check) |
169+
| QUIC/HTTP3 | HTTP/3 MITM via quic-go, SNI from Initial packet | Full HTTP/3 request/response | Per-request (each HTTP/3 request triggers policy check) |
169170
| APNS | Connection-level allow/deny (port 5223) | N/A | Per-connection |
170171

171172
**Per-request policy evaluation** applies to HTTP/HTTPS, gRPC-over-HTTP/2, and QUIC/HTTP3. Policy is re-evaluated for every HTTP request (or HTTP/2 stream, or HTTP/3 request), so "Allow Once" permits a single request and subsequent requests on the same connection re-trigger the approval flow. When a per-request approval resolves to "Always Allow" or "Always Deny", the `RequestPolicyChecker` persists the new rule to the policy store via its `PersistRuleFunc` callback and swaps in a freshly compiled engine, so subsequent requests match via the fast path instead of re-entering the approval flow. A fast path skips per-request checks when the SOCKS5 CONNECT matched an explicit allow rule (`RuleMatch`, not default verdict) so normally allowed destinations incur no extra overhead. WebSocket, SSH, and IMAP/SMTP remain connection-level on purpose: per-message or per-command policy on those would blow past the broker's 5/min per-destination rate limit and break normal usage.
172173

173174
**MITM library:** HTTPS interception uses go-mitmproxy (`github.com/lqqyt2423/go-mitmproxy`). The `SluiceAddon` struct in `internal/proxy/addon.go` implements go-mitmproxy's `Addon` interface. `Requestheaders` fires per HTTP/2 stream, giving true per-request policy for gRPC and other HTTP/2 traffic. `Request` handles credential injection (three-pass phantom swap). `Response` handles OAuth token interception.
174175

175-
**QUIC per-request:** `EvaluateQUICDetailed` returns Ask when an ask rule matches. The UDP dispatch loop creates a `RequestPolicyChecker` and passes it to `buildHandler`, which calls `CheckAndConsume` per HTTP/3 request.
176+
**QUIC per-request:** `EvaluateQUICDetailed` returns Ask when an ask rule matches and falls back to the engine's configured default verdict (not hardcoded Deny). The UDP dispatch loop creates a `RequestPolicyChecker` and passes it to `buildHandler`, which calls `CheckAndConsume` per HTTP/3 request. When the default verdict is "allow", a per-request checker is still attached (with seed credits of 1) so long-lived QUIC sessions re-evaluate policy on subsequent requests.
176177

177-
See `internal/proxy/request_policy.go`, `internal/policy/engine.go` (`EvaluateDetailed`, `EvaluateQUICDetailed`), and `internal/proxy/addon.go` (`SluiceAddon`).
178+
**QUIC SNI extraction:** Hostname recovery uses `ExtractQUICSNI()` to decrypt the QUIC Initial packet and extract SNI from the embedded TLS ClientHello. QUIC Initial packets encrypt the ClientHello, but the encryption keys are derived from the Destination Connection ID (DCID) visible in the packet header (RFC 9001 Section 5). Supports both QUIC v1 and v2 salts. Falls back to DNS reverse cache lookup, then raw IP if extraction fails.
179+
180+
**QUIC broker dedup:** `pendingQUICSessions` in `server.go` prevents duplicate Telegram approval prompts when multiple UDP packets arrive for the same destination during the approval wait. Packets are buffered (max 32 per session). When approval resolves, buffered packets are flushed (if allowed) or discarded (if denied).
181+
182+
See `internal/proxy/request_policy.go`, `internal/policy/engine.go` (`EvaluateDetailed`, `EvaluateQUICDetailed`), `internal/proxy/quic_sni.go` (`ExtractQUICSNI`), and `internal/proxy/addon.go` (`SluiceAddon`).
178183

179184
## Implementation Details
180185

181186
### Policy engine
182187

183188
`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()`.
184189

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+
185192
### Protocol detection
186193

187194
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.
@@ -192,7 +199,7 @@ Two-phase detection: port-based guess first, then byte-level for non-standard po
192199

193200
`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.
194201

195-
**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.
196203

197204
### Audit logger
198205

cmd/sluice/flagutil.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,28 @@ func parsePortsList(s string) ([]int, error) {
3333
return ports, nil
3434
}
3535

36+
// parseProtocolsList parses a comma-separated string of protocol names into
37+
// a []string. An empty input returns (nil, nil). Whitespace around each
38+
// entry is trimmed and the name is lowercased.
39+
//
40+
// Validation against the known protocol set is deferred to the store layer
41+
// (validateProtocols) which runs during AddRule/ImportTOML. This keeps
42+
// the canonical list in one place.
43+
func parseProtocolsList(s string) ([]string, error) {
44+
if s == "" {
45+
return nil, nil
46+
}
47+
var protocols []string
48+
for _, ps := range strings.Split(s, ",") {
49+
ps = strings.TrimSpace(strings.ToLower(ps))
50+
if ps == "" {
51+
return nil, fmt.Errorf("empty protocol name in list")
52+
}
53+
protocols = append(protocols, ps)
54+
}
55+
return protocols, nil
56+
}
57+
3658
// reorderFlagsBeforePositional returns a copy of args with all flag
3759
// arguments moved before any positional arguments, so that Go's stdlib
3860
// flag parser (which stops at the first non-flag) still sees every flag.

cmd/sluice/policy.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func handlePolicyList(args []string) error {
8989

9090
func handlePolicyAdd(args []string) error {
9191
if len(args) == 0 {
92-
return fmt.Errorf("usage: sluice policy add <allow|deny|ask> <destination> [--ports 443,80] [--name \"reason\"]")
92+
return fmt.Errorf("usage: sluice policy add <allow|deny|ask> <destination> [--ports 443,80] [--protocols quic,udp] [--name \"reason\"]")
9393
}
9494

9595
verdict := args[0]
@@ -100,13 +100,14 @@ func handlePolicyAdd(args []string) error {
100100
fs := flag.NewFlagSet("policy add", flag.ContinueOnError)
101101
dbPath := fs.String("db", "data/sluice.db", "path to SQLite database")
102102
portsStr := fs.String("ports", "", "comma-separated port list (e.g. 443,80)")
103+
protocolsStr := fs.String("protocols", "", "comma-separated protocol list (e.g. quic,udp)")
103104
note := fs.String("name", "", "human-readable name")
104105
if err := fs.Parse(args[1:]); err != nil {
105106
return err
106107
}
107108

108109
if fs.NArg() == 0 {
109-
return fmt.Errorf("usage: sluice policy add <allow|deny|ask> <destination> [--ports 443,80] [--name \"reason\"]")
110+
return fmt.Errorf("usage: sluice policy add <allow|deny|ask> <destination> [--ports 443,80] [--protocols quic,udp] [--name \"reason\"]")
110111
}
111112
destination := fs.Arg(0)
112113

@@ -119,13 +120,18 @@ func handlePolicyAdd(args []string) error {
119120
return err
120121
}
121122

123+
protocols, err := parseProtocolsList(*protocolsStr)
124+
if err != nil {
125+
return err
126+
}
127+
122128
db, err := store.New(*dbPath)
123129
if err != nil {
124130
return fmt.Errorf("open store: %w", err)
125131
}
126132
defer func() { _ = db.Close() }()
127133

128-
id, err := db.AddRule(verdict, store.RuleOpts{Destination: destination, Ports: ports, Name: *note})
134+
id, err := db.AddRule(verdict, store.RuleOpts{Destination: destination, Ports: ports, Protocols: protocols, Name: *note})
129135
if err != nil {
130136
return fmt.Errorf("add rule: %w", err)
131137
}

cmd/sluice/policy_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,68 @@ func TestHandlePolicyAddWithGlob(t *testing.T) {
305305
}
306306
}
307307

308+
func TestHandlePolicyAddWithProtocols(t *testing.T) {
309+
dir := t.TempDir()
310+
dbPath := filepath.Join(dir, "test.db")
311+
312+
output := capturePolicyOutput(t, func() {
313+
if err := handlePolicyAdd([]string{"allow", "--db", dbPath, "--protocols", "quic,udp", "--ports", "443", "cdn.example.com"}); err != nil {
314+
t.Fatalf("handlePolicyAdd with protocols: %v", err)
315+
}
316+
})
317+
318+
if !strings.Contains(output, "added allow rule") {
319+
t.Errorf("expected 'added allow rule' in output: %s", output)
320+
}
321+
322+
db, err := store.New(dbPath)
323+
if err != nil {
324+
t.Fatal(err)
325+
}
326+
defer func() { _ = db.Close() }()
327+
328+
rules, _ := db.ListRules(store.RuleFilter{Verdict: "allow"})
329+
if len(rules) != 1 {
330+
t.Fatalf("expected 1 rule, got %d", len(rules))
331+
}
332+
if len(rules[0].Protocols) != 2 {
333+
t.Fatalf("expected 2 protocols, got %v", rules[0].Protocols)
334+
}
335+
protos := make(map[string]bool)
336+
for _, p := range rules[0].Protocols {
337+
protos[p] = true
338+
}
339+
if !protos["quic"] || !protos["udp"] {
340+
t.Errorf("expected quic and udp, got %v", rules[0].Protocols)
341+
}
342+
}
343+
344+
func TestHandlePolicyAddInvalidProtocol(t *testing.T) {
345+
dir := t.TempDir()
346+
dbPath := filepath.Join(dir, "test.db")
347+
348+
err := handlePolicyAdd([]string{"allow", "--db", dbPath, "--protocols", "htp", "example.com"})
349+
if err == nil {
350+
t.Fatal("expected error for invalid protocol")
351+
}
352+
if !strings.Contains(err.Error(), "unknown protocol") {
353+
t.Errorf("expected 'unknown protocol' in error, got: %v", err)
354+
}
355+
}
356+
357+
func TestHandlePolicyAddEmptyProtocol(t *testing.T) {
358+
dir := t.TempDir()
359+
dbPath := filepath.Join(dir, "test.db")
360+
361+
err := handlePolicyAdd([]string{"allow", "--db", dbPath, "--protocols", "quic,,udp", "example.com"})
362+
if err == nil {
363+
t.Fatal("expected error for empty protocol name")
364+
}
365+
if !strings.Contains(err.Error(), "empty protocol name") {
366+
t.Errorf("expected 'empty protocol name' in error, got: %v", err)
367+
}
368+
}
369+
308370
// --- handlePolicyRemove tests ---
309371

310372
func TestHandlePolicyRemoveValid(t *testing.T) {

0 commit comments

Comments
 (0)