go-middleware-client-header-trust
Root cause. The vulnerable middleware built Entitlements directly from client-controlled request headers (X-ASCE-Tier, X-ASCE-Quota-Limit, X-ASCE-Features). Any caller could forge X-ASCE-Tier: enterprise and bypass entitlement checks — a classic entitlement-bypass landmine.
The fix (repo: ~/entitlements, module entitlements):
1. Delete every r.Header.Get("X-ASCE-*") read.
2. Derive identity server-side from auth.KeyRecordContextKey (populated only by the authn middleware from verified key lookups — never from headers).
3. Derive tier server-side via tiers.Service.GetAccountTier(apiKeyID), falling back to tiers.Free() when the key is unassigned ((*Tier, nil)) or the db is nil ((nil, error)).
4. Static regression tripwire test forbids any Header.Get("X-ASCE / Header["X-ASCE from reappearing.
Before (vulnerable, deleted):
func EntitlementsFromHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.ParseInt(r.Header.Get("X-ASCE-Quota-Limit"), 10, 64)
e := Entitlements{
TierName: r.Header.Get("X-ASCE-Tier"), // ← client-controlled!
QuotaLimit: limit, // ← client-controlled!
Features: strings.Split(r.Header.Get("X-ASCE-Features"), ","),
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), entitlementsCtxKey, e)))
})
}
After (fixed — middleware/entitlements.go):
func EntitlementsFromKeyRecord(svc *tiers.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
e := resolve(r.Context(), svc) // r's headers are never consulted
ctx := context.WithValue(r.Context(), entitlementsCtxKey, e)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func resolve(ctx context.Context, svc *tiers.Service) Entitlements {
rec, ok := auth.KeyRecordFromContext(ctx) // identity: server-side only
if !ok {
return fromTier(tiers.Free()) // unauthenticated caller
}
t, err := svc.GetAccountTier(rec.APIKeyID) // tier: server-side only
if err != nil || t == nil {
return fromTier(tiers.Free()) // unassigned or DB nil → free
}
return fromTier(*t)
}
Supporting pieces: auth.KeyRecordContextKey (typed context key for the verified KeyRecord), and tiers.Service.GetAccountTier with documented (*Tier, nil) = unassigned / (nil, error) = db-nil semantics plus a nil-receiver guard. The old middleware is retained only as entitlements_legacy.go behind //go:build legacy for diff reference — excluded from default builds.
Verified with Go 1.26.0: `go vet ./...` clean, `go build ./...` clean, `go test -race -count=1 ./...` all pass. Grep confirms zero `X-ASCE` reads in production code (only the tripwire test and the build-tagged legacy reference match).
Edge cases tested (`middleware/entitlements_test.go`):
- **Upgrade attempt**: free key + forged `enterprise/999999999` headers → **free**; unauthenticated caller + forged headers → **free**.
- **Downgrade attempt**: premium key + forged `free/1` headers → **premium** (headers have zero effect in *both* directions).
- **Unassigned key** (`GetAccountTier` → `nil, nil`) + forged headers → **free**.
- **DB nil** (`SetDown(true)`) and **nil `*tiers.Service`** + forged headers → **free**.
- **Handler-level enforcement**: free key forging enterprise headers still gets **402**; premium key claiming free still gets **200**.
- **Regression tripwire** `TestNoClientHeaderReads`: statically scans production sources; fails if any `Header.Get("X-ASCE` / `Header["X-ASCE` reappears.
- **Contrast proof** (`-tags legacy`): `TestLegacyShowsTheVulnerability` confirms the old code *would* grant enterprise to an unauthenticated caller from headers alone — the exact bypass the fix closes.
Test count: **11** (8 entitlement-matrix subtests + 3 top-level tests).{"model": "deepseek-v4-flash", "problem_class": "go-middleware-client-header-trust", "result": "passed", "tests": 11}Root cause. The vulnerable middleware built Entitlements directly from client-controlled request headers (X-ASCE-Tier, X-ASCE-Quota-Limit, X-ASCE-Features). Any caller could forge X-ASCE-Tier: enterprise and bypass entitlement checks — a classic entitlement-bypass landmine.
The fix (repo: ~/entitlements, module entitlements):
1. Delete every r.Header.Get("X-ASCE-*") read.
2. Derive identity server-side from auth.KeyRecordContextKey (populated only by the authn middleware from verified key lookups — never from headers).
3. Derive tier server-side via tiers.Service.GetAccountTier(apiKeyID), falling back to tiers.Free() when the key is unassigned ((*Tier, nil)) or the db is nil ((nil, error)).
4. Static regression tripwire test forbids any Header.Get("X-ASCE / Header["X-ASCE from reappearing.
Before (vulnerable, deleted):
func EntitlementsFromHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.ParseInt(r.Header.Get("X-ASCE-Quota-Limit"), 10, 64)
e := Entitlements{
TierName: r.Header.Get("X-ASCE-Tier"), // ← client-controlled!
QuotaLimit: limit, // ← client-controlled!
Features: strings.Split(r.Header.Get("X-ASCE-Features"), ","),
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), entitlementsCtxKey, e)))
})
}
After (fixed — middleware/entitlements.go):
func EntitlementsFromKeyRecord(svc *tiers.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
e := resolve(r.Context(), svc) // r's headers are never consulted
ctx := context.WithValue(r.Context(), entitlementsCtxKey, e)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func resolve(ctx context.Context, svc *tiers.Service) Entitlements {
rec, ok := auth.KeyRecordFromContext(ctx) // identity: server-side only
if !ok {
return fromTier(tiers.Free()) // unauthenticated caller
}
t, err := svc.GetAccountTier(rec.APIKeyID) // tier: server-side only
if err != nil || t == nil {
return fromTier(tiers.Free()) // unassigned or DB nil → free
}
return fromTier(*t)
}
Supporting pieces: auth.KeyRecordContextKey (typed context key for the verified KeyRecord), and tiers.Service.GetAccountTier with documented (*Tier, nil) = unassigned / (nil, error) = db-nil semantics plus a nil-receiver guard. The old middleware is retained only as entitlements_legacy.go behind //go:build legacy for diff reference — excluded from default builds.
Verified with Go 1.26.0: `go vet ./...` clean, `go build ./...` clean, `go test -race -count=1 ./...` all pass. Grep confirms zero `X-ASCE` reads in production code (only the tripwire test and the build-tagged legacy reference match).
Edge cases tested (`middleware/entitlements_test.go`):
- **Upgrade attempt**: free key + forged `enterprise/999999999` headers → **free**; unauthenticated caller + forged headers → **free**.
- **Downgrade attempt**: premium key + forged `free/1` headers → **premium** (headers have zero effect in *both* directions).
- **Unassigned key** (`GetAccountTier` → `nil, nil`) + forged headers → **free**.
- **DB nil** (`SetDown(true)`) and **nil `*tiers.Service`** + forged headers → **free**.
- **Handler-level enforcement**: free key forging enterprise headers still gets **402**; premium key claiming free still gets **200**.
- **Regression tripwire** `TestNoClientHeaderReads`: statically scans production sources; fails if any `Header.Get("X-ASCE` / `Header["X-ASCE` reappears.
- **Contrast proof** (`-tags legacy`): `TestLegacyShowsTheVulnerability` confirms the old code *would* grant enterprise to an unauthenticated caller from headers alone — the exact bypass the fix closes.
Test count: **11** (8 entitlement-matrix subtests + 3 top-level tests).{"model": "deepseek-v4-flash", "problem_class": "go-middleware-client-header-trust", "result": "passed", "tests": 11}