Files
commerce/middleware/edgeauth_test.go
zeekay 8cab81b83d A standalone edge acts in the subject's org, never the application's
EdgeAuth set X-Org-Id, and pinned the billing subject, from the `owner`
claim: the org of the application a person signed in through. Every
self-serve user of the brand app therefore acted in and billed to the
brand's org at a standalone commerce (COMMERCE_EDGE_AUTH=true).

tenancy now resolves a verified token as cloud's SanitizeIdentity does:
the home org is orgs[0], the acting org is the client's selection
(X-Org-Id, or ?org on a billing path) when the signed set admits it,
else the default (authz.Claims.EffectiveOrg). A program is its
application's org and selects nothing; a person's token without a
membership set acts in no org. The billing subject follows the acting
org, and X-User-IsOrgAdmin is asked of it.

orgAdminHomeMatches compared home and acting org with EqualFold; it
compares them verbatim.
2026-10-20 07:49:25 -07:00

396 lines
16 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package middleware
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/valyala/fasthttp"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/auth"
)
// runEdgeAuth mounts EdgeAuth ahead of a probe and drives req through the real
// zip chain. EdgeAuth mutates the underlying fasthttp request in place (strips /
// mints identity headers); the probe — running AFTER EdgeAuth via c.Next() —
// observes exactly what the downstream chain sees: the captured request headers,
// and whether it was reached at all (EdgeAuth
// never aborts a non-JWT bearer). The *Ctx is pooled and reset once the request
// returns, so every observation is taken inside the probe, never after.
func runEdgeAuth(t *testing.T, req *http.Request, captureHeaders ...string) (reached bool, headers map[string]string) {
t.Helper()
headers = map[string]string{}
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Use(EdgeAuth())
app.Raw(zip.MethodAll, "/*", func(c *zip.Ctx) error {
reached = true
for _, k := range captureHeaders {
headers[k] = c.Header(k)
}
return c.NoContent(http.StatusOK)
})
if _, err := app.Test(req); err != nil {
t.Fatalf("edgeauth request: %v", err)
}
return
}
// TestEdgeAuth_StripsSpoofedIdentity proves the core security fix: at a
// directly-exposed edge a client-supplied X-Org-Id (and friends) must be
// removed so it can't impersonate an org via the header-trust path.
func TestEdgeAuth_StripsSpoofedIdentity(t *testing.T) {
t.Setenv("COMMERCE_EDGE_AUTH", "true")
req := httptest.NewRequest("GET", "/v1/billing/balance?user=hanzo&currency=usd", nil)
req.Header.Set("X-Org-Id", "hanzo")
req.Header.Set("X-User-Permissions", "16") // spoofed admin bit
req.Header.Set("X-User-IsAdmin", "true")
keys := []string{"X-Org-Id", "X-User-Permissions", "X-User-IsAdmin"}
_, headers := runEdgeAuth(t, req, keys...)
for _, k := range keys {
if got := headers[k]; got != "" {
t.Fatalf("EdgeAuth(enabled) must strip spoofed %s, got %q", k, got)
}
}
}
// TestEdgeAuth_StripsSuperAdminSpoof proves the platform-sudo headers are
// stripped at the edge. GetIAMClaims derives SuperAdmin from the HOME org
// (X-User-Owner, owner=="admin") — so if EdgeAuth did NOT strip X-User-Owner, an
// in-cluster caller could forge `X-User-Owner: admin` and pass every cross-org
// gate (the /_/commerce/tenants escalation). The retired X-User-IsGlobalAdmin
// boolean is also still stripped so a stale client copy never survives. Both — plus
// X-Org-Id and X-User-IsAdmin — MUST be in the strip set (there is no JWT here, so
// nothing is re-minted; every identity header must come out empty).
func TestEdgeAuth_StripsSuperAdminSpoof(t *testing.T) {
t.Setenv("COMMERCE_EDGE_AUTH", "true")
req := httptest.NewRequest("POST", "/_/commerce/tenants", nil)
req.Header.Set("X-Org-Id", "admin")
req.Header.Set("X-User-Owner", "admin") // forged HOME org → sudo (the new escalation)
req.Header.Set("X-User-IsGlobalAdmin", "true") // forged retired boolean
req.Header.Set("X-User-IsAdmin", "true")
keys := []string{"X-Org-Id", "X-User-Owner", "X-User-IsGlobalAdmin", "X-User-IsAdmin"}
_, headers := runEdgeAuth(t, req, keys...)
for _, k := range keys {
if got := headers[k]; got != "" {
t.Fatalf("EdgeAuth must strip spoofed %s, got %q", k, got)
}
}
}
// TestEdgeAuth_OpaqueBearerStripsOrg: an opaque (non-JWT) bearer never gets its
// client-supplied X-Org-Id back. Restoring it let IAMTokenRequired treat an
// unvalidated token as a verified identity (opaque bearer + X-Org-Id ⇒ forged
// principal for any org). There used to be a private stash of that selector for
// a shared service token to read once verified; the token is gone, so the
// selector is simply dropped and an opaque bearer resolves no org.
//
// EdgeAuth never aborts an opaque bearer (not a JWT ⇒ minting skipped).
func TestEdgeAuth_OpaqueBearerStripsOrg(t *testing.T) {
t.Setenv("COMMERCE_EDGE_AUTH", "true")
req := httptest.NewRequest("POST", "/v1/billing/usage", nil)
req.Header.Set("Authorization", "Bearer st_opaque_service_token_not_a_jwt")
req.Header.Set("X-Org-Id", "maxpower")
reached, headers := runEdgeAuth(t, req, "X-Org-Id")
if !reached {
t.Fatal("EdgeAuth must NOT abort an opaque bearer (money path)")
}
// X-Org-Id MUST be stripped — never re-exposed to the trust boundary.
if got := headers["X-Org-Id"]; got != "" {
t.Fatalf("opaque bearer: X-Org-Id must stay stripped (bypass fix), got %q", got)
}
}
// TestEdgeAuth_ClosesOpaqueBearerBypass is the explicit RED regression: the
// live-proved bypass was `Authorization: Bearer <opaque>` + `X-Org-Id: <target>`
// → EdgeAuth restored the client X-Org-Id → IAMTokenRequired trusted it (no
// token check) → the checkout money surface minted for the attacker's chosen
// org. After the fix, X-Org-Id is stripped and NEVER put back on the header for
// a non-service opaque bearer, so IAMTokenRequired resolves no org and the
// request cannot forge an IAM principal.
func TestEdgeAuth_ClosesOpaqueBearerBypass(t *testing.T) {
t.Setenv("COMMERCE_EDGE_AUTH", "true")
for _, path := range []string{
"/v1/checkout/sessions", "/v1/checkout/charge",
"/v1/checkout/authorize", "/v1/checkout/capture",
} {
req := httptest.NewRequest("POST", path, nil)
req.Header.Set("Authorization", "Bearer redteamprobe")
req.Header.Set("X-Org-Id", "hanzo")
_, headers := runEdgeAuth(t, req, "X-Org-Id")
if got := headers["X-Org-Id"]; got != "" {
t.Fatalf("%s: bypass vector X-Org-Id must be stripped, got %q", path, got)
}
}
}
// TestEdgeAuth_DisabledIsNoOp proves gateway-fronted deployments are
// unaffected: with the flag off, identity headers pass through untouched.
func TestEdgeAuth_DisabledIsNoOp(t *testing.T) {
t.Setenv("COMMERCE_EDGE_AUTH", "")
req := httptest.NewRequest("GET", "/v1/billing/balance", nil)
req.Header.Set("X-Org-Id", "hanzo")
_, headers := runEdgeAuth(t, req, "X-Org-Id")
if got := headers["X-Org-Id"]; got != "hanzo" {
t.Fatalf("EdgeAuth(disabled) must be a no-op, X-Org-Id=%q", got)
}
}
// TestEdgeAuth_NilClientNeverMints proves fail-closed: a JWT-shaped token
// with no verifier (nil client) yields NO minted identity — the spoofed
// header is stripped and nothing is trusted in its place.
func TestEdgeAuth_NilClientNeverMints(t *testing.T) {
t.Setenv("COMMERCE_EDGE_AUTH", "true")
req := httptest.NewRequest("GET", "/v1/billing/balance", nil)
req.Header.Set("Authorization", "Bearer a.b.c")
req.Header.Set("X-Org-Id", "evil")
_, headers := runEdgeAuth(t, req, "X-Org-Id")
if got := headers["X-Org-Id"]; got != "" {
t.Fatalf("nil verifier must not mint identity, X-Org-Id=%q", got)
}
}
// billingReq builds a fasthttp request (method + URI + optional JSON body) for
// the header/body-locking unit tests — the direct callees now take the
// fasthttp request EdgeAuth mutates, not a net/http one.
func billingReq(method, uri, body string) *fasthttp.Request {
req := &fasthttp.Request{}
req.Header.SetMethod(method)
req.SetRequestURI(uri)
if body != "" {
req.Header.SetContentType("application/json")
req.SetBody([]byte(body))
}
return req
}
// TestLockBillingSubject proves per-org isolation: the billing subject is
// pinned to the caller's own org slug, other query params are preserved.
func TestLockBillingSubject(t *testing.T) {
req := billingReq("GET",
"/v1/billing/transactions?user=victim/other&userId=x&customerId=y&limit=5", "")
lockBillingSubject(req, "hanzo")
q, _ := url.ParseQuery(string(req.URI().QueryString()))
for _, k := range []string{"user", "userId", "customerId"} {
if q.Get(k) != "hanzo" {
t.Fatalf("%s must be locked to org slug, got %q", k, q.Get(k))
}
}
if q.Get("limit") != "5" {
t.Fatalf("unrelated query param clobbered, limit=%q", q.Get("limit"))
}
}
// TestLockBillingSubjectBody is the write-side counterpart to
// TestLockBillingSubject: a /billing/ write whose JSON body names a FOREIGN
// billing subject must have it pinned to the caller's own slug before the
// handler binds it — the exact gap that let a body customerId of "hanzo/z"
// orphan a payment method that every (query-locked) read could never see.
func TestLockBillingSubjectBody(t *testing.T) {
// amount is 2^53+1: a value float64 cannot hold exactly, so a clean
// round-trip proves the rewrite preserves numeric precision (json.Number).
const body = `{"customerId":"victim/other","amount":9007199254740993,"currency":"usd","brand":"visa"}`
req := billingReq("POST", "/v1/billing/methods", body)
lockBillingSubjectBody(req, "hanzo")
raw := req.Body()
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var m map[string]any
if err := dec.Decode(&m); err != nil {
t.Fatalf("rewritten body must stay valid JSON: %v (%s)", err, raw)
}
if m["customerId"] != "hanzo" {
t.Fatalf("body customerId must be pinned to caller slug, got %v", m["customerId"])
}
if n, _ := m["amount"].(json.Number); n.String() != "9007199254740993" {
t.Fatalf("numeric precision lost in rewrite, amount=%v", m["amount"])
}
if m["currency"] != "usd" || m["brand"] != "visa" {
t.Fatalf("unrelated fields clobbered: %v", m)
}
if got := req.Header.ContentLength(); got != len(raw) {
t.Fatalf("Content-Length must track rewritten body (%d), got %d", len(raw), got)
}
}
// TestLockBillingSubjectBody_PinsAllSubjectKeys: every billing-subject field in
// the body (user/userId/customerId) is pinned, mirroring the query lock's set.
func TestLockBillingSubjectBody_PinsAllSubjectKeys(t *testing.T) {
const body = `{"user":"a/b","userId":"c/d","customerId":"e/f","note":"keep"}`
req := billingReq("PUT", "/v1/billing/methods/123", body)
lockBillingSubjectBody(req, "hanzo")
var m map[string]any
if err := json.NewDecoder(bytes.NewReader(req.Body())).Decode(&m); err != nil {
t.Fatalf("rewritten body must stay valid JSON: %v", err)
}
for _, k := range []string{"user", "userId", "customerId"} {
if m[k] != "hanzo" {
t.Fatalf("body %s must be pinned to caller slug, got %v", k, m[k])
}
}
if m["note"] != "keep" {
t.Fatalf("unrelated field clobbered, note=%v", m["note"])
}
}
// TestLockBillingSubjectBody_ShapeAgnostic proves the rewrite is surgical: a
// body that is not a JSON object, carries none of the subject keys, or is
// unparseable passes through byte-for-byte — EdgeAuth never rejects or mangles
// an unexpected shape, it only ever pins the three known keys when present.
func TestLockBillingSubjectBody_ShapeAgnostic(t *testing.T) {
for _, body := range []string{
`["a","b"]`, // JSON array, not an object
`{"amount":100,"currency":"usd"}`, // object without any subject key
`not json at all`, // unparseable
``, // empty
} {
req := billingReq("POST", "/v1/billing/topup", body)
// billingReq skips Content-Type for an empty body; set it so the
// content-type gate is exercised for every shape.
req.Header.SetContentType("application/json")
lockBillingSubjectBody(req, "hanzo")
if got := string(req.Body()); got != body {
t.Fatalf("body %q must pass through untouched, got %q", body, got)
}
}
}
// TestLockBillingSubjectBody_NonWriteMethodIgnored: reads (GET/DELETE) carry the
// subject in the query (locked by lockBillingSubject), so the body lock — which
// would consume the stream — must leave non-write methods untouched.
func TestLockBillingSubjectBody_NonWriteMethodIgnored(t *testing.T) {
const body = `{"customerId":"victim/other"}`
req := billingReq("GET", "/v1/billing/balance", body)
lockBillingSubjectBody(req, "hanzo")
if got := string(req.Body()); got != body {
t.Fatalf("GET body must be untouched, got %q", got)
}
}
// TestLockBillingSubjectBody_NonJSONIgnored: a non-JSON content type carries no
// billing-subject fields to pin, so the body is left untouched (and unread).
func TestLockBillingSubjectBody_NonJSONIgnored(t *testing.T) {
const body = `customerId=victim%2Fother&amount=100`
req := &fasthttp.Request{}
req.Header.SetMethod("POST")
req.SetRequestURI("/v1/billing/methods")
req.Header.SetContentType("application/x-www-form-urlencoded")
req.SetBody([]byte(body))
lockBillingSubjectBody(req, "hanzo")
if got := string(req.Body()); got != body {
t.Fatalf("non-JSON body must be untouched, got %q", got)
}
}
func TestLooksLikeJWTAndBearer(t *testing.T) {
cases := map[string]bool{"a.b.c": true, "sk-abc123": false, "": false, "a.b": false}
for tok, want := range cases {
if got := looksLikeJWT(tok); got != want {
t.Fatalf("looksLikeJWT(%q)=%v want %v", tok, got, want)
}
}
if got := bearerToken("Bearer xyz"); got != "xyz" {
t.Fatalf("bearerToken Bearer parse = %q", got)
}
if got := bearerToken("xyz"); got != "" {
t.Fatalf("bearerToken non-bearer must be empty, got %q", got)
}
}
// TestPermsHeader_AdminBitIsSuperAdminOnly is the money-hole regression. It pins the
// exact X-User-Permissions bit.Field permsHeader mints for the three principal
// classes, in the wire units commerce parses (permission.Live=4, Admin=16). The
// invariant: the Admin (money/admin) bit is minted ONLY for a SuperAdmin (home owner=="admin"); an
// org-level admin (an org owner) gets Live but NOT Admin, so
// TokenRequired(permission.Admin) on the credit-creating / card-charging billing
// endpoints denies them. Before the fix an org owner minted Admin|Live=20 and
// satisfied every admin money gate → unlimited free balance + platform-wide card
// charging (live-proven as Dave/maxpower).
func TestPermsHeader_AdminBitIsSuperAdminOnly(t *testing.T) {
const (
live = 4 // permission.Live (1 << 2)
admin = 16 // permission.Admin (1 << 4)
adminLive = admin | live
)
cases := []struct {
name string
claims *auth.IAMClaims
want int64
}{
// Plain authenticated user: no admin authority, no live mode. Zero bits.
{"plain-user", &auth.IAMClaims{Owner: "hanzo"}, 0},
// Org owner (maxpower): live mode YES (real Square top-up must work),
// Admin money bit NO. This is the exact principal that exploited the hole.
{"org-admin-live-not-admin", &auth.IAMClaims{Owner: "maxpower", IsAdmin: true}, live},
// SuperAdmin by the edge's verdict: full Admin+Live.
{"SuperAdmin", &auth.IAMClaims{Owner: "admin", IsAdmin: true, Sudo: true}, adminLive},
// The verdict holds in any org the SuperAdmin acts in.
{"SuperAdmin acting in hanzo", &auth.IAMClaims{Owner: "hanzo", IsAdmin: true, Sudo: true}, adminLive},
// SuperAdmin WITHOUT org-level IsAdmin still carries Admin (the money
// authority) and not Live (IsAdmin gates Live): authority and mode are
// independent signals.
{"SuperAdmin, no org admin", &auth.IAMClaims{Owner: "hanzo", Sudo: true}, admin},
// The `owner` claim "admin" without the verdict is the minting application's
// org, not the subject's, and carries no money authority.
{"owner claim admin, no verdict", &auth.IAMClaims{Owner: "admin", IsAdmin: true}, live},
{"owner claim Admin, no verdict", &auth.IAMClaims{Owner: "Admin"}, 0},
}
for _, tc := range cases {
got, err := strconv.ParseInt(permsHeader(tc.claims), 10, 64)
if err != nil {
t.Fatalf("%s: permsHeader returned non-integer: %v", tc.name, err)
}
if got != tc.want {
t.Fatalf("%s: permsHeader=%d want %d (admin bit set iff SuperAdmin)", tc.name, got, tc.want)
}
// Belt-and-suspenders: the Admin bit is present iff the caller is a SuperAdmin.
hasAdmin := got&admin != 0
if hasAdmin != tc.claims.IsSuperAdmin() {
t.Fatalf("%s: Admin bit=%v but IsSuperAdmin=%v — must match exactly", tc.name, hasAdmin, tc.claims.IsSuperAdmin())
}
}
}
func TestValidOrgSlug(t *testing.T) {
good := []string{"hanzo", "admin", "max-power", "a", "org123"}
bad := []string{"", "-lead", "Caps", "has space", "slash/x", "under_score", "dot.org"}
for _, s := range good {
if !validOrgSlug(s) {
t.Fatalf("validOrgSlug(%q)=false, want true", s)
}
}
for _, s := range bad {
if validOrgSlug(s) {
t.Fatalf("validOrgSlug(%q)=true, want false", s)
}
}
}