fix(approval): match static-rule hosts case-insensitively - #41
Conversation
Host names are case-insensitive (RFC 4343), but staticURLMatches compared the URL and the rule pattern byte for byte. A rule written in lower case did not match a request that varied the case of the host, even though both reach the same server. An allow rule for *.example.com missed a request to API.Example.com, which then fell through to the LLM judge instead of matching the rule. Fold the scheme and host of both the URL and the pattern to lower case before matching. Only the authority is folded, so paths and query strings stay case-sensitive. This covers prefix, exact and glob rules, and the eval runner's MatchesStaticRules.
|
| Filename | Overview |
|---|---|
| internal/approval/manager.go | Adds lowerAuthority to fold scheme and host to lowercase before stripDefaultPort, then before pattern matching — correctly orders operations so uppercase-scheme patterns have their default ports stripped |
| internal/approval/hostcase_test.go | New test file with 8 table-driven cases covering prefix/exact/glob rules for both allow and deny actions, plus an upper-case pattern against a lower-case URL, and a negative case confirming path case is preserved |
| internal/approval/manager_llm_test.go | Adds 6 new rows to TestStaticURLMatches, including two specifically testing that an uppercase scheme with a redundant default port is normalised correctly — directly covering the ordering dependency between lowerAuthority and stripDefaultPort |
Reviews (3): Last reviewed commit: "fix(approval): fold case before strippin..." | Re-trigger Greptile
stripDefaultPort matches the scheme with a case-sensitive HasPrefix, so a pattern authored as "HTTPS://api.example.com:443/v1" kept its redundant port while the request URL had it stripped, leaving the two strings different and the rule unmatched. Run lowerAuthority first so the scheme is already lower case by the time stripDefaultPort inspects it.
|
Good catch, confirmed. Swapped the order so the case fold runs first, and added table cases for HTTPS://host:443 and HTTP://host:80 so the ordering is pinned. |
bjhaid
left a comment
There was a problem hiding this comment.
LGTM. it would be nice if we could do all of this work on load of StaticRule in a non-exported field normalizedPattern rather than on every request, probably a micro optimization but it is something that should count in the long run.
Happy to merge as it is and you can follow up if you want to address my concerns or I can find time to do it when I have some bandwidth.
…43) * fix(approval): enforce host boundary in static prefix and glob rules Static "prefix" rules matched with a raw whole-URL strings.HasPrefix, so an allow rule for "https://api.github.com" also matched "https://api.github.com.evil.example/steal". Because a matching static allow short-circuits the LLM judge, this let a compromised agent exfiltrate its credentials to any attacker-controlled host whose name begins with an allowed host string. Enforce an authority/path boundary after the prefix check, operating on the already-normalized URL and pattern so the single-pass percent-decode, case-folded authority (#41), and default-port stripping are all preserved. A host-only pattern now matches only when the remainder is empty or begins with '/', '?', or '#'; a pattern already carrying a path terminator keeps matching any continuation. This also blocks the "user@host" userinfo variant. Reject glob patterns with a bare trailing "*" (e.g. "https://api.github.com*") in ValidateStaticRules, since they compile to a regexp that crosses the host boundary; "*.github.com/*" and "https://api.github.com/*" stay valid. Add prefix bypass/positive-control and glob-validation test cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(approval): prevent glob authority stars from crossing host boundary The initial fix only rejected a bare trailing "*" in glob patterns, but an earlier star still crossed the host boundary: "https://api.github.com*/*" passed validation yet compiled to a regexp whose ".*" consumed ".evil.example", matching "https://api.github.com.evil.example/steal". The "*." subdomain form was also accepted mid-authority, so "api.*.com/*" matched "api.github.com.evil.com/x". Harden globToRegexp to track authority position: the "*." multi-label subdomain form is honoured only at the start of the authority; any other authority-position "*" compiles to the boundary-safe "[^./:]*", and a mid-authority "*." collapses to a literal dot so it cannot span labels. Path-position "*" is unchanged. This protects patterns loaded from the store that never re-pass validation. ValidateStaticRules now strips the scheme before analysis (so its view of the authority matches the matcher's), permits "*." only at authority position 0, and rejects any other bare "*" in the authority with a clear error. Add match-path and validation regression tests for the earlier-star and mid-authority "*." bypasses plus the legitimate subdomain/path controls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(approval): report correct rule index in glob authority errors The authority-scan loops in ValidateStaticRules reused the name "i", shadowing the outer rule index, so the "rule %d" in the new glob authority-wildcard errors printed the byte offset instead of the rule number. Rename the inner loop variables to "pos" so "rule %d" refers to the offending rule and "position %d" to the offset within the authority. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem
Host names are case-insensitive (RFC 4343), but
staticURLMatchescompares the URL and the rule pattern byte for byte. A rule written in lower case does not match a request that varies the case of the host, even though both reach the same server.The effect shows up most often on allow rules. A rule for
*.example.comdoes not matchhttps://API.Example.com/v1/users, so traffic the operator already approved falls through to the LLM judge. That is a judge call on every such request, or a denial when the fallback is deny.Deny rules have the same gap. It matters most when a broad allow rule is paired with a host-based deny carve-out, since a static allow returns before the judge runs.
Fix
Lower-case the scheme and host of both the URL and the pattern before matching. Only the authority is folded, so paths and query strings keep their case. Userinfo ahead of
@is left alone.Doing this inside
staticURLMatchescovers prefix, exact and glob rules, andMatchesStaticRulesin the eval runner picks it up as well.Tests
TestStaticRuleHostCaseInsensitivecovers upper and mixed case hosts against prefix, exact and glob rules for both actions, including a pattern authored in upper case matched against a lower-case request. It also asserts that a path differing only in case still does not match, so the fold stays limited to the authority.Four rows added to
TestStaticURLMatchesfor the same behaviour at the matcher level.Ran
go test ./internal/approval/on Go 1.26.1 with the testcontainers Postgres setup. The new cases fail before the change and pass after it, and the rest of the package is unaffected.