Files
gateway/auth_middleware_security_test.go
eaad111dc8 fix(billing-gate): fail-closed balance check + regression tests (#25)
origin/main already serves the correct /v1/billing/balance path (#24 /
db2931e), but the prepaid check is still FAIL-OPEN: a commerce error,
non-200, or unreachable commerce returns "allow" — so a logged-in $0
user gets free AI whenever commerce hiccups. That violates the no-free
policy.

- checkBalance now FAILS CLOSED. Contract: (true,nil)=allow,
  (false,nil)=out-of-funds, (false,err)=could-not-determine. The only
  allow-without-proof case is baseURL unset (billing not enforced).
- Caller maps (false,nil) -> 402 insufficient_balance and (false,err)
  -> 503 billing_unavailable, so a commerce outage no longer tells a
  paying customer they are out of funds (and 503 is retryable/alertable).
- url.QueryEscape the user id in the commerce URL.
- Tests pin both invariants — and would have caught the `-X theirs`
  merge-train regression that reverted #24:
  TestBillingCheck_HitsV1PathAndAllows (asserts the gateway calls
  /v1/billing/balance), TestBillingCheck_FailsClosedOnCommerceError
  (commerce 500 -> 503, no backend), TestBillingCheckerFailClosed.

Pre-existing on origin/main, NOT introduced here: TestPortFromEnvNumeric
/ TestPortFromEnvNonNumeric fail due to a krakend-koanf env-merge change
from the dep bumps — unrelated to billing.

Co-authored-by: Antje Worring <[email protected]>
Co-authored-by: hanzo-dev <[email protected]>
2026-06-19 20:44:44 -07:00

1731 lines
50 KiB
Go

package gateway
// Security regression tests for auth middleware.
//
// These tests exist to prevent reintroduction of auth bypass vulnerabilities.
// They cover: X-Identity header injection, JWT issuer validation, and correct
// identity header propagation on valid auth.
//
// Every test in this file must continue to pass before any merge to main.
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
gojose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// testJWKS holds a test RSA key pair and provides helpers for creating
// signed JWTs and serving a JWKS endpoint.
type testJWKS struct {
key *rsa.PrivateKey
keyID string
signer gojose.Signer
}
func newTestJWKS(t *testing.T) *testJWKS {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate RSA key: %v", err)
}
kid := "test-key-1"
signingKey := gojose.SigningKey{Algorithm: gojose.RS256, Key: key}
opts := (&gojose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid)
signer, err := gojose.NewSigner(signingKey, opts)
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
return &testJWKS{
key: key,
keyID: kid,
signer: signer,
}
}
// jwksJSON returns the JWKS as JSON bytes (public key only).
func (tj *testJWKS) jwksJSON(t *testing.T) []byte {
t.Helper()
jwk := gojose.JSONWebKey{
Key: &tj.key.PublicKey,
KeyID: tj.keyID,
Algorithm: string(gojose.RS256),
Use: "sig",
}
jwks := gojose.JSONWebKeySet{Keys: []gojose.JSONWebKey{jwk}}
data, err := json.Marshal(jwks)
if err != nil {
t.Fatalf("failed to marshal JWKS: %v", err)
}
return data
}
// serveJWKS starts an httptest.Server that serves the JWKS endpoint.
// The caller must defer server.Close().
func (tj *testJWKS) serveJWKS(t *testing.T) *httptest.Server {
t.Helper()
data := tj.jwksJSON(t)
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}))
}
// signToken creates a signed JWT string with the given claims.
func (tj *testJWKS) signToken(t *testing.T, claims interface{}) string {
t.Helper()
raw, err := jwt.Signed(tj.signer).Claims(claims).Serialize()
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
return raw
}
// validClaims returns hanzoJWTClaims with valid issuer, audience, subject,
// owner, and expiry for use in tests.
func validClaims(issuer, audience string) hanzoJWTClaims {
now := time.Now()
return hanzoJWTClaims{
Claims: jwt.Claims{
Issuer: issuer,
Subject: "alice",
Audience: jwt.Audience{audience},
IssuedAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
Expiry: jwt.NewNumericDate(now.Add(10 * time.Minute)),
},
Owner: "hanzo",
Name: "Alice",
Email: "[email protected]",
}
}
// setupMiddlewareWithJWKS creates a gin engine with the auth middleware
// wired to a test JWKS server. Returns the engine and test JWKS helper.
// The caller must defer jwksServer.Close().
func setupMiddlewareWithJWKS(t *testing.T, overrideCfg func(*AuthConfig)) (*gin.Engine, *testJWKS, *httptest.Server) {
t.Helper()
gin.SetMode(gin.TestMode)
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
cfg := AuthConfig{
Enabled: true,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
BillingEnabled: false, // Disable billing for security tests
PublicPaths: []string{"/healthz"},
PublicHosts: []string{"hanzo.id"},
RequireAuth: true,
}
if overrideCfg != nil {
overrideCfg(&cfg)
}
r := gin.New()
r.Use(NewAuthMiddleware(cfg))
return r, tj, jwksServer
}
// --- Test 1: X-Identity Header Injection Prevention on API Key Path ---
func TestAPIKeyAuth_StripsIncomingXIdentityHeaders(t *testing.T) {
r, _, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
var gotOrgID, gotUserID, gotEmail string
r.POST("/v1/chat/completions", func(c *gin.Context) {
gotOrgID = c.Request.Header.Get("X-Org-Id")
gotUserID = c.Request.Header.Get("X-User-Id")
gotEmail = c.Request.Header.Get("X-User-Email")
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer sk-test-key-abcdef")
// Attacker injects forged identity headers
req.Header.Set("X-User-Id", "attacker")
req.Header.Set("X-Org-Id", "victim-org")
req.Header.Set("X-User-Email", "[email protected]")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for API key passthrough, got %d", w.Code)
}
if gotOrgID != "" {
t.Errorf("SECURITY: X-Org-Id was NOT stripped on API key path, got %q", gotOrgID)
}
if gotUserID != "" {
t.Errorf("SECURITY: X-User-Id was NOT stripped on API key path, got %q", gotUserID)
}
if gotEmail != "" {
t.Errorf("SECURITY: X-User-Email was NOT stripped on API key path, got %q", gotEmail)
}
}
// --- Test 2: JWT Empty Issuer Rejection ---
func TestJWTAuth_RejectsEmptyIssuer(t *testing.T) {
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
backendReached := false
r.GET("/api/test", func(c *gin.Context) {
backendReached = true
c.Status(http.StatusOK)
})
// Create JWT with empty issuer
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
claims.Claims.Issuer = "" // Empty issuer -- this MUST be rejected
token := tj.signToken(t, claims)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("SECURITY: empty issuer JWT should get 401, got %d", w.Code)
}
if backendReached {
t.Error("SECURITY: empty issuer JWT reached the backend handler -- auth bypass!")
}
}
// --- Test 3: JWT Missing Issuer Rejection ---
func TestJWTAuth_RejectsMissingIssuer(t *testing.T) {
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
backendReached := false
r.GET("/api/test", func(c *gin.Context) {
backendReached = true
c.Status(http.StatusOK)
})
// Create a JWT with no issuer claim at all.
// We build claims manually using a map to ensure "iss" is completely absent.
now := time.Now()
claimsMap := map[string]interface{}{
"sub": "alice",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"owner": "hanzo",
"name": "Alice",
"email": "[email protected]",
// No "iss" key at all
}
token := tj.signToken(t, claimsMap)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("SECURITY: missing issuer JWT should get 401, got %d", w.Code)
}
if backendReached {
t.Error("SECURITY: missing issuer JWT reached the backend handler -- auth bypass!")
}
}
// --- Test 4: Valid JWT Still Works ---
func TestJWTAuth_AcceptsValidIssuer(t *testing.T) {
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
var gotOrgID, gotUserID, gotEmail string
r.GET("/api/test", func(c *gin.Context) {
gotOrgID = c.Request.Header.Get("X-Org-Id")
gotUserID = c.Request.Header.Get("X-User-Id")
gotEmail = c.Request.Header.Get("X-User-Email")
c.Status(http.StatusOK)
})
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
token := tj.signToken(t, claims)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("valid JWT should get 200, got %d", w.Code)
}
if gotOrgID != "hanzo" {
t.Errorf("X-Org-Id = %q, want %q", gotOrgID, "hanzo")
}
if gotUserID != "alice" {
t.Errorf("X-User-Id = %q, want %q", gotUserID, "alice")
}
if gotEmail != "[email protected]" {
t.Errorf("X-User-Email = %q, want %q", gotEmail, "[email protected]")
}
}
// --- Test 5: X-Identity Headers Set Correctly on Valid Auth ---
func TestValidAuth_SetsCorrectXIdentityHeaders(t *testing.T) {
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
var gotOrgID, gotUserID, gotEmail string
r.GET("/api/test", func(c *gin.Context) {
gotOrgID = c.Request.Header.Get("X-Org-Id")
gotUserID = c.Request.Header.Get("X-User-Id")
gotEmail = c.Request.Header.Get("X-User-Email")
c.Status(http.StatusOK)
})
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
claims.Owner = "acme-corp"
claims.Claims.Subject = "bob"
claims.Email = "[email protected]"
token := tj.signToken(t, claims)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
// Attacker tries to override with forged headers alongside a valid JWT
req.Header.Set("X-Org-Id", "evil-org")
req.Header.Set("X-User-Id", "evil-admin")
req.Header.Set("X-User-Email", "[email protected]")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("valid JWT should get 200, got %d", w.Code)
}
// Headers MUST come from the validated JWT, NOT from the forged request headers
if gotOrgID != "acme-corp" {
t.Errorf("SECURITY: X-Org-Id = %q, want %q (from JWT owner claim)", gotOrgID, "acme-corp")
}
if gotUserID != "bob" {
t.Errorf("SECURITY: X-User-Id = %q, want %q (from JWT sub claim)", gotUserID, "bob")
}
if gotEmail != "[email protected]" {
t.Errorf("SECURITY: X-User-Email = %q, want %q (from JWT email claim)", gotEmail, "[email protected]")
}
}
// --- Test 6: No X-Identity Header Passthrough on Any Auth Path ---
func TestAllAuthPaths_NoXIdentityPassthrough(t *testing.T) {
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
// Build a valid token for the JWT path test
validToken := tj.signToken(t, validClaims("https://hanzo.id", "https://api.hanzo.ai"))
forgedHeaders := map[string]string{
"X-Org-Id": "forged-org",
"X-User-Id": "forged-admin",
"X-User-Email": "[email protected]",
"X-User-Permissions": "20", // Admin|Live attempted forgery
"X-Hanzo-Custom": "forged-custom", // Mixed case to test case-insensitive stripping
}
tests := []struct {
name string
authHeader string // Authorization header value
host string
path string
requireAuth bool
expectCode int
}{
{
name: "API key (sk-*) with forged X-Identity headers",
authHeader: "Bearer sk-live-test-key-12345",
host: "api.hanzo.ai",
path: "/v1/chat/completions",
requireAuth: true,
expectCode: http.StatusOK,
},
{
name: "API key (hk-*) with forged X-Identity headers",
authHeader: "Bearer hk-0d2eb9cfafd049389f2904cad770a9d8",
host: "api.hanzo.ai",
path: "/v1/chat/completions",
requireAuth: true,
expectCode: http.StatusOK,
},
{
name: "API key (fw_*) with forged X-Identity headers",
authHeader: "Bearer fw_test_fireworks_key",
host: "api.hanzo.ai",
path: "/v1/chat/completions",
requireAuth: true,
expectCode: http.StatusOK,
},
{
name: "Widget key (hz_*) with forged X-Identity headers",
authHeader: "Bearer hz_widget_public",
host: "api.hanzo.ai",
path: "/v1/chat/completions",
requireAuth: true,
expectCode: http.StatusOK,
},
{
name: "Valid JWT with forged X-Identity headers",
authHeader: "Bearer " + validToken,
host: "api.hanzo.ai",
path: "/api/test",
requireAuth: true,
expectCode: http.StatusOK,
},
{
name: "No auth (optional) with forged X-Identity headers",
authHeader: "",
host: "api.hanzo.ai",
path: "/api/test",
requireAuth: false,
expectCode: http.StatusOK,
},
{
name: "Public path with forged X-Identity headers",
authHeader: "",
host: "api.hanzo.ai",
path: "/healthz",
requireAuth: true,
expectCode: http.StatusOK,
},
{
name: "Public host with forged X-Identity headers",
authHeader: "",
host: "hanzo.id",
path: "/api/test",
requireAuth: true,
expectCode: http.StatusOK,
},
{
name: "Auth disabled with forged X-Identity headers",
authHeader: "",
host: "api.hanzo.ai",
path: "/api/test",
requireAuth: false,
expectCode: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
// Special case: "auth disabled" test
enabled := true
if tt.name == "Auth disabled with forged X-Identity headers" {
enabled = false
}
cfg := AuthConfig{
Enabled: enabled,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
BillingEnabled: false,
PublicPaths: []string{"/healthz"},
PublicHosts: []string{"hanzo.id"},
RequireAuth: tt.requireAuth,
}
r := gin.New()
r.Use(NewAuthMiddleware(cfg))
var receivedHeaders map[string]string
handler := func(c *gin.Context) {
receivedHeaders = make(map[string]string)
for key := range forgedHeaders {
receivedHeaders[key] = c.Request.Header.Get(key)
}
c.Status(http.StatusOK)
}
r.GET(tt.path, handler)
r.POST(tt.path, handler)
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
if tt.path == "/v1/chat/completions" {
req = httptest.NewRequest(http.MethodPost, tt.path, nil)
}
req.Host = tt.host
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
// Inject all forged headers
for k, v := range forgedHeaders {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.expectCode {
t.Fatalf("expected status %d, got %d", tt.expectCode, w.Code)
}
// If the handler was reached, verify no forged X-Identity headers survived
if receivedHeaders != nil {
for key, forgedVal := range forgedHeaders {
got := receivedHeaders[key]
// For the valid JWT path, X-Identity headers are SET from the JWT
// claims. They must NOT match the forged values.
if got == forgedVal {
t.Errorf("SECURITY: forged header %s=%q was NOT stripped", key, forgedVal)
}
}
}
})
}
}
// --- Test 7: API Key Auth Doesn't Skip Validation ---
func TestAPIKeyAuth_ValidatesKey(t *testing.T) {
gin.SetMode(gin.TestMode)
// A request with a token that is NOT a recognized API key prefix and
// NOT a valid JWT should be rejected when auth is required.
r, _, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
backendReached := false
r.POST("/v1/chat/completions", func(c *gin.Context) {
backendReached = true
c.Status(http.StatusOK)
})
invalidTokens := []struct {
name string
token string
}{
{"random string", "not-a-valid-token"},
{"almost an API key", "sk"},
{"empty bearer", ""},
{"jwt-like but invalid", "eyJhbGciOiJSUzI1NiJ9.invalid.signature"},
{"api key prefix but mangled JWT attempt", "xx-fake-key"},
}
for _, tt := range invalidTokens {
t.Run(tt.name, func(t *testing.T) {
backendReached = false
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
req.Host = "api.hanzo.ai"
if tt.token != "" {
req.Header.Set("Authorization", "Bearer "+tt.token)
}
r.ServeHTTP(w, req)
if tt.token == "" {
// No token at all with RequireAuth=true -> 401
if w.Code != http.StatusUnauthorized {
t.Errorf("empty token should get 401, got %d", w.Code)
}
} else {
// Invalid token (not API key, not valid JWT) -> 401
if w.Code != http.StatusUnauthorized {
t.Errorf("invalid token %q should get 401, got %d", tt.token, w.Code)
}
}
if backendReached {
t.Errorf("SECURITY: invalid token %q reached backend", tt.token)
}
})
}
}
// --- Test 8: JWT Wrong Issuer Rejection ---
func TestJWTAuth_RejectsWrongIssuer(t *testing.T) {
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
backendReached := false
r.GET("/api/test", func(c *gin.Context) {
backendReached = true
c.Status(http.StatusOK)
})
// Create JWT with wrong issuer
claims := validClaims("https://evil-issuer.com", "https://api.hanzo.ai")
token := tj.signToken(t, claims)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("SECURITY: wrong issuer JWT should get 401, got %d", w.Code)
}
if backendReached {
t.Error("SECURITY: wrong issuer JWT reached the backend handler")
}
}
// --- Test 9: JWT Expired Token Rejection ---
func TestJWTAuth_RejectsExpiredToken(t *testing.T) {
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
backendReached := false
r.GET("/api/test", func(c *gin.Context) {
backendReached = true
c.Status(http.StatusOK)
})
// Create expired JWT (expired 10 minutes ago, beyond the 2min leeway)
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
claims.Claims.Expiry = jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))
claims.Claims.IssuedAt = jwt.NewNumericDate(time.Now().Add(-20 * time.Minute))
token := tj.signToken(t, claims)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expired JWT should get 401, got %d", w.Code)
}
if backendReached {
t.Error("expired JWT reached the backend handler")
}
}
// --- Test 10: JWT Wrong Audience Rejection ---
func TestJWTAuth_RejectsWrongAudience(t *testing.T) {
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
backendReached := false
r.GET("/api/test", func(c *gin.Context) {
backendReached = true
c.Status(http.StatusOK)
})
// Create JWT with wrong audience
claims := validClaims("https://hanzo.id", "https://wrong-audience.com")
token := tj.signToken(t, claims)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("wrong audience JWT should get 401, got %d", w.Code)
}
if backendReached {
t.Error("wrong audience JWT reached the backend handler")
}
}
// --- Test 11: JWT Signed With Wrong Key Rejection ---
func TestJWTAuth_RejectsWrongSigningKey(t *testing.T) {
// Set up middleware with one key, sign token with a different key
r, _, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
backendReached := false
r.GET("/api/test", func(c *gin.Context) {
backendReached = true
c.Status(http.StatusOK)
})
// Create a DIFFERENT key to sign the token
attackerKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate attacker key: %v", err)
}
attackerSigningKey := gojose.SigningKey{Algorithm: gojose.RS256, Key: attackerKey}
opts := (&gojose.SignerOptions{}).WithType("JWT").WithHeader("kid", "attacker-key")
attackerSigner, err := gojose.NewSigner(attackerSigningKey, opts)
if err != nil {
t.Fatalf("failed to create attacker signer: %v", err)
}
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
raw, err := jwt.Signed(attackerSigner).Claims(claims).Serialize()
if err != nil {
t.Fatalf("failed to sign with attacker key: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+raw)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("SECURITY: JWT signed with wrong key should get 401, got %d", w.Code)
}
if backendReached {
t.Error("SECURITY: JWT signed with wrong key reached the backend handler")
}
}
// --- Test 12: stripIdentityHeaders is case-insensitive ---
func TestStripIdentityHeaders_CaseInsensitive(t *testing.T) {
// HTTP headers are case-insensitive per RFC 7230. An attacker might try
// alternate casings like "x-org-id" or "X-Org-Id" to bypass
// stripping logic.
casings := []string{
"X-Org-Id",
"x-org-id",
"X-Org-Id",
"x-ORG-ID",
"x-user-id",
"X-USER-EMAIL",
"x-hanzo-custom-header",
}
for _, header := range casings {
t.Run(header, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(header, "forged-value")
stripIdentityHeaders(req)
if v := req.Header.Get(header); v != "" {
t.Errorf("SECURITY: header %q was NOT stripped (got %q)", header, v)
}
})
}
}
// --- Test 13: Non-X-Identity headers are preserved ---
func TestStripIdentityHeaders_PreservesOtherHeaders(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer some-token")
req.Header.Set("X-Request-Id", "req-123")
req.Header.Set("X-Forwarded-For", "1.2.3.4")
// This one should be stripped
req.Header.Set("X-Org-Id", "forged")
stripIdentityHeaders(req)
preserved := map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer some-token",
"X-Request-Id": "req-123",
"X-Forwarded-For": "1.2.3.4",
}
for k, want := range preserved {
if got := req.Header.Get(k); got != want {
t.Errorf("header %s was modified: got %q, want %q", k, got, want)
}
}
if v := req.Header.Get("X-Org-Id"); v != "" {
t.Errorf("X-Org-Id should have been stripped, got %q", v)
}
}
// --- Test 14: Comprehensive forged header name variants ---
func TestStripIdentityHeaders_AllVariants(t *testing.T) {
// The canonical 3 identity headers are X-User-Id, X-Org-Id, X-Roles.
// Gateway-emitted auxiliaries: X-User-Email, X-Phone-Number, X-User-IsAdmin.
// Every other identity-like header (legacy + vendor-prefixed) MUST be stripped.
attackerHeaders := []string{
// Canonical identity headers — attacker may not forge these
"X-User-Id",
"X-Org-Id",
"X-Roles",
"X-User-Permissions",
// Gateway-emitted auxiliaries
"X-User-Email",
"X-Phone-Number",
"X-User-IsAdmin",
// Legacy non-canonical identity headers
"X-User-Role", // singular
"X-User-Roles", // plural (renamed to X-Roles)
"X-User-Name",
"X-Tenant-Id",
"X-Tenant-ID",
"X-Org",
// Vendor-prefixed legacy headers
"X-Hanzo-Role",
"X-Hanzo-Scope",
"X-Hanzo-Admin",
"X-Hanzo-Whatever-New-Header",
"X-IAM-User-Id",
"X-IAM-Org-Id",
"X-IAM-Roles",
"X-IAM-User-Email",
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
for _, h := range attackerHeaders {
req.Header.Set(h, "forged")
}
stripIdentityHeaders(req)
for _, h := range attackerHeaders {
if v := req.Header.Get(h); v != "" {
t.Errorf("SECURITY: header %q was NOT stripped", h)
}
}
}
// --- Test 14b: Canonical 3 header emission from valid JWT ---
func TestCanonicalHeaders_EmittedFromJWT(t *testing.T) {
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
now := time.Now()
// Legacy shape: roles as []{"name": "..."}
claims := map[string]interface{}{
"iss": "https://hanzo.id",
"sub": "alice",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"owner": "hanzo",
"email": "[email protected]",
"isAdmin": true,
"roles": []map[string]string{
{"name": "admin"},
{"name": "operator"},
},
}
token := tj.signToken(t, claims)
gin.SetMode(gin.TestMode)
var got struct {
userID string
orgID string
roles string
email string
isAdmin string
}
r := gin.New()
r.Use(NewAuthMiddleware(AuthConfig{
Enabled: true,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
RequireAuth: true,
}))
r.GET("/x", func(c *gin.Context) {
got.userID = c.Request.Header.Get("X-User-Id")
got.orgID = c.Request.Header.Get("X-Org-Id")
got.roles = c.Request.Header.Get("X-Roles")
got.email = c.Request.Header.Get("X-User-Email")
got.isAdmin = c.Request.Header.Get("X-User-IsAdmin")
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
if got.userID != "alice" {
t.Errorf("X-User-Id = %q, want %q", got.userID, "alice")
}
if got.orgID != "hanzo" {
t.Errorf("X-Org-Id = %q, want %q", got.orgID, "hanzo")
}
if got.roles != "admin,operator" {
t.Errorf("X-Roles = %q, want %q", got.roles, "admin,operator")
}
if got.email != "[email protected]" {
t.Errorf("X-User-Email = %q, want %q", got.email, "[email protected]")
}
if got.isAdmin != "true" {
t.Errorf("X-User-IsAdmin = %q, want %q", got.isAdmin, "true")
}
}
// --- Test 14c: Roles claim as plain []string also parses ---
func TestCanonicalHeaders_RolesAsStringArray(t *testing.T) {
raw := []byte(`["admin","viewer"]`)
got := extractRoleNames(raw)
if got != "admin,viewer" {
t.Errorf("extractRoleNames = %q, want %q", got, "admin,viewer")
}
}
// --- Test 14d: Empty/absent roles -> no X-Roles header ---
func TestCanonicalHeaders_NoRolesNoHeader(t *testing.T) {
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
now := time.Now()
claims := map[string]interface{}{
"iss": "https://hanzo.id",
"sub": "bob",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"owner": "acme",
// no roles claim
}
token := tj.signToken(t, claims)
gin.SetMode(gin.TestMode)
var gotRoles string
r := gin.New()
r.Use(NewAuthMiddleware(AuthConfig{
Enabled: true,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
RequireAuth: true,
}))
r.GET("/x", func(c *gin.Context) {
gotRoles = c.Request.Header.Get("X-Roles")
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if gotRoles != "" {
t.Errorf("X-Roles = %q, want empty (no roles in JWT)", gotRoles)
}
}
// --- Test 15: validateToken rejects multiple issuer attack vectors ---
func TestValidateToken_IssuerAttackVectors(t *testing.T) {
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
cache := newJWKSCache(jwksServer.URL, 5*time.Minute)
tests := []struct {
name string
issuer string
}{
{"empty issuer", ""},
{"whitespace issuer", " "},
{"wrong issuer", "https://evil.com"},
{"partial match issuer", "https://hanzo.id.evil.com"},
{"issuer with trailing slash", "https://hanzo.id/"},
{"issuer subdomain", "https://sub.hanzo.id"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
now := time.Now()
claims := map[string]interface{}{
"sub": "alice",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"owner": "hanzo",
"email": "[email protected]",
}
if tt.issuer != "" {
claims["iss"] = tt.issuer
}
// No "iss" at all for empty issuer test
token := tj.signToken(t, claims)
_, err := validateToken(token, cache, "https://hanzo.id", "https://api.hanzo.ai")
if err == nil {
t.Errorf("SECURITY: validateToken accepted issuer %q -- should have been rejected", tt.issuer)
}
})
}
}
// --- Test 16: Billing returns 402 when balance is zero ---
func TestBillingCheck_Returns402WhenNoBalance(t *testing.T) {
gin.SetMode(gin.TestMode)
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
// Mock billing server that returns zero balance
billingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(billingResponse{
User: "hanzo/alice",
Currency: "usd",
Balance: 0,
Holds: 0,
Available: 0,
})
}))
defer billingServer.Close()
cfg := AuthConfig{
Enabled: true,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
BillingURL: billingServer.URL,
BillingToken: "test-token",
BillingEnabled: true,
PublicPaths: []string{},
PublicHosts: []string{},
RequireAuth: true,
}
r := gin.New()
r.Use(NewAuthMiddleware(cfg))
backendReached := false
r.GET("/api/test", func(c *gin.Context) {
backendReached = true
c.Status(http.StatusOK)
})
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
token := tj.signToken(t, claims)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusPaymentRequired {
t.Errorf("zero balance should get 402, got %d", w.Code)
}
if backendReached {
t.Error("request with zero balance should not reach backend")
}
}
// --- Test 16b: gateway calls the CORRECT commerce path (/v1/billing/balance) ---
// Pins the path. If anyone reverts to /api/v1/... the mock 404s, checkBalance
// fails-closed, and this test goes 503 instead of 200 — i.e. it would have
// caught the -X theirs merge-train regression of PR #24.
func TestBillingCheck_HitsV1PathAndAllows(t *testing.T) {
gin.SetMode(gin.TestMode)
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
var gotPath string
billingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if r.URL.Path != "/v1/billing/balance" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(billingResponse{
User: "hanzo/alice", Currency: "usd",
Balance: 5000, Holds: 0, Available: 5000,
})
}))
defer billingServer.Close()
cfg := AuthConfig{
Enabled: true, JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id", Audience: "https://api.hanzo.ai",
BillingURL: billingServer.URL, BillingToken: "test-token", BillingEnabled: true,
PublicPaths: []string{}, PublicHosts: []string{}, RequireAuth: true,
}
r := gin.New()
r.Use(NewAuthMiddleware(cfg))
backendReached := false
r.GET("/api/test", func(c *gin.Context) { backendReached = true; c.Status(http.StatusOK) })
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
token := tj.signToken(t, claims)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if gotPath != "/v1/billing/balance" {
t.Errorf("gateway must call /v1/billing/balance, called %q", gotPath)
}
if w.Code != http.StatusOK || !backendReached {
t.Errorf("positive balance should reach backend with 200, got %d (reached=%v)", w.Code, backendReached)
}
}
// --- Test 16c: gateway fails CLOSED when commerce errors (no free) ---
func TestBillingCheck_FailsClosedOnCommerceError(t *testing.T) {
gin.SetMode(gin.TestMode)
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
billingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer billingServer.Close()
cfg := AuthConfig{
Enabled: true, JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id", Audience: "https://api.hanzo.ai",
BillingURL: billingServer.URL, BillingToken: "test-token", BillingEnabled: true,
PublicPaths: []string{}, PublicHosts: []string{}, RequireAuth: true,
}
r := gin.New()
r.Use(NewAuthMiddleware(cfg))
backendReached := false
r.GET("/api/test", func(c *gin.Context) { backendReached = true; c.Status(http.StatusOK) })
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
token := tj.signToken(t, claims)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if backendReached {
t.Error("commerce error must NOT reach backend (fail-closed, no free)")
}
if w.Code != http.StatusServiceUnavailable {
t.Errorf("commerce error should yield 503 billing_unavailable, got %d", w.Code)
}
}
// --- Test 17: Multiple X-Identity header values (multi-value attack) ---
func TestStripIdentityHeaders_MultiValueAttack(t *testing.T) {
// HTTP allows multiple values for the same header. An attacker might
// add multiple X-Org-Id values hoping one survives stripping.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add("X-Org-Id", "forged-1")
req.Header.Add("X-Org-Id", "forged-2")
req.Header.Add("X-User-Id", "admin")
req.Header.Add("X-User-Id", "root")
stripIdentityHeaders(req)
if vals := req.Header.Values("X-Org-Id"); len(vals) != 0 {
t.Errorf("SECURITY: X-Org-Id had %d values after stripping: %v", len(vals), vals)
}
if vals := req.Header.Values("X-User-Id"); len(vals) != 0 {
t.Errorf("SECURITY: X-User-Id had %d values after stripping: %v", len(vals), vals)
}
}
// --- Test 18: Cookie-based token with forged headers ---
func TestCookieAuth_StripsForgedXIdentityHeaders(t *testing.T) {
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
var gotOrgID, gotUserID string
r.GET("/api/test", func(c *gin.Context) {
gotOrgID = c.Request.Header.Get("X-Org-Id")
gotUserID = c.Request.Header.Get("X-User-Id")
c.Status(http.StatusOK)
})
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
claims.Owner = "legit-org"
claims.Claims.Subject = "legit-user"
token := tj.signToken(t, claims)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
// Token via cookie instead of Authorization header
req.AddCookie(&http.Cookie{Name: "iam_access_token", Value: token})
// Attacker injects forged headers
req.Header.Set("X-Org-Id", "forged-org")
req.Header.Set("X-User-Id", "forged-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("cookie auth should get 200, got %d", w.Code)
}
// Must reflect JWT claims, not forged headers
if gotOrgID != "legit-org" {
t.Errorf("SECURITY: X-Org-Id = %q, want %q", gotOrgID, "legit-org")
}
if gotUserID != "legit-user" {
t.Errorf("SECURITY: X-User-Id = %q, want %q", gotUserID, "legit-user")
}
}
// --- Test 19: Publishable key (pk-*) with forged headers ---
func TestPublishableKeyAuth_StripsForgedXIdentityHeaders(t *testing.T) {
r, _, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
var gotOrgID string
r.GET("/api/test", func(c *gin.Context) {
gotOrgID = c.Request.Header.Get("X-Org-Id")
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer pk-publishable-key-xyz")
req.Header.Set("X-Org-Id", "forged-org")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("pk- key should pass through, got %d", w.Code)
}
if gotOrgID != "" {
t.Errorf("SECURITY: X-Org-Id was NOT stripped on pk- key path, got %q", gotOrgID)
}
}
// --- Test 20: Concurrent header injection attempts ---
func TestConcurrentHeaderInjection(t *testing.T) {
r, _, jwksServer := setupMiddlewareWithJWKS(t, func(cfg *AuthConfig) {
cfg.RequireAuth = false
})
defer jwksServer.Close()
var mu sync.Mutex
type result struct {
orgID string
userID string
}
results := make([]result, 100)
r.GET("/api/test", func(c *gin.Context) {
// Capture what the backend sees
res := result{
orgID: c.Request.Header.Get("X-Org-Id"),
userID: c.Request.Header.Get("X-User-Id"),
}
mu.Lock()
results = append(results, res)
mu.Unlock()
c.Status(http.StatusOK)
})
// Fire 100 concurrent requests with forged headers
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("X-Org-Id", fmt.Sprintf("forged-org-%d", n))
req.Header.Set("X-User-Id", fmt.Sprintf("forged-user-%d", n))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("request %d: expected 200, got %d", n, w.Code)
}
}(i)
}
wg.Wait()
// Verify no forged headers made it through
mu.Lock()
defer mu.Unlock()
for i, res := range results {
if res.orgID != "" {
t.Errorf("SECURITY: request %d: forged X-Org-Id leaked: %q", i, res.orgID)
}
if res.userID != "" {
t.Errorf("SECURITY: request %d: forged X-User-Id leaked: %q", i, res.userID)
}
}
}
// --- Test 21: X-User-Permissions forged value is stripped (Red P0-1) ---
//
// Regression for Red P0-1 (2026-04-27): commerce trusts X-User-Permissions
// as a base-10 bit.Field. Before this fix, gateway neither stripped a
// client-supplied X-User-Permissions nor minted one from JWT. An attacker
// could send `X-User-Permissions: 16` (Admin bit) with any valid token
// and gain admin in commerce.
func TestPermissions_ForgedHeaderStripped(t *testing.T) {
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
var gotPerms string
r.GET("/api/test", func(c *gin.Context) {
gotPerms = c.Request.Header.Get("X-User-Permissions")
c.Status(http.StatusOK)
})
// JWT has no permissions claim and isAdmin=false → no permissions
// should be minted. The forged header MUST NOT survive.
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
token := tj.signToken(t, claims)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
// Attacker forges Admin|Live (16|4 = 20)
req.Header.Set("X-User-Permissions", "20")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
if gotPerms != "" {
t.Errorf("SECURITY: X-User-Permissions = %q, want empty (forged value must be stripped, JWT had no perms)", gotPerms)
}
}
// --- Test 22: X-User-Permissions minted from permissions claim ---
func TestPermissions_MintedFromClaim(t *testing.T) {
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
now := time.Now()
// Legacy shape: []*Permission with name fields
claims := map[string]interface{}{
"iss": "https://hanzo.id",
"sub": "alice",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"owner": "hanzo",
"permissions": []map[string]string{
{"name": "admin"},
{"name": "live"},
},
}
token := tj.signToken(t, claims)
gin.SetMode(gin.TestMode)
var gotPerms string
r := gin.New()
r.Use(NewAuthMiddleware(AuthConfig{
Enabled: true,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
RequireAuth: true,
}))
r.GET("/x", func(c *gin.Context) {
gotPerms = c.Request.Header.Get("X-User-Permissions")
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", "Bearer "+token)
// Attacker overlays a forged value too — JWT must win.
req.Header.Set("X-User-Permissions", "999999")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
// Admin (1<<4 = 16) | Live (1<<2 = 4) = 20
if gotPerms != "20" {
t.Errorf("X-User-Permissions = %q, want %q (Admin|Live)", gotPerms, "20")
}
}
// --- Test 23: X-User-Permissions minted from string array ---
func TestPermissions_MintedFromStringArray(t *testing.T) {
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
now := time.Now()
claims := map[string]interface{}{
"iss": "https://hanzo.id",
"sub": "bob",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"owner": "acme",
"permissions": []string{"live", "test"}, // 4 | 8 = 12
}
token := tj.signToken(t, claims)
gin.SetMode(gin.TestMode)
var gotPerms string
r := gin.New()
r.Use(NewAuthMiddleware(AuthConfig{
Enabled: true,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
RequireAuth: true,
}))
r.GET("/x", func(c *gin.Context) {
gotPerms = c.Request.Header.Get("X-User-Permissions")
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
// Live (4) | Test (8) = 12
if gotPerms != "12" {
t.Errorf("X-User-Permissions = %q, want %q (Live|Test)", gotPerms, "12")
}
}
// --- Test 24: X-User-Permissions minted from numeric bit-field claim ---
func TestPermissions_MintedFromNumericClaim(t *testing.T) {
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
now := time.Now()
claims := map[string]interface{}{
"iss": "https://hanzo.id",
"sub": "charlie",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"owner": "acme",
"permissions": 64, // permission.Secret
}
token := tj.signToken(t, claims)
gin.SetMode(gin.TestMode)
var gotPerms string
r := gin.New()
r.Use(NewAuthMiddleware(AuthConfig{
Enabled: true,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
RequireAuth: true,
}))
r.GET("/x", func(c *gin.Context) {
gotPerms = c.Request.Header.Get("X-User-Permissions")
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if gotPerms != "64" {
t.Errorf("X-User-Permissions = %q, want %q", gotPerms, "64")
}
}
// --- Test 25: isAdmin claim auto-mints Admin|Live ---
func TestPermissions_IsAdminMintsAdminLive(t *testing.T) {
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
now := time.Now()
// No "permissions" claim, but isAdmin=true → gateway mints Admin|Live.
claims := map[string]interface{}{
"iss": "https://hanzo.id",
"sub": "z",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"owner": "hanzo",
"isAdmin": true,
}
token := tj.signToken(t, claims)
gin.SetMode(gin.TestMode)
var gotIsAdmin, gotPerms string
r := gin.New()
r.Use(NewAuthMiddleware(AuthConfig{
Enabled: true,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
RequireAuth: true,
}))
r.GET("/x", func(c *gin.Context) {
gotIsAdmin = c.Request.Header.Get("X-User-IsAdmin")
gotPerms = c.Request.Header.Get("X-User-Permissions")
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if gotIsAdmin != "true" {
t.Errorf("X-User-IsAdmin = %q, want %q", gotIsAdmin, "true")
}
// Admin (16) | Live (4) = 20
if gotPerms != "20" {
t.Errorf("X-User-Permissions = %q, want %q (Admin|Live)", gotPerms, "20")
}
}
// --- Test 26: No permissions claim + non-admin → no header ---
func TestPermissions_AbsentWhenNoneGranted(t *testing.T) {
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
now := time.Now()
claims := map[string]interface{}{
"iss": "https://hanzo.id",
"sub": "viewer",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"owner": "acme",
// no permissions, no isAdmin
}
token := tj.signToken(t, claims)
gin.SetMode(gin.TestMode)
var sawHeader bool
var gotPerms string
r := gin.New()
r.Use(NewAuthMiddleware(AuthConfig{
Enabled: true,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
RequireAuth: true,
}))
r.GET("/x", func(c *gin.Context) {
_, sawHeader = c.Request.Header[http.CanonicalHeaderKey("X-User-Permissions")]
gotPerms = c.Request.Header.Get("X-User-Permissions")
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
// Documented contract: header is absent (not "0") so commerce's
// parsePermissionsHeader returns bit.Field(0) — fail-closed.
if sawHeader {
t.Errorf("X-User-Permissions should be ABSENT for no-perms JWT, got %q", gotPerms)
}
}
// --- Test 27: Unknown permission name doesn't grant any bits ---
func TestPermissions_UnknownNameIgnored(t *testing.T) {
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
now := time.Now()
claims := map[string]interface{}{
"iss": "https://hanzo.id",
"sub": "viewer",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"owner": "acme",
"permissions": []string{"made-up-policy", "another-fake-one"},
}
token := tj.signToken(t, claims)
gin.SetMode(gin.TestMode)
var gotPerms string
var sawHeader bool
r := gin.New()
r.Use(NewAuthMiddleware(AuthConfig{
Enabled: true,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
RequireAuth: true,
}))
r.GET("/x", func(c *gin.Context) {
_, sawHeader = c.Request.Header[http.CanonicalHeaderKey("X-User-Permissions")]
gotPerms = c.Request.Header.Get("X-User-Permissions")
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
// All names unknown -> zero bits -> header omitted.
if sawHeader {
t.Errorf("X-User-Permissions = %q, want ABSENT (all names unknown)", gotPerms)
}
}
// --- Test 28: stripIdentityHeaders covers X-User-Permissions ---
//
// Direct unit test on stripIdentityHeaders so a regression in the strip
// list shows up before any middleware-level test gets a chance to mask
// it via mint-over.
func TestStripIdentityHeaders_StripsPermissions(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-User-Permissions", "20")
stripIdentityHeaders(req)
if v := req.Header.Get("X-User-Permissions"); v != "" {
t.Errorf("SECURITY: X-User-Permissions was NOT stripped (got %q)", v)
}
}
// --- Test 29: strip-list ⊇ mint-list contract test ---
//
// Red called this out as a gap (P0-1 audit): every header the gateway
// mints downstream MUST also appear in the strip list. Otherwise a new
// mint target added without a strip pair is forgeable. This test is the
// canonical link between the two lists.
func TestStripList_CoversAllMintedHeaders(t *testing.T) {
for _, h := range gatewayMintedIdentityHeaders {
t.Run(h, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(h, "forged-"+h)
stripIdentityHeaders(req)
if v := req.Header.Get(h); v != "" {
t.Errorf("SECURITY: gateway mints %q but strip list does NOT cover it (got %q)", h, v)
}
})
}
}
// --- Test 30: Unparseable permissions claim fails closed ---
func TestPermissions_UnparseableClaimFailsClosed(t *testing.T) {
tj := newTestJWKS(t)
jwksServer := tj.serveJWKS(t)
defer jwksServer.Close()
now := time.Now()
claims := map[string]interface{}{
"iss": "https://hanzo.id",
"sub": "weird",
"aud": []string{"https://api.hanzo.ai"},
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"owner": "acme",
"permissions": "not-a-list", // wrong shape entirely
}
token := tj.signToken(t, claims)
gin.SetMode(gin.TestMode)
var sawHeader bool
r := gin.New()
r.Use(NewAuthMiddleware(AuthConfig{
Enabled: true,
JWKSURL: jwksServer.URL,
Issuer: "https://hanzo.id",
Audience: "https://api.hanzo.ai",
RequireAuth: true,
}))
r.GET("/x", func(c *gin.Context) {
_, sawHeader = c.Request.Header[http.CanonicalHeaderKey("X-User-Permissions")]
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", "Bearer "+token)
// Attacker also forges a value
req.Header.Set("X-User-Permissions", "20")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
// Unparseable claim → no bits granted → header omitted (forged value
// already stripped on ingress).
if sawHeader {
t.Error("SECURITY: unparseable permissions claim must NOT mint a header")
}
}