Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
becc8e7e39 |
@@ -314,6 +314,99 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
|
||||
commercebilling.DeleteSpendAlert,
|
||||
)
|
||||
|
||||
// POST /v1/billing/topup/token — the INLINE Square card top-up (the console's
|
||||
// "Billing → Credits → add credits": the Square Web Payments SDK tokenizes the card
|
||||
// IN THE BROWSER → a single-use nonce → this endpoint charges it and credits the
|
||||
// caller's balance). commerce's api.Route() billing bundle is NOT compiled into the
|
||||
// co-resident embed, so — exactly like plans/invoices/spend-alerts above — without
|
||||
// this registration the POST fell through to the account bridge's /v1/billing/*
|
||||
// wildcard (order 122). That wildcard is service-token-forwardable for topup/token
|
||||
// (billing.go billingForwardable), so billingData re-forwarded it to COMMERCE_URL
|
||||
// (default the public api.hanzo.ai edge = THIS binary) over commerceinproc's
|
||||
// self-routing transport, re-entering the SAME wildcard until the depth-8 guard
|
||||
// refused → the "commerceinproc: in-process dispatch depth 8 exceeded" 502 that broke
|
||||
// top-up outright. Registering commerce's real TopupWithToken co-resident here
|
||||
// (order 100 < 122) shadows the wildcard and serves the charge in-process at depth 1
|
||||
// — no HTTP hop, no self-dispatch. topup/token STAYS in billingForwardable as the
|
||||
// split-deploy fallback (a standalone commerce still serves it); co-residence just
|
||||
// wins first.
|
||||
//
|
||||
// Chain — the browser money-WRITE posture, byte-for-byte what the bridge applied:
|
||||
// RequireCSRF — the ambient-cookie anti-CSRF gate the bridge's requireCSRF
|
||||
// wrapped POST /v1/billing/* with (a Bearer/gateway caller is
|
||||
// not CSRF-able; an ambient-cookie write needs the token).
|
||||
// RequestContext — the gated request context commerce's handler + ledger read.
|
||||
// IAMTokenRequired — resolves the org from the gateway-validated X-Org-Id into
|
||||
// Locals("organization"), which TopupWithToken.GetOrganization
|
||||
// + topupDestination read as the org billing key.
|
||||
// PinBillingSubject — pins ?user= to the caller's OWN account.Payer subject (the
|
||||
// SAME rule the ai spend-gate debits and billingData pins), so
|
||||
// the credit lands on the caller's subject (person=org/name) and
|
||||
// can never be widened; fail-closed for an unvalidated caller —
|
||||
// the IDOR boundary stays exactly where billingData put it.
|
||||
// The card PAN never touches this binary: TopupWithToken charges the Square nonce only,
|
||||
// and the settled charge itself is the mint authority (mintauth.WithAuthorized).
|
||||
app.Post("/v1/billing/topup/token",
|
||||
accountclient.RequireCSRF(),
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercebilling.TopupWithToken,
|
||||
)
|
||||
|
||||
// The remaining console billing WRITES that share topup/token's self-dispatch loop
|
||||
// class — each is a POST the console makes (billingForwardable in billing.go), each had
|
||||
// NO co-resident handler, so each fell through to the account bridge's /v1/billing/*
|
||||
// wildcard (order 122) and re-entered it over commerceinproc until the depth-8 guard
|
||||
// refused (the same "in-process dispatch depth 8 exceeded" 502 that broke top-up). Each
|
||||
// commerce handler exists in the vendored module (v1.49.13); registering them co-resident
|
||||
// (order 100 < 122) shadows the wildcard and serves the write in-process at depth 1. They
|
||||
// STAY in billingForwardable as the split-deploy fallback (same precedent as topup/token
|
||||
// + spend-alerts). Chain matches the bridge's write posture byte-for-byte:
|
||||
//
|
||||
// - RequireCSRF — the ambient-cookie anti-CSRF gate the bridge wrapped POST
|
||||
// /v1/billing/* with (Bearer/gateway callers are not CSRF-able).
|
||||
// - RequestContext — the gated request context commerce's handlers read.
|
||||
// - IAMTokenRequired — resolves the org from the gateway-validated X-Org-Id into
|
||||
// Locals("organization") — the namespace GetOrganization reads.
|
||||
// - PinBillingSubject — pins the caller's OWN account.Payer subject into BOTH query and
|
||||
// body AND fail-closes an unvalidated caller. It is the auth gate
|
||||
// on every one, and the IDOR control on the subject-scoped one.
|
||||
//
|
||||
// payment-methods (save a card-on-file / vault a Square nonce) is SUBJECT-scoped: commerce's
|
||||
// CreatePaymentMethod reads `customerId` from the BODY, so PinBillingSubject's body-pin is
|
||||
// load-bearing here — a member can only vault a card for their OWN subject, exactly the
|
||||
// boundary billingData's scopedBillingBody enforced. The Square nonce goes to Square; the
|
||||
// PAN never touches this binary.
|
||||
app.Post("/v1/billing/payment-methods",
|
||||
accountclient.RequireCSRF(),
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercebilling.CreatePaymentMethod,
|
||||
)
|
||||
|
||||
// subscriptions/:id/{cancel,reactivate} are org-NAMESPACE-scoped: commerce's handlers
|
||||
// resolve the subscription by `:id` WITHIN the caller's org namespace (a foreign org's id
|
||||
// is a 404 miss), so tenancy is the namespace IAMTokenRequired resolves and PinBillingSubject
|
||||
// is the fail-closed-anon auth gate — its pinned subject params are ignored by these
|
||||
// handlers (the SAME role it plays for the org-scoped payment-config read). The bridge's
|
||||
// subject-pin was likewise a no-op for these, so nothing is dropped.
|
||||
app.Post("/v1/billing/subscriptions/:id/cancel",
|
||||
accountclient.RequireCSRF(),
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercebilling.CancelBillingSubscription,
|
||||
)
|
||||
app.Post("/v1/billing/subscriptions/:id/reactivate",
|
||||
accountclient.RequireCSRF(),
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercebilling.ReactivateBillingSubscription,
|
||||
)
|
||||
|
||||
// In-process seams:
|
||||
// - commerceinproc routes the S2S billing byte-stream into the co-resident
|
||||
// app (the metering debit path) instead of a socket to a standalone pod.
|
||||
|
||||
+72
-6
@@ -312,6 +312,11 @@ func detectGPUs() []gpuInfo {
|
||||
}
|
||||
|
||||
// detectNvidiaGPUs reports NVIDIA accelerators via nvidia-smi (name + total VRAM).
|
||||
// A discrete card returns a real "<N> MiB" for memory.total; a unified-memory board
|
||||
// (GB10 "spark" — Grace Blackwell, LPDDR5X) has no discrete VRAM BAR, so memory.total
|
||||
// reports "[N/A]". In that case fall back to the machine's nominal unified RAM —
|
||||
// exactly the treatment detectAppleGPU gives Apple Silicon — rounded to the installed
|
||||
// figure so spark shows a clean "128 GiB", not "[N/A]".
|
||||
func detectNvidiaGPUs() []gpuInfo {
|
||||
out, err := exec.Command("nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader").Output()
|
||||
if err != nil {
|
||||
@@ -325,24 +330,63 @@ func detectNvidiaGPUs() []gpuInfo {
|
||||
continue
|
||||
}
|
||||
name, mem, _ := strings.Cut(line, ",")
|
||||
gpus = append(gpus, gpuInfo{Name: strings.TrimSpace(name), MemoryTotal: strings.TrimSpace(mem)})
|
||||
info := gpuInfo{Name: strings.TrimSpace(name)}
|
||||
if mib := leadingMiB(mem); mib > 0 { // real discrete VRAM: keep the reported total
|
||||
info.MemoryTotal = fmt.Sprintf("%d MiB", mib)
|
||||
} else if ram := detectMemTotal(); ram > 0 { // "[N/A]" unified board → nominal system RAM
|
||||
info.MemoryTotal = fmt.Sprintf("%d MiB", nominalMemMiB(ram/(1024*1024)))
|
||||
}
|
||||
gpus = append(gpus, info)
|
||||
}
|
||||
return gpus
|
||||
}
|
||||
|
||||
// leadingMiB extracts the leading integer MiB from an nvidia-smi memory.total token
|
||||
// ("24576 MiB"), or 0 when it is non-numeric ("[N/A]", empty) — the unified-memory
|
||||
// signal that routes to the system-RAM fallback.
|
||||
func leadingMiB(token string) int64 {
|
||||
var mib int64
|
||||
if _, err := fmt.Sscan(strings.TrimSpace(token), &mib); err == nil && mib > 0 {
|
||||
return mib
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// nominalMemMiB rounds a unified-memory total (in MiB) UP to the nearest nominal
|
||||
// installed-DIMM capacity, so a kernel MemTotal that reads a few GiB under a round
|
||||
// figure (128 GiB installed reports ~125 GiB; an amdgpu carveout reads ~118 GiB) is
|
||||
// reported as the headline the operator expects ("128 GiB", not "125"/"118"). ONLY
|
||||
// unified-memory paths (Apple / GB10 / Strix Halo) call this — discrete VRAM carries
|
||||
// its exact reported total. Totals above the known DIMM steps round up to the next
|
||||
// multiple of 64 GiB.
|
||||
func nominalMemMiB(rawMiB int64) int64 {
|
||||
if rawMiB <= 0 {
|
||||
return 0
|
||||
}
|
||||
const gib = int64(1024) // 1 GiB expressed in MiB
|
||||
for _, n := range []int64{16, 24, 32, 48, 64, 96, 128, 192, 256} {
|
||||
if rawMiB <= n*gib {
|
||||
return n * gib
|
||||
}
|
||||
}
|
||||
steps := (rawMiB + 64*gib - 1) / (64 * gib)
|
||||
return steps * 64 * gib
|
||||
}
|
||||
|
||||
// detectAmdGPUs reports AMD accelerators — discrete Radeon cards and gfx APUs alike
|
||||
// (e.g. evo's gfx1151 Radeon 8060S on the RYZEN AI MAX+ 395). Resolution order:
|
||||
// rocm-smi (marketing name + gfx target), then the kfd topology under /sys (gfx
|
||||
// target from gfx_target_version, GPU nodes only), then a vulkaninfo summary. VRAM
|
||||
// is filled best-effort from the amdgpu sysfs mem_info_vram_total, positionally.
|
||||
func detectAmdGPUs() []gpuInfo {
|
||||
sysRAMMiB := detectMemTotal() / (1024 * 1024)
|
||||
if out, err := exec.Command("rocm-smi", "--showproductname", "--csv").Output(); err == nil {
|
||||
if gpus := parseRocmSmiCSV(out); len(gpus) > 0 {
|
||||
return fillAmdVRAM(gpus, amdVRAMTotals(sysfsDRM))
|
||||
return fillAmdVRAM(gpus, amdVRAMTotals(sysfsDRM), sysRAMMiB)
|
||||
}
|
||||
}
|
||||
if gpus := parseKfdTopology(sysfsKfdNodes); len(gpus) > 0 {
|
||||
return fillAmdVRAM(gpus, amdVRAMTotals(sysfsDRM))
|
||||
return fillAmdVRAM(gpus, amdVRAMTotals(sysfsDRM), sysRAMMiB)
|
||||
}
|
||||
if out, err := exec.Command("vulkaninfo", "--summary").Output(); err == nil {
|
||||
if gpus := parseVulkaninfoSummary(out); len(gpus) > 0 {
|
||||
@@ -487,18 +531,40 @@ func amdVRAMTotals(drmDir string) []int64 {
|
||||
return mems
|
||||
}
|
||||
|
||||
// fillAmdVRAM attaches VRAM totals to the GPU list positionally when the counts
|
||||
// match (the common single-GPU case always does); otherwise the names stand alone.
|
||||
func fillAmdVRAM(gpus []gpuInfo, memsMiB []int64) []gpuInfo {
|
||||
// fillAmdVRAM attaches each GPU's headline memory positionally when the counts match
|
||||
// (the common single-GPU case always does); otherwise the names stand alone. A
|
||||
// discrete Radeon carries its exact amdgpu VRAM carveout (mem_info_vram_total); a
|
||||
// unified-memory APU (Strix Halo / "Ryzen AI Max" / gfx1151, e.g. evo) instead reports
|
||||
// the machine's nominal installed RAM — the carveout undercounts the shared LPDDR5X
|
||||
// (evo reads ~118 GiB of a 128 GiB box), so the nominal system total is the right
|
||||
// headline, the same unified-memory treatment as Apple/GB10.
|
||||
func fillAmdVRAM(gpus []gpuInfo, memsMiB []int64, sysRAMMiB int64) []gpuInfo {
|
||||
if len(memsMiB) != len(gpus) {
|
||||
return gpus
|
||||
}
|
||||
for i := range gpus {
|
||||
if unifiedAPU(gpus[i].Name, memsMiB[i], sysRAMMiB) {
|
||||
gpus[i].MemoryTotal = fmt.Sprintf("%d MiB", nominalMemMiB(sysRAMMiB))
|
||||
continue
|
||||
}
|
||||
gpus[i].MemoryTotal = fmt.Sprintf("%d MiB", memsMiB[i])
|
||||
}
|
||||
return gpus
|
||||
}
|
||||
|
||||
// unifiedAPU reports whether an AMD GPU is a unified-memory APU — a Strix Halo /
|
||||
// "Ryzen AI Max" / gfx1151 part whose real memory is the machine's installed RAM, not
|
||||
// the amdgpu VRAM carveout. Detected by marketing/gfx name OR by the carveout sitting
|
||||
// close to (>=85% of) system RAM; a discrete card's dedicated VRAM never does either,
|
||||
// so it keeps its exact reported total.
|
||||
func unifiedAPU(name string, carveoutMiB, sysRAMMiB int64) bool {
|
||||
l := strings.ToLower(name)
|
||||
if strings.Contains(l, "gfx1151") || strings.Contains(l, "ryzen ai max") || strings.Contains(l, "strix halo") {
|
||||
return true
|
||||
}
|
||||
return sysRAMMiB > 0 && carveoutMiB > 0 && carveoutMiB*100 >= sysRAMMiB*85
|
||||
}
|
||||
|
||||
// parseVulkaninfoSummary is the last-resort AMD path: it scrapes GPU device names
|
||||
// from `vulkaninfo --summary` (the "deviceName = ..." lines), keeping AMD/Radeon
|
||||
// devices only so it never double-counts an NVIDIA card already handled upstream.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
const gib = int64(1024) // 1 GiB expressed in MiB, matching nominalMemMiB
|
||||
|
||||
// TestNominalMemMiB — the unified-memory headline rounds a kernel/carveout total UP
|
||||
// to the nominal installed-DIMM figure, so spark (GB10) and evo (Strix Halo) show a
|
||||
// clean 128 GiB instead of the ~125/~118 GiB the probes actually read. Rounding is
|
||||
// scoped to the unified path only; the discrete-VRAM cases here confirm nominal DIMM
|
||||
// steps are preserved, never inflated past the next step.
|
||||
func TestNominalMemMiB(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
rawMiB int64
|
||||
wantMiB int64
|
||||
}{
|
||||
{"amd carveout ~118GiB → 128", 118 * gib, 128 * gib}, // evo Strix Halo carveout
|
||||
{"kernel MemTotal ~125GiB → 128", 125 * gib, 128 * gib}, // GB10 / 128GiB box under-read
|
||||
{"fractional 15.5GiB → 16", 15872, 16 * gib}, // 15.5 GiB in MiB
|
||||
{"exact 128GiB stays 128", 128 * gib, 128 * gib},
|
||||
{"nominal 24GiB stays 24", 24 * gib, 24 * gib}, // a 24GB step is itself nominal
|
||||
{"64GiB stays 64", 64 * gib, 64 * gib},
|
||||
{"200GiB → 256 step", 200 * gib, 256 * gib},
|
||||
{"above steps rounds to 64 multiple", 300 * gib, 320 * gib},
|
||||
{"zero stays zero", 0, 0},
|
||||
} {
|
||||
if got := nominalMemMiB(c.rawMiB); got != c.wantMiB {
|
||||
t.Errorf("%s: nominalMemMiB(%d) = %d, want %d", c.name, c.rawMiB, got, c.wantMiB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeadingMiB — nvidia-smi's memory.total is numeric "<N> MiB" on a discrete card
|
||||
// and "[N/A]" on a unified GB10; leadingMiB is the guard that routes only the latter
|
||||
// to the system-RAM fallback.
|
||||
func TestLeadingMiB(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
token string
|
||||
want int64
|
||||
}{
|
||||
{"24576 MiB", 24576}, // discrete card, with units
|
||||
{"81920 MiB", 81920}, // H100-class
|
||||
{"[N/A]", 0}, // GB10 unified — no VRAM BAR
|
||||
{"N/A", 0},
|
||||
{"", 0},
|
||||
{" ", 0},
|
||||
} {
|
||||
if got := leadingMiB(c.token); got != c.want {
|
||||
t.Errorf("leadingMiB(%q) = %d, want %d", c.token, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnifiedAPU — a Strix Halo / gfx1151 APU (by name, or by its carveout hugging
|
||||
// system RAM) reports installed RAM; a discrete Radeon with dedicated VRAM does not.
|
||||
func TestUnifiedAPU(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
gpuName string
|
||||
carveoutMiB int64
|
||||
sysRAMMiB int64
|
||||
want bool
|
||||
}{
|
||||
{"evo by gfx name", "Radeon 8060S Graphics (gfx1151)", 118 * gib, 125 * gib, true},
|
||||
{"kfd gfx name", "AMD GPU (gfx1151)", 0, 125 * gib, true},
|
||||
{"ryzen ai max marketing", "AMD Ryzen AI Max+ 395", 118 * gib, 125 * gib, true},
|
||||
{"carveout hugs sysram", "Some APU", 118 * gib, 125 * gib, true},
|
||||
{"discrete 24GB in 128GB box", "Radeon RX 7900 XTX (gfx1100)", 24 * gib, 128 * gib, false},
|
||||
{"discrete no sysram", "Radeon RX 7900 XTX (gfx1100)", 24 * gib, 0, false},
|
||||
} {
|
||||
if got := unifiedAPU(c.gpuName, c.carveoutMiB, c.sysRAMMiB); got != c.want {
|
||||
t.Errorf("%s: unifiedAPU(%q, %d, %d) = %v, want %v", c.name, c.gpuName, c.carveoutMiB, c.sysRAMMiB, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillAmdVRAM — the discrete card keeps its exact carveout; the gfx1151 APU's
|
||||
// headline becomes the nominal installed RAM (118 GiB carveout → 128 GiB).
|
||||
func TestFillAmdVRAM(t *testing.T) {
|
||||
// Unified APU: carveout undercounts, headline is nominal system RAM.
|
||||
apu := fillAmdVRAM([]gpuInfo{{Name: "Radeon 8060S Graphics (gfx1151)"}}, []int64{118 * gib}, 125*gib)
|
||||
if got, want := apu[0].MemoryTotal, "131072 MiB"; got != want { // 128 GiB
|
||||
t.Errorf("APU headline = %q, want %q", got, want)
|
||||
}
|
||||
// Discrete card: exact carveout preserved, never rounded.
|
||||
dgpu := fillAmdVRAM([]gpuInfo{{Name: "Radeon RX 7900 XTX (gfx1100)"}}, []int64{24 * gib}, 128*gib)
|
||||
if got, want := dgpu[0].MemoryTotal, "24576 MiB"; got != want {
|
||||
t.Errorf("discrete headline = %q, want %q", got, want)
|
||||
}
|
||||
// Count mismatch: names stand alone (no memory attached).
|
||||
mismatch := fillAmdVRAM([]gpuInfo{{Name: "A"}, {Name: "B"}}, []int64{4096}, 128*gib)
|
||||
if mismatch[0].MemoryTotal != "" || mismatch[1].MemoryTotal != "" {
|
||||
t.Errorf("count mismatch should leave memory empty, got %+v", mismatch)
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,12 @@ import (
|
||||
// - Neither: refuse. A bearer-less request with a forged X-Org-Id has no validated
|
||||
// principal and is fail-closed here, before the read handler runs.
|
||||
//
|
||||
// The pin rewrites the request URI's query string in place; fasthttp's SetQueryString
|
||||
// resets the parsed-args cache, so the handler's later c.Query() reads the pinned values.
|
||||
// The pin rewrites the request URI's query string AND (on a write) the JSON body in place;
|
||||
// fasthttp's SetQueryString resets the parsed-args cache and SetBody replaces the body
|
||||
// bytes, so the handler's later c.Query() / c.Bind() read the pinned values. Pinning the
|
||||
// body is what keeps a co-resident WRITE handler that reads its subject from the body
|
||||
// (commerce's CreatePaymentMethod reads customerId from the JSON body) IDOR-safe — query-only
|
||||
// pinning would leave a client-named customerId/userId in the body untouched.
|
||||
func PinBillingSubject() zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
inQuery, _ := url.ParseQuery(string(c.Fiber().Request().URI().QueryString()))
|
||||
@@ -69,7 +73,17 @@ func PinBillingSubject() zip.Handler {
|
||||
Account: principal.BillingAccount(c),
|
||||
}).Subject()
|
||||
|
||||
// Pin the subject on BOTH the query AND the write body — the SAME two-helper
|
||||
// scoping billingData applies (scopedBillingSearch + scopedBillingBody). A
|
||||
// co-resident WRITE handler that reads its subject from the body (commerce's
|
||||
// CreatePaymentMethod → customerId) is only IDOR-safe if the body is pinned too.
|
||||
// scopedBillingBody overwrites the subject keys and preserves every other field
|
||||
// (card, type, sourceId, …); a non-JSON / empty body is returned unchanged, so a
|
||||
// GET read carries no body and is unaffected.
|
||||
c.Fiber().Request().URI().SetQueryString(scopedBillingSearch(inQuery, subject).Encode())
|
||||
if len(c.Body()) > 0 {
|
||||
c.Fiber().Request().SetBody(scopedBillingBody(c.Body(), subject))
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,17 @@ func echoQuery(c *zip.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// echoBody is the downstream stand-in for a commerce WRITE handler (e.g. CreatePaymentMethod)
|
||||
// that reads its subject from the JSON BODY: it reports the body it observes AFTER the pin, so
|
||||
// a test can assert the subject the handler would persist and that non-subject fields survive.
|
||||
func echoBody(c *zip.Ctx) error {
|
||||
var got map[string]any
|
||||
if len(c.Body()) > 0 {
|
||||
_ = json.Unmarshal(c.Body(), &got)
|
||||
}
|
||||
return c.JSON(200, got)
|
||||
}
|
||||
|
||||
func pinApp(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
@@ -38,6 +49,7 @@ func pinApp(t *testing.T) *zip.App {
|
||||
t.Fatalf("MountAccount: %v", err)
|
||||
}
|
||||
app.Get("/probe", PinBillingSubject(), echoQuery)
|
||||
app.Post("/probe", PinBillingSubject(), echoBody)
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -67,6 +79,33 @@ func TestPinBillingSubject_PinsCallerAndDropsOrg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinBillingSubject_PinsBodyForWrites — a POST whose JSON body names a FOREIGN subject
|
||||
// (customerId/userId/user) has every subject key overwritten with the caller's OWN subject
|
||||
// before the handler binds it, while non-subject fields survive. This is the boundary
|
||||
// commerce's CreatePaymentMethod (which reads customerId from the body) relies on to be
|
||||
// IDOR-safe co-resident — byte-identical to billingData's scopedBillingBody on the bridge.
|
||||
func TestPinBillingSubject_PinsBodyForWrites(t *testing.T) {
|
||||
app := pinApp(t)
|
||||
code, body := callH(t, app, http.MethodPost, "/probe", alice,
|
||||
`{"customerId":"victim","userId":"victim","user":"victim","card":{"last4":"4242"}}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("bad body: %s", body)
|
||||
}
|
||||
for _, k := range billingSubjectKeys {
|
||||
if got[k] != "acme" { // alice/acme resolves to the org subject "acme"
|
||||
t.Fatalf("handler must see body %s=acme (caller's own subject), got %v", k, got[k])
|
||||
}
|
||||
}
|
||||
card, ok := got["card"].(map[string]any)
|
||||
if !ok || card["last4"] != "4242" {
|
||||
t.Fatalf("non-subject body field must survive the pin, got card=%v", got["card"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinBillingSubject_RefusesUnvalidated — a forged X-Org-Id with NO validated
|
||||
// X-User-Id (and no service token) is refused before the read handler runs: no
|
||||
// cross-tenant billing read is possible.
|
||||
|
||||
@@ -173,6 +173,23 @@ func requireCSRF(s *cloud.Service[state], next zip.Handler) zip.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireCSRF exposes the ambient-cookie anti-CSRF gate as a STANDALONE middleware for a
|
||||
// co-resident money-WRITE route registered OUTSIDE this package — specifically
|
||||
// apps/commerce.go's POST /v1/billing/topup/token, which shadows the account-bridge's
|
||||
// POST /v1/billing/* wildcard (order 100 < 122) that would otherwise have wrapped the
|
||||
// write in requireCSRF. Moving the route co-resident to break the commerceinproc
|
||||
// self-dispatch loop must NOT silently drop that anti-CSRF gate, so the identical
|
||||
// enforcement rides along as its own handler. It binds to the SAME process-wide key
|
||||
// (sharedCSRFKey) the GET /v1/csrf issuer and the bridge verifier use, so a token minted
|
||||
// at /v1/csrf verifies here byte-identically. Enforces ONLY on the ambient-cookie path (a
|
||||
// Bearer/gateway/API caller is not CSRF-able); on success it c.Next()s into the rest of
|
||||
// the chain. The minimal Service carries only the shared key — requireCSRF/verifyCSRF
|
||||
// read nothing else off it.
|
||||
func RequireCSRF() zip.Handler {
|
||||
s := &cloud.Service[state]{State: state{csrfKey: sharedCSRFKey(nil)}}
|
||||
return requireCSRF(s, func(c *zip.Ctx) error { return c.Next() })
|
||||
}
|
||||
|
||||
// issueCSRFToken serves GET /v1/csrf: for a VALIDATED caller, a fresh token
|
||||
// bound to their identity. no-store so it is never cached by a shared proxy. This is
|
||||
// the same-origin endpoint the embedded SPA reads (its response body is unreadable to
|
||||
|
||||
@@ -186,3 +186,124 @@ func TestSpendAlertsAuthorizeShadowsBridge(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// assertPostShadowsBridge is the shared body of every "co-resident POST billing WRITE shadows
|
||||
// the self-proxying /v1/billing/* bridge wildcard" regression — the whole class the P0 that
|
||||
// broke console Billing → Credits belonged to. specificPattern is the route as registered
|
||||
// co-resident (may carry a :param); requestPath is the concrete path the browser POSTs.
|
||||
//
|
||||
// It models both arrangements on the POST transport this seam owns:
|
||||
// - build(false) registers ONLY the account bridge's self-proxying POST /v1/billing/*
|
||||
// wildcard (like MountBridge at order 122): billingData → commerceDo re-dials COMMERCE_URL
|
||||
// (the public edge = this binary) BY PATH through THIS transport, re-entering the wildcard
|
||||
// until the depth-8 guard refuses — the exact user-facing 502.
|
||||
// - build(true) ALSO registers the specific commerce route FIRST (like mountCommerce at
|
||||
// order 100), so it shadows the wildcard and the write runs once at depth 1 — no loop.
|
||||
func assertPostShadowsBridge(t *testing.T, specificPattern, requestPath string) {
|
||||
t.Helper()
|
||||
const okBody = `{"status":"ok"}`
|
||||
newReq := func() *http.Request {
|
||||
req, _ := http.NewRequest(http.MethodPost, BaseURL("")+requestPath, strings.NewReader(`{"sourceId":"cnon:card-nonce-ok"}`))
|
||||
req.Header.Set("Authorization", "Bearer service-token")
|
||||
req.Header.Set("X-Org-Id", "hanzo")
|
||||
return req
|
||||
}
|
||||
build := func(withSpecific bool) (app *zip.App, specificHits, wildcardHits *int32) {
|
||||
var sHits, wHits int32
|
||||
app = zip.New(zip.Config{})
|
||||
if withSpecific {
|
||||
app.Post(specificPattern, func(c *zip.Ctx) error {
|
||||
atomic.AddInt32(&sHits, 1)
|
||||
return c.Bytes(http.StatusOK, []byte(okBody))
|
||||
})
|
||||
}
|
||||
app.Post("/v1/billing/*", func(c *zip.Ctx) error {
|
||||
atomic.AddInt32(&wHits, 1)
|
||||
resp, err := Client(5 * time.Second).Do(newReq())
|
||||
if err != nil { // the depth guard's refusal surfaces here — the 502 the user saw.
|
||||
return c.Bytes(http.StatusBadGateway, []byte(err.Error()))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return c.Bytes(resp.StatusCode, body)
|
||||
})
|
||||
return app, &sHits, &wHits
|
||||
}
|
||||
do := func() (int, string) {
|
||||
resp, err := Client(5 * time.Second).Do(newReq())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(body)
|
||||
}
|
||||
|
||||
// BUG (no co-resident handler): the wildcard self-loops until the depth guard refuses, so
|
||||
// it is entered exactly maxDepth times and the outer request surfaces the depth-exceeded
|
||||
// 502 carrying the EXACT re-entrancy refusal for THIS route.
|
||||
t.Run("bug_wildcard_self_loops", func(t *testing.T) {
|
||||
t.Cleanup(func() { SetHandler(nil) })
|
||||
app, specificHits, wildcardHits := build(false)
|
||||
SetApp(app)
|
||||
status, body := do()
|
||||
t.Logf("no-shadow %s => status=%d wildcardHits=%d body=%s", requestPath, status, atomic.LoadInt32(wildcardHits), body)
|
||||
if got := atomic.LoadInt32(specificHits); got != 0 {
|
||||
t.Fatalf("specific hits = %d, want 0 (no specific route registered)", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(wildcardHits); got != maxDepth {
|
||||
t.Fatalf("wildcard entered %d times, want maxDepth=%d (the self-dispatch loop)", got, maxDepth)
|
||||
}
|
||||
if status != http.StatusBadGateway ||
|
||||
!strings.Contains(body, "dispatch depth") ||
|
||||
!strings.Contains(body, "POST "+requestPath) {
|
||||
t.Fatalf("outer status=%d body=%q, want 502 carrying the depth-exceeded refusal for POST %s", status, body, requestPath)
|
||||
}
|
||||
})
|
||||
|
||||
// FIX (co-resident specific route registered ahead of the wildcard): it shadows the
|
||||
// wildcard, so the handler runs once at depth 1, the wildcard's self-proxy NEVER fires,
|
||||
// and the write returns 200 — no loop, no depth-8 502.
|
||||
t.Run("fix_specific_route_shadows_wildcard", func(t *testing.T) {
|
||||
t.Cleanup(func() { SetHandler(nil) })
|
||||
app, specificHits, wildcardHits := build(true)
|
||||
SetApp(app)
|
||||
status, body := do()
|
||||
t.Logf("shadowed %s => status=%d specificHits=%d wildcardHits=%d body=%s",
|
||||
requestPath, status, atomic.LoadInt32(specificHits), atomic.LoadInt32(wildcardHits), body)
|
||||
if got := atomic.LoadInt32(specificHits); got != 1 {
|
||||
t.Fatalf("specific route entered %d times, want exactly 1 (served in-process at depth 1)", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(wildcardHits); got != 0 {
|
||||
t.Fatalf("wildcard self-dispatch fired %d times, want 0 — the specific route must shadow it", got)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("outer status = %d, want 200 (the co-resident write, no self-dispatch 502)", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTopupTokenShadowsBridge — the reported P0: POST /v1/billing/topup/token (inline Square
|
||||
// card top-up) self-dispatched to the depth-8 502 until commerce's TopupWithToken was
|
||||
// registered co-resident (apps/commerce.go mountCommerce).
|
||||
func TestTopupTokenShadowsBridge(t *testing.T) {
|
||||
assertPostShadowsBridge(t, "/v1/billing/topup/token", "/v1/billing/topup/token")
|
||||
}
|
||||
|
||||
// TestPaymentMethodsShadowsBridge — the save-card write (POST /v1/billing/payment-methods →
|
||||
// commerce CreatePaymentMethod), same self-dispatch class, now shadowed co-resident.
|
||||
func TestPaymentMethodsShadowsBridge(t *testing.T) {
|
||||
assertPostShadowsBridge(t, "/v1/billing/payment-methods", "/v1/billing/payment-methods")
|
||||
}
|
||||
|
||||
// TestSubscriptionCancelShadowsBridge — POST /v1/billing/subscriptions/:id/cancel →
|
||||
// commerce CancelBillingSubscription, same class, now shadowed co-resident.
|
||||
func TestSubscriptionCancelShadowsBridge(t *testing.T) {
|
||||
assertPostShadowsBridge(t, "/v1/billing/subscriptions/:id/cancel", "/v1/billing/subscriptions/sub_1/cancel")
|
||||
}
|
||||
|
||||
// TestSubscriptionReactivateShadowsBridge — POST /v1/billing/subscriptions/:id/reactivate →
|
||||
// commerce ReactivateBillingSubscription, same class, now shadowed co-resident.
|
||||
func TestSubscriptionReactivateShadowsBridge(t *testing.T) {
|
||||
assertPostShadowsBridge(t, "/v1/billing/subscriptions/:id/reactivate", "/v1/billing/subscriptions/sub_1/reactivate")
|
||||
}
|
||||
|
||||
@@ -98,7 +98,13 @@ func installTraceProvider(ctx context.Context, serviceName string) func(context.
|
||||
}
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exp),
|
||||
sdktrace.WithResource(resource.NewSchemaless(attribute.String("service.name", serviceName))),
|
||||
sdktrace.WithResource(resource.NewSchemaless(
|
||||
attribute.String("service.name", serviceName),
|
||||
// deployment.environment on the resource so o11y's Environment column
|
||||
// resolves instead of defaulting to "default". Every span this provider
|
||||
// exports (cloud's own + the adopted ai gen_ai spans) inherits it.
|
||||
attribute.String("deployment.environment", deploymentEnvironment()),
|
||||
)),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
|
||||
@@ -141,3 +147,14 @@ func firstNonEmptyEnv(keys ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// deploymentEnvironment resolves the process deployment environment for the OTel
|
||||
// resource. Env-overridable (DEPLOYMENT_ENVIRONMENT / OTEL_DEPLOYMENT_ENVIRONMENT /
|
||||
// ENVIRONMENT); defaults to "production" because this provider installs only when a
|
||||
// sink/wire is configured — i.e. a real deployment.
|
||||
func deploymentEnvironment() string {
|
||||
if v := firstNonEmptyEnv("DEPLOYMENT_ENVIRONMENT", "OTEL_DEPLOYMENT_ENVIRONMENT", "ENVIRONMENT"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "production"
|
||||
}
|
||||
|
||||
@@ -756,12 +756,12 @@ require (
|
||||
github.com/hanzo-ds/go v1.0.1
|
||||
github.com/hanzo-ds/native v0.72.0 // indirect
|
||||
github.com/hanzoai/agent v0.1.3
|
||||
github.com/hanzoai/ai v1.830.0
|
||||
github.com/hanzoai/ai v1.831.1
|
||||
github.com/hanzoai/authz v1.10.7
|
||||
github.com/hanzoai/base v1.5.7
|
||||
github.com/hanzoai/licensing v0.1.5
|
||||
github.com/hanzoai/metrics v1.110.2
|
||||
github.com/hanzoai/o11y v1.5.28
|
||||
github.com/hanzoai/o11y v1.5.30
|
||||
github.com/hanzoai/thinking v0.1.1 // indirect
|
||||
github.com/hanzoai/vfs v0.6.4
|
||||
github.com/hanzoai/zen v1.4.2
|
||||
|
||||
@@ -1061,6 +1061,8 @@ github.com/hanzoai/agent v0.1.3 h1:zzV4t8kN/m/wTLrqzEy0fxxONSZbx3XSVH7TIR9gZNU=
|
||||
github.com/hanzoai/agent v0.1.3/go.mod h1:Z3hCBdSeN/nGV4o+3F4psQ2bbFk17+tMP5l+G2ssNNA=
|
||||
github.com/hanzoai/ai v1.830.0 h1:l7bZwuEPuql+z3aE+nmn/Ei24+MqXoJXL32Z3W+vueQ=
|
||||
github.com/hanzoai/ai v1.830.0/go.mod h1:BeIlJNoqJ38l77meu6pgYfZqnxdebqcDJlZNgvwDJ8w=
|
||||
github.com/hanzoai/ai v1.831.1 h1:Ik9RYAOYgjsSsEfhgj4/ggJ0+OyYUBV9uYLB7tIWGgA=
|
||||
github.com/hanzoai/ai v1.831.1/go.mod h1:iZMupp7r/NsFt8kb3BXCj/0bpu0E82fIalK2UyEsKfw=
|
||||
github.com/hanzoai/authz v1.10.7 h1:JrHljH29mbmVi8u6/6EVG7R0NiFhIYYm2WUBBuBmFq0=
|
||||
github.com/hanzoai/authz v1.10.7/go.mod h1:9wf6n6BvrvxRULUtL3yc+vmprwwvAGZLGJoMHhiQSK4=
|
||||
github.com/hanzoai/base v1.5.7 h1:490temFA2Bz4/QD5lWRKWFWJp+k7FfIJCOi3FXMA80w=
|
||||
@@ -1117,6 +1119,8 @@ github.com/hanzoai/notify v1.6.18 h1:YLIKheJSMhGqRuo7NRsMicHjAWSVFV6j6ZGqm2H+IBM
|
||||
github.com/hanzoai/notify v1.6.18/go.mod h1:O8OZj1cfUAIY39ROTPpiaVH8jv947VNfAGor2AZ/ebQ=
|
||||
github.com/hanzoai/o11y v1.5.28 h1:kOqV7vdDdezB+q9YEvrVOq3kviraVs1Pst7IsD0LbI0=
|
||||
github.com/hanzoai/o11y v1.5.28/go.mod h1:0N4ISLvuFsN/Dw74uWwNH3yBTsqBYB+BAgqfBJToyZ0=
|
||||
github.com/hanzoai/o11y v1.5.30 h1:jh0BSAijR98eT5lvhq5/o3sRTU+YsVHtrGVym+a7cKo=
|
||||
github.com/hanzoai/o11y v1.5.30/go.mod h1:lI8yn6GRGJ4ZK2CEfuduZ9yGrr7QFo3AZr7FrFwg0M8=
|
||||
github.com/hanzoai/orm v0.6.1 h1:PELYVy+kTVuA7hqn1y3IQqR1Q5cTk008Wh4CLn9Isok=
|
||||
github.com/hanzoai/orm v0.6.1/go.mod h1:7tXULhLKymkAwlC+jASS66tlLEzU2sdCXX1sRFPoAFs=
|
||||
github.com/hanzoai/otel-collector v1.2.0 h1:lBDL5lKotq89JaqchcM+/oxEnjrjazEI2JJfDhotudc=
|
||||
|
||||
Reference in New Issue
Block a user