Skip to content

fix(approval): match static-rule hosts case-insensitively - #41

Merged
bjhaid merged 2 commits into
brexhq:mainfrom
brian717:fix/host-case-insensitive-matching
Jul 27, 2026
Merged

bjhaid merged 2 commits into
brexhq:mainfrom
brian717:fix/host-case-insensitive-matching

Conversation

@brian717

Copy link
Copy Markdown
Contributor

Problem

Host names are case-insensitive (RFC 4343), but staticURLMatches compares 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.com does not match https://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 staticURLMatches covers prefix, exact and glob rules, and MatchesStaticRules in the eval runner picks it up as well.

Tests

TestStaticRuleHostCaseInsensitive covers 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 TestStaticURLMatches for 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.

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.
@greptile-apps

greptile-apps Bot commented Jul 24, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR fixes staticURLMatches to compare URL hosts case-insensitively, as required by RFC 4343, by introducing lowerAuthority which folds only the scheme and host to lowercase (preserving path, query, and userinfo). The fix is correctly ordered to run before stripDefaultPort, which was already case-sensitive on the scheme prefix — the previous review comment flagging this ordering issue is resolved by this change.

  • lowerAuthority locates the authority segment (handling schemeless patterns, IPv6, and userinfo containing @), lowercases scheme and host only, and is called before stripDefaultPort in staticURLMatches.
  • Two test files add comprehensive table-driven coverage: prefix/exact/glob rules for allow and deny, upper-case patterns against lower-case URLs, and two cases that explicitly exercise the lowerAuthority-before-stripDefaultPort ordering for HTTPS://host:443 patterns.

Confidence Score: 5/5

Safe to merge — the change is narrowly scoped to case-folding only the scheme and host before matching, path and query semantics are unchanged, and the ordering concern raised in the previous review is directly addressed.

The implementation is correct: lowerAuthority isolates exactly the authority segment (handling schemeless patterns, userinfo with @, IPv6, and bare hostnames), and calling it before stripDefaultPort ensures uppercase-scheme patterns like HTTPS://host:443 have their redundant ports stripped. Test coverage is thorough — both the unit-level matcher tests and the higher-level MatchesStaticRules tests include negative cases (path case preserved) and the specific ordering scenario that was previously flagged.

Files Needing Attention: No files require special attention.

Important Files Changed

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.
@brian717

Copy link
Copy Markdown
Contributor Author

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 bjhaid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bjhaid
bjhaid merged commit 7cd8303 into brexhq:main Jul 27, 2026
4 checks passed
bjhaid added a commit that referenced this pull request Sep 19, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants