mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
Every hk- in the docs, the widget refusal messages and the fixture tokens named a shape that authenticates nothing. Cloud accepts pk- and sk- and nothing else, IAM mints sk-, and no generator in the fleet still produces hk- — so a reader who copied one of these examples got a 401 and no way to see why. The predicate keeps it. authz.IsAPIKey still matches hk-, and should: those keys are still in circulation and have to reach the backend that refuses them rather than be parsed here as a JWT. TestIsAPIKey pins that row, and the two comments annotating the predicate now mark the shape retired rather than list it alongside the current ones. Where the hk- row sat in the forged-header and widget-key tables, pk- takes its place. sk- was already covered in both, and pk- — the shape that ships in a browser bundle — is the one neither table had ever exercised. Verified: make test, 489 pass / 0 fail, including all four param_forwarding fixtures whose bearer changed. Co-authored-by: Hanzo Dev <[email protected]>
1816 lines
58 KiB
Go
1816 lines
58 KiB
Go
// Copyright © 2026 Hanzo AI. MIT License.
|
|
|
|
//go:build legacy
|
|
// +build legacy
|
|
|
|
// gin-driven: this suite drives the LEGACY Lura edge's transports
|
|
// (legacy_transports.go), which exist only under this tag. The policies it
|
|
// exercises are framework-free values shared with the zip edge, and
|
|
// transport_parity_test.go asserts the two edges answer alike.
|
|
|
|
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"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/hanzoai/authz"
|
|
"github.com/hanzoai/gateway/v2/token"
|
|
)
|
|
|
|
// 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",
|
|
Audiences: []string{"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.RegisteredClaims.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")
|
|
// The MEMBERSHIP SET is the subject's org. Setting only `owner` would set the org
|
|
// of the APP the token was minted through, which is not who bob is.
|
|
claims.Orgs = []authz.Membership{{Org: "acme-corp", Role: authz.Member}}
|
|
claims.RegisteredClaims.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 the signed membership set)", 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]")
|
|
}
|
|
}
|
|
|
|
// TestValidAuth_WritesProjectFromClaim proves the org SUB-SCOPE X-Project-Id is
|
|
// written from the validated JWT `project` claim (exactly like X-Org-Id from
|
|
// `owner`) and a forged client X-Project-Id can never survive. With no project
|
|
// claim the header is omitted (default project), preserving single-project behavior.
|
|
func TestValidAuth_WritesProjectFromClaim(t *testing.T) {
|
|
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
|
|
defer jwksServer.Close()
|
|
|
|
var gotProject string
|
|
r.GET("/api/test", func(c *gin.Context) {
|
|
gotProject = c.Request.Header.Get("X-Project-Id")
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
// (a) JWT carries a non-default project → written; forged header dropped.
|
|
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
|
|
claims.Owner = "acme-corp"
|
|
claims.Project = "research"
|
|
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
req.Header.Set("Authorization", "Bearer "+tj.signToken(t, claims))
|
|
req.Header.Set("X-Project-Id", "victim-project") // forgery attempt
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("valid JWT should get 200, got %d", w.Code)
|
|
}
|
|
if gotProject != "research" {
|
|
t.Errorf("SECURITY: X-Project-Id = %q, want %q (from JWT project claim, not the forged header)", gotProject, "research")
|
|
}
|
|
|
|
// (b) No project claim → default project → header omitted.
|
|
gotProject = "sentinel"
|
|
claims2 := validClaims("https://hanzo.id", "https://api.hanzo.ai")
|
|
claims2.Owner = "acme-corp"
|
|
req2 := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
|
req2.Host = "api.hanzo.ai"
|
|
req2.Header.Set("Authorization", "Bearer "+tj.signToken(t, claims2))
|
|
req2.Header.Set("X-Project-Id", "victim-project") // forgery attempt
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
if gotProject != "" {
|
|
t.Errorf("SECURITY: X-Project-Id = %q, want empty (no project claim ⟹ default, forged header dropped)", gotProject)
|
|
}
|
|
}
|
|
|
|
// --- 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 (pk-*) with forged X-Identity headers",
|
|
authHeader: "Bearer pk-publishable-test-key",
|
|
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",
|
|
Audiences: []string{"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.RegisteredClaims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))
|
|
claims.RegisteredClaims.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)
|
|
}
|
|
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
|
|
raw := signAs(t, attackerKey, "attacker-key", claims)
|
|
|
|
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")
|
|
|
|
stripClaimed(httpHeaders{req.Header})
|
|
|
|
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")
|
|
|
|
stripClaimed(httpHeaders{req.Header})
|
|
|
|
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",
|
|
"X-User-IsGlobalAdmin", // PLATFORM superadmin (money authority) — MUST be stripped
|
|
// 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",
|
|
// Org sub-scope selector: a raw client copy is forgeable and MUST be
|
|
// stripped on ingress; the trusted value is rewritten from the validated
|
|
// JWT `project` claim (InjectIdentity / auth_middleware), never trusted raw.
|
|
"X-Project-Id",
|
|
// 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")
|
|
}
|
|
|
|
stripClaimed(httpHeaders{req.Header})
|
|
|
|
for _, h := range attackerHeaders {
|
|
if v := req.Header.Get(h); v != "" {
|
|
t.Errorf("SECURITY: header %q was NOT stripped", h)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Test 14: the identity headers a validated token earns, and the two it does not ---
|
|
|
|
// headersFor runs one request through the live middleware and reports the identity
|
|
// the backend would see.
|
|
func headersFor(t *testing.T, tj *testJWKS, jwksURL string, claims map[string]any) map[string]string {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
seen := map[string]string{}
|
|
r := gin.New()
|
|
r.Use(NewAuthMiddleware(AuthConfig{
|
|
Enabled: true, JWKSURL: jwksURL, Issuer: "https://hanzo.id",
|
|
Audiences: []string{"https://api.hanzo.ai"}, RequireAuth: true,
|
|
}))
|
|
r.GET("/x", func(c *gin.Context) {
|
|
for _, h := range append(append([]string{}, authz.Headers...), authz.Retired...) {
|
|
seen[h] = c.Request.Header.Get(h)
|
|
}
|
|
c.Status(http.StatusOK)
|
|
})
|
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
req.Header.Set("Authorization", "Bearer "+tj.signToken(t, claims))
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("request was refused with %d: %s", w.Code, w.Body.String())
|
|
}
|
|
return seen
|
|
}
|
|
|
|
// An ORG ADMIN gets the org-admin header and NOT the platform one. isAdmin is IAM's
|
|
// org-role bit; writing platform authority from it made every org owner a platform
|
|
// admin, which is the escalation this whole path was rebuilt to make unrepeatable.
|
|
func TestOrgAdminGetsNoPlatformAuthority(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
jwksServer := tj.serveJWKS(t)
|
|
defer jwksServer.Close()
|
|
|
|
got := headersFor(t, tj, jwksServer.URL, iamToken("hanzo", map[string]any{
|
|
"isAdmin": true,
|
|
"orgs": []map[string]any{{"org": "hanzo", "role": "admin"}},
|
|
}))
|
|
|
|
if got[authz.HeaderUserAdmin] != "" {
|
|
t.Errorf("%s = %q — an org admin was given PLATFORM authority",
|
|
authz.HeaderUserAdmin, got[authz.HeaderUserAdmin])
|
|
}
|
|
if got[authz.HeaderUserOrgAdmin] != "true" {
|
|
t.Errorf("%s = %q, want true", authz.HeaderUserOrgAdmin, got[authz.HeaderUserOrgAdmin])
|
|
}
|
|
if got[authz.HeaderOrg] != "hanzo" {
|
|
t.Errorf("%s = %q, want hanzo", authz.HeaderOrg, got[authz.HeaderOrg])
|
|
}
|
|
if got[authz.HeaderUser] != "uuid-alice" {
|
|
t.Errorf("%s = %q, want uuid-alice", authz.HeaderUser, got[authz.HeaderUser])
|
|
}
|
|
if got[authz.HeaderUserEmail] != "[email protected]" {
|
|
t.Errorf("%s = %q, want [email protected]", authz.HeaderUserEmail, got[authz.HeaderUserEmail])
|
|
}
|
|
|
|
// The money bit-field carries `live` (a real top-up, on the org role) and NOT
|
|
// `admin` (platform money authority). Commerce reads this with intersection
|
|
// semantics, so an org owner holding admin was the free-money hole.
|
|
bits := got[authz.HeaderUserPermissions]
|
|
if bits == "" {
|
|
t.Fatalf("%s was not written for an org admin", authz.HeaderUserPermissions)
|
|
}
|
|
n, err := strconv.ParseInt(bits, 10, 64)
|
|
if err != nil {
|
|
t.Fatalf("%s = %q, not a bit-field", authz.HeaderUserPermissions, bits)
|
|
}
|
|
if n&permissionBits["admin"] != 0 {
|
|
t.Errorf("an org admin carries the PLATFORM money bit (%s=%q)", authz.HeaderUserPermissions, bits)
|
|
}
|
|
if n&permissionBits["live"] == 0 {
|
|
t.Errorf("an org admin lost the live bit (%s=%q) — their own real top-up would break", authz.HeaderUserPermissions, bits)
|
|
}
|
|
}
|
|
|
|
// A platform operator gets both the platform header and the money bit.
|
|
func TestPlatformOperatorGetsPlatformAuthority(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
jwksServer := tj.serveJWKS(t)
|
|
defer jwksServer.Close()
|
|
|
|
got := headersFor(t, tj, jwksServer.URL, iamToken(authz.AdminOrg, map[string]any{
|
|
"isAdmin": true,
|
|
"orgs": []map[string]any{{"org": authz.AdminOrg, "role": "admin"}},
|
|
}))
|
|
|
|
if got[authz.HeaderUserAdmin] != "true" {
|
|
t.Errorf("%s = %q, want true", authz.HeaderUserAdmin, got[authz.HeaderUserAdmin])
|
|
}
|
|
n, _ := strconv.ParseInt(got[authz.HeaderUserPermissions], 10, 64)
|
|
if n&permissionBits["admin"] == 0 {
|
|
t.Errorf("the platform operator lost the money bit (%s=%q)",
|
|
authz.HeaderUserPermissions, got[authz.HeaderUserPermissions])
|
|
}
|
|
}
|
|
|
|
// A MACHINE in the reserved admin org gets neither, and cannot name another tenant.
|
|
// IAM's client_credentials grant signs no membership set, which is what marks it a
|
|
// machine; the predicate that used to look for tokenType "application" never fired,
|
|
// because IAM assigns that value nowhere.
|
|
func TestAdminOrgMachineGetsNoAuthority(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
jwksServer := tj.serveJWKS(t)
|
|
defer jwksServer.Close()
|
|
|
|
claims := iamToken(authz.AdminOrg, map[string]any{"isAdmin": true, "tokenType": "access-token"})
|
|
claims["sub"] = authz.AdminOrg + "/kms-sync"
|
|
delete(claims, "orgs")
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
seen := map[string]string{}
|
|
r := gin.New()
|
|
r.Use(NewAuthMiddleware(AuthConfig{
|
|
Enabled: true, JWKSURL: jwksServer.URL, Issuer: "https://hanzo.id",
|
|
Audiences: []string{"https://api.hanzo.ai"}, RequireAuth: true,
|
|
}))
|
|
r.GET("/x", func(c *gin.Context) {
|
|
for _, h := range authz.Headers {
|
|
seen[h] = c.Request.Header.Get(h)
|
|
}
|
|
c.Status(http.StatusOK)
|
|
})
|
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
req.Header.Set("Authorization", "Bearer "+tj.signToken(t, claims))
|
|
req.Header.Set(authz.HeaderOrg, "victim") // the org it asks to act in
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if seen[authz.HeaderUserAdmin] != "" {
|
|
t.Errorf("an admin-org machine was given %s=%q", authz.HeaderUserAdmin, seen[authz.HeaderUserAdmin])
|
|
}
|
|
if seen[authz.HeaderUserOrgAdmin] != "" {
|
|
t.Errorf("an admin-org machine was given %s=%q", authz.HeaderUserOrgAdmin, seen[authz.HeaderUserOrgAdmin])
|
|
}
|
|
// NO org: a machine's org lives only in the `owner` claim, which cannot be told
|
|
// apart from the app a human signed in through.
|
|
if seen[authz.HeaderOrg] != "" {
|
|
t.Errorf("an admin-org machine was given org %q, want none", seen[authz.HeaderOrg])
|
|
}
|
|
if n, _ := strconv.ParseInt(seen[authz.HeaderUserPermissions], 10, 64); n&permissionBits["admin"] != 0 {
|
|
t.Errorf("an admin-org machine carries the money bit (%q)", seen[authz.HeaderUserPermissions])
|
|
}
|
|
}
|
|
|
|
// X-Roles and X-Phone-Number are RETIRED: no edge writes them, and a token carrying
|
|
// the claims the old parsers read changes nothing. IAM emits neither `roles` nor
|
|
// `phone` — they are absent from the signed claim set and from claims_supported —
|
|
// so the three-shape parsers were reading a claim that never arrived.
|
|
func TestRetiredHeadersAreNeverWritten(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
jwksServer := tj.serveJWKS(t)
|
|
defer jwksServer.Close()
|
|
|
|
got := headersFor(t, tj, jwksServer.URL, iamToken("hanzo", map[string]any{
|
|
"orgs": []map[string]any{{"org": "hanzo", "role": "member"}},
|
|
"roles": []map[string]string{{"name": "admin"}, {"name": "operator"}},
|
|
"phone": "+15555550100",
|
|
}))
|
|
|
|
for _, h := range authz.Retired {
|
|
if got[h] != "" {
|
|
t.Errorf("retired header %s was written as %q", h, got[h])
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Test 15: validateToken rejects multiple issuer attack vectors ---
|
|
|
|
func TestValidateToken_IssuerAttackVectors(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
jwksServer := tj.serveJWKS(t)
|
|
defer jwksServer.Close()
|
|
|
|
validator := token.NewValidator(token.Config{
|
|
JWKSURL: jwksServer.URL, Issuers: []string{"https://hanzo.id"},
|
|
Audiences: []string{"https://api.hanzo.ai"},
|
|
})
|
|
|
|
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
|
|
|
|
raw := tj.signToken(t, claims)
|
|
_, err := validator.VerifyRaw(raw)
|
|
if err == nil {
|
|
t.Errorf("SECURITY: the edge 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",
|
|
Audiences: []string{"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 16a: billing is PATH-SCOPED to the must-gate surface ---
|
|
// With BillingPaths set, a zero-balance org is 402'd on a must-gate route
|
|
// but NOT balance-gated on an off-scope route (the AI/validated surface,
|
|
// which bills per-token downstream). This is the guarantee that turning
|
|
// edge billing ON for the must-gate routes never breaks the 171.
|
|
func TestBillingCheck_PathScoped(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tj := newTestJWKS(t)
|
|
jwksServer := tj.serveJWKS(t)
|
|
defer jwksServer.Close()
|
|
|
|
// Commerce mock: every org has zero available 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{Currency: "usd", Available: 0})
|
|
}))
|
|
defer billingServer.Close()
|
|
|
|
cfg := AuthConfig{
|
|
Enabled: true,
|
|
JWKSURL: jwksServer.URL,
|
|
Issuer: "https://hanzo.id",
|
|
Audiences: []string{"https://api.hanzo.ai"},
|
|
BillingURL: billingServer.URL,
|
|
BillingToken: "test-token",
|
|
BillingEnabled: true,
|
|
BillingPaths: []string{"/v1/mpc/", "/v1/cloud/"}, // must-gate scope
|
|
PublicPaths: []string{},
|
|
PublicHosts: []string{},
|
|
RequireAuth: true,
|
|
}
|
|
|
|
r := gin.New()
|
|
r.Use(NewAuthMiddleware(cfg))
|
|
embeddingsReached := false
|
|
mpcReached := false
|
|
r.POST("/v1/embeddings", func(c *gin.Context) { embeddingsReached = true; c.Status(http.StatusOK) })
|
|
r.POST("/v1/mpc/sign", func(c *gin.Context) { mpcReached = true; c.Status(http.StatusOK) })
|
|
|
|
token := tj.signToken(t, validClaims("https://hanzo.id", "https://api.hanzo.ai"))
|
|
do := func(path string) int {
|
|
req := httptest.NewRequest(http.MethodPost, path, nil)
|
|
req.Host = "api.hanzo.ai"
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
return w.Code
|
|
}
|
|
|
|
// Off-scope (AI/validated) route: zero balance MUST NOT gate it.
|
|
if code := do("/v1/embeddings"); code != http.StatusOK {
|
|
t.Errorf("off-scope route must not be balance-gated; got %d, want 200", code)
|
|
}
|
|
if !embeddingsReached {
|
|
t.Error("off-scope route should reach backend despite zero balance")
|
|
}
|
|
|
|
// In-scope (must-gate) route: zero balance MUST 402.
|
|
if code := do("/v1/mpc/sign"); code != http.StatusPaymentRequired {
|
|
t.Errorf("must-gate route with zero balance must be 402; got %d", code)
|
|
}
|
|
if mpcReached {
|
|
t.Error("must-gate route with zero balance must 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", Audiences: []string{"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", Audiences: []string{"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")
|
|
|
|
stripClaimed(httpHeaders{req.Header})
|
|
|
|
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.Orgs = []authz.Membership{{Org: "legit-org", Role: authz.Member}}
|
|
claims.RegisteredClaims.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 wrote 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 written. 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 21b: org-level admin is NOT granted the Admin money bit ---
|
|
//
|
|
// The free-money regression. An org OWNER (owner="hanzo", isAdmin=true) is an
|
|
// ORG-level admin, not a platform admin. Commerce gates every credit-creating
|
|
// and card-charging billing endpoint on TokenRequired(permission.Admin); before
|
|
// the fix the gateway wrote Admin|Live (20) for any isAdmin JWT, so an org owner
|
|
// satisfied those gates and minted unlimited free balance (live-proven as
|
|
// Dave/maxpower). The gateway must now write ONLY Live (4) for an org-level admin.
|
|
func TestPermissions_OrgAdminGetsNoAdminBit(t *testing.T) {
|
|
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
|
|
defer jwksServer.Close()
|
|
|
|
var gotPerms, gotGlobalAdmin string
|
|
r.GET("/api/test", func(c *gin.Context) {
|
|
gotPerms = c.Request.Header.Get("X-User-Permissions")
|
|
gotGlobalAdmin = c.Request.Header.Get("X-User-IsGlobalAdmin")
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
// Org owner: isAdmin=true, owner="hanzo" (NOT the admin org), no explicit
|
|
// permissions claim → the ONLY bits are the isAdmin-derived ones.
|
|
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
|
|
claims.IsAdmin = true
|
|
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 also forges the platform-superadmin header — MUST be stripped.
|
|
req.Header.Set("X-User-IsGlobalAdmin", "true")
|
|
|
|
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())
|
|
}
|
|
// Live (4) only — NOT Admin|Live (20).
|
|
if gotPerms != "4" {
|
|
t.Errorf("SECURITY: org-admin X-User-Permissions = %q, want %q (Live only, no Admin money bit)", gotPerms, "4")
|
|
}
|
|
// The forged platform-superadmin header must not survive; an org admin is
|
|
// not a global admin, so the gateway must not write it either.
|
|
if gotGlobalAdmin != "" {
|
|
t.Errorf("SECURITY: X-User-IsGlobalAdmin = %q, want empty (org admin is not global; forged value must be stripped)", gotGlobalAdmin)
|
|
}
|
|
}
|
|
|
|
// --- Test 21c: a real global admin DOES get Admin|Live + the superadmin header ---
|
|
func TestPermissions_PlatformSudoGetsAdminBitNoHeader(t *testing.T) {
|
|
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
|
|
defer jwksServer.Close()
|
|
|
|
var gotPerms, gotGlobalAdmin string
|
|
r.GET("/api/test", func(c *gin.Context) {
|
|
gotPerms = c.Request.Header.Get("X-User-Permissions")
|
|
gotGlobalAdmin = c.Request.Header.Get("X-User-IsGlobalAdmin")
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
// Platform sudo: owner=="admin" → full Admin|Live bits, but NO boolean header.
|
|
claims := validClaims("https://hanzo.id", "https://api.hanzo.ai")
|
|
claims.Owner = "admin"
|
|
claims.IsAdmin = true
|
|
// A user token always carries its home org first (store.MemberOrgRefs). Without
|
|
// it this reads as a MACHINE, which holds no platform authority — so the fixture
|
|
// has to be the shape IAM actually signs.
|
|
claims.Orgs = []authz.Membership{{Org: "admin", Role: "admin"}}
|
|
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("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
|
}
|
|
if gotPerms != "20" {
|
|
t.Errorf("platform-sudo X-User-Permissions = %q, want %q (Admin|Live)", gotPerms, "20")
|
|
}
|
|
// Platform sudo grants the Admin bit (owner=="admin") but writes NO boolean header.
|
|
if gotGlobalAdmin != "" {
|
|
t.Errorf("SECURITY: X-User-IsGlobalAdmin = %q, want empty — the platform-admin boolean is never written (sudo = org==admin)", gotGlobalAdmin)
|
|
}
|
|
}
|
|
|
|
// --- Test 22: the money bit-field comes from the PREDICATES, never from a claim ---
|
|
|
|
// Three tests used to live here, one per shape of a `permissions` claim: a numeric
|
|
// bit-field, a []string of names, and IAM's []*Permission objects. IAM emits NO such
|
|
// claim — it is absent from the signed claim set (internal/oidc/jwt.go) and from
|
|
// claims_supported — so all three exercised a parser nothing ever fed, and a token
|
|
// that DID carry the claim was granted whatever bits it named.
|
|
//
|
|
// This is the invariant that replaces them, and it is stronger: the money authority
|
|
// is a function of the two predicates, and a token asserting its own permissions
|
|
// gains nothing from doing so.
|
|
func TestPermissions_ClaimCannotGrantBits(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
jwksServer := tj.serveJWKS(t)
|
|
defer jwksServer.Close()
|
|
|
|
now := time.Now()
|
|
claims := map[string]any{
|
|
"iss": "https://hanzo.id", "sub": "uuid-mallory",
|
|
"aud": []string{"https://api.hanzo.ai"},
|
|
"iat": now.Add(-time.Minute).Unix(), "exp": now.Add(10 * time.Minute).Unix(),
|
|
"owner": "acme", "preferred_username": "mallory",
|
|
"orgs": []map[string]any{{"org": "acme", "role": "member"}},
|
|
// Every shape the old parsers accepted, all naming the money bit.
|
|
"permissions": []map[string]string{{"name": "admin"}},
|
|
}
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
var gotPerms, gotAdmin string
|
|
r := gin.New()
|
|
r.Use(NewAuthMiddleware(AuthConfig{
|
|
Enabled: true, JWKSURL: jwksServer.URL, Issuer: "https://hanzo.id",
|
|
Audiences: []string{"https://api.hanzo.ai"}, RequireAuth: true,
|
|
}))
|
|
r.GET("/x", func(c *gin.Context) {
|
|
gotPerms = c.Request.Header.Get(authz.HeaderUserPermissions)
|
|
gotAdmin = c.Request.Header.Get(authz.HeaderUserAdmin)
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
for _, shape := range []any{
|
|
[]map[string]string{{"name": "admin"}},
|
|
[]string{"admin", "live"},
|
|
permissionBits["admin"],
|
|
} {
|
|
claims["permissions"] = shape
|
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
req.Header.Set("Authorization", "Bearer "+tj.signToken(t, claims))
|
|
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: a `permissions` claim of %T wrote %s=%q — a plain member named its own money authority",
|
|
shape, authz.HeaderUserPermissions, gotPerms)
|
|
}
|
|
if gotAdmin != "" {
|
|
t.Errorf("SECURITY: a plain member was given %s=%q", authz.HeaderUserAdmin, gotAdmin)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Test 25: a GLOBAL admin's isAdmin auto-writes Admin|Live ---
|
|
//
|
|
// The Admin (money) bit is written from isAdmin ONLY for a platform (global)
|
|
// admin — here owner=="admin". An org-level admin gets Live only; that half of
|
|
// the contract is TestPermissions_OrgAdminGetsNoAdminBit (the free-money
|
|
// regression). Global-admin tooling keeps its Admin|Live so it still works.
|
|
func TestPermissions_IsAdminWritesAdminLive(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
jwksServer := tj.serveJWKS(t)
|
|
defer jwksServer.Close()
|
|
|
|
now := time.Now()
|
|
// No "permissions" claim, isAdmin=true, owner=="admin" (global admin org)
|
|
// → gateway writes 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": "admin",
|
|
"isAdmin": true,
|
|
"orgs": []map[string]any{{"org": "admin", "role": "admin"}},
|
|
}
|
|
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",
|
|
Audiences: []string{"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",
|
|
Audiences: []string{"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",
|
|
Audiences: []string{"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 by overwriting.
|
|
func TestStripIdentityHeaders_StripsPermissions(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("X-User-Permissions", "20")
|
|
|
|
stripClaimed(httpHeaders{req.Header})
|
|
|
|
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 ⊇ write-list contract test ---
|
|
//
|
|
// Red called this out as a gap (P0-1 audit): every header the gateway
|
|
// writes downstream MUST also appear in the strip list. Otherwise a new
|
|
// write target added without a strip pair is forgeable. This test is the
|
|
// canonical link between the two lists.
|
|
func TestStripList_CoversAllWrittenHeaders(t *testing.T) {
|
|
for _, h := range authz.Headers {
|
|
t.Run(h, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set(h, "forged-"+h)
|
|
|
|
stripClaimed(httpHeaders{req.Header})
|
|
|
|
if v := req.Header.Get(h); v != "" {
|
|
t.Errorf("SECURITY: gateway writes %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",
|
|
Audiences: []string{"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 write a header")
|
|
}
|
|
}
|