Skip to content

fix(route-surface): security + correctness + x402 hardening for #717 (F1,F1-HMAC,F3,F3+,F4,F5,F6,F7,F8,P1b) - #718

Closed
bussyjd wants to merge 9 commits into
feat/route-surface-gatesfrom
fix/route-surface-hardening
Closed

fix(route-surface): security + correctness + x402 hardening for #717 (F1,F1-HMAC,F3,F3+,F4,F5,F6,F7,F8,P1b)#718
bussyjd wants to merge 9 commits into
feat/route-surface-gatesfrom
fix/route-surface-hardening

Conversation

@bussyjd

@bussyjd bussyjd commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Hardening fixes for the findings in #717's review, based on feat/route-surface-gates. Six commits, grouped by category — all touch disjoint files and are independently revertable, so this can merge whole or be split into per-category PRs on request. Every finding was verified against the x402 spec (sign-in-with-x, offer-receipt, core v2) and re-checked by a second model.

Build + test: go build ./... clean; go test green across internal/x402, internal/serviceoffercontroller, internal/monetizeapi, internal/tunnel, internal/embed. New tests fail-before / pass-after each fix.

Security (the two merge-blockers + one more)

  • F1 — job-broker trust boundary. NetworkPolicy pinning job-broker ingress to the x402-verifier pod (port 8090). The broker only checked header presence, and any in-cluster pod could POST forged X-Obol-Upstream-* for a free SSRF/arbitrary-fetch relay. (HMAC on the verifier→broker hop is the recommended defense-in-depth follow-up — left out of this security fix as it touches both sides + a Secret; happy to add.)
  • F4 — login-CSRF on /auth/verify. Require Content-Type: application/json, reject cross-site Sec-Fetch-Site, and set the session cookie SameSite=Strict. Blocks the text/plain-form session-fixation vector; the browser sign-in page already sends JSON, so nothing legitimate breaks. Adds CSRF-guard tests.
  • F5 — open redirect via backslash. sanitizeNextPath now rejects \ (Go's url.Parse keeps it as a path byte; browsers normalize it to /, so /\evil.com escaped off-origin).

Route-surface correctness

  • F6 — OpenAPI/verifier gate drift. The OpenAPI builder now resolves collapsed path+method winners with the same exact-over-wildcard specificity the matcher uses, so discovery can no longer advertise a route free that the gate charges. Golden test with the adversarial ordering.
  • F7 — fail-closed for exact-only tables. Register the offer base (StripPrefix) as a tracked paid prefix whenever the offer has a non-free rule, so undeclared siblings 403 instead of 200 in ForwardAuth. Declared routes still resolve first, so no behavior change for real routes.
  • F8 — reserved-path validation. Run the reserved-path check over every declared spec.routes[].path (not just the offer root) and add the verifier-owned /auth suffixes, refusing shadowing offers at admission with a clear condition/CLI error.

x402 spec-fidelity

  • F3 — accept the sign-in-with-x spec transport. Additively accept the standard SIGN-IN-WITH-X base64-JSON header (CAIP-122 / EIP-4361) alongside the existing Authorization: SIWX and Bearer/cookie forms, so a stock x402 client interoperates. Uses the x402 Go SDK's signinwithx.ParseHeader + CreateMessage to rebuild the canonical EIP-4361 message, then runs it through the same VerifyMessage path (domain binding, freshness window, nonce-replay cache, EIP-191 EOA recovery) — no bespoke crypto. EVM/EOA only, matching the rest of the surface. Adds a signed round-trip test. (Remaining for full interop: advertise the sign-in-with-x extension block in the 402 challenge — a larger change touching the challenge/nonce model, noted for a follow-up.)

Reconcile safety

  • P1b — offerBoundHostnames fails safe. It returned nil on any kubectl error, so a transient failure during stack up re-seized bound per-offer origins under the storefront catch-all. Now distinguishes "query failed" from "zero bound" and leaves existing routes untouched on failure.

Follow-ups now folded in

  • F1-HMAC — authenticate the verifier→broker hop. Defense in depth over the NetworkPolicy: when JOB_BROKER_HMAC_SECRET is set, the verifier signs the security-critical contract fields (upstreamURL, offer, upstreamAuth) into X-Obol-Broker-Sig and the broker rejects any mismatched submit — so a pod that slips past the network layer still can't forge an arbitrary-URL, attacker-credentialed job. Empty secret = disabled on both sides (NetworkPolicy-only), so installs without the key are unaffected. Secret + optional env wired into both containers; forged/missing/valid-sig submits tested.
  • F3-followup — advertise sign-in-with-x in the challenge. The machine 401 challenge now carries the extensions[sign-in-with-x] block (fresh server nonce, supported EVM chains, SDK canonical schema), so a cold stock client can build the credential without prior knowledge of obol. Completes the F3 intake — obol now both accepts the spec transport and tells clients how to construct it. Stateless (obol accepts client nonces), so additive.

Not in this PR (genuine blockers / decisions for the author)

  • F2 — result retrieval is already SIWx-gated in the broker (callerMayRead: X-Verified-Wallet == payer), so that half is done. The remaining piece — an offer-receipt signed receipt on successful delivery — needs a receipt-signer-key decision (payTo signs / a stack service key / verifier-via-remote-signer) plus hand-rolled EIP-712 (no SDK package). Surfaced as a design decision rather than a guessed implementation.
  • F9 — the job-broker image isn't published to ghcr yet (no remote manifest), so there is no digest to pin. This is inherently a release-pipeline step, not a code change — pin when the image is first built and pushed.

https://claude.ai/code/session_01VquWN9UMaSHH7MHGcG8bw1

@bussyjd bussyjd changed the title fix(route-surface): security + correctness hardening for #717 (F1,F4,F5,F6,F7,F8,P1b) fix(route-surface): security + correctness + x402 hardening for #717 (F1,F3,F4,F5,F6,F7,F8,P1b) Jul 8, 2026
bussyjd added 7 commits July 8, 2026 12:58
…(F4, F5)

- Require Content-Type: application/json and reject cross-site Sec-Fetch-Site
  on /auth/verify so a cross-origin form cannot mint an attacker-wallet
  session in a victim browser (login-CSRF / session fixation).
- Set the session cookie SameSite=Strict (was Lax); the post-sign-in
  redirect is same-origin so Strict never blocks the real flow.
- Reject a backslash in sanitizeNextPath: Go url.Parse keeps '\' as a path
  byte but browsers normalize it to '/', so /\evil.com was an open redirect.
An offer whose non-free routes are all exact registered no paid prefix, so an
undeclared sibling under the offer base 200-free-passed in HandleVerify —
contradicting the route-table contract that undeclared paths are refused once
any route is declared. Register the offer base (StripPrefix) as a tracked
paid prefix whenever the offer has a non-free rule. Declared routes still
return from matchRoute before the prefix check, so only genuinely-undeclared
siblings fail closed. Adds CSRF-guard + exact-only-fail-closed tests.
…y, not declaration order (F6)

(cherry picked from commit 36dfe7960de5d1d5923e9e1f0dd354c5653012f7)
…ffixes (F8)

ReservedPathConflict was only ever checked against an offer's root
EffectivePath(), in both the serviceoffer-controller reconcile and the
CLI preflight. Individual spec.routes[].path entries went unchecked, so
a route could be declared at a reserved verifier surface — most
notably "/auth" or "/auth/verify", the SIWX sign-in endpoints the
verifier serves for gate:auth offers — and silently shadow it.

Add ReservedRoutePathConflict (types.go), which reuses the shared
reservedPathRoots denylist plus "/auth", without the root check's "/"
and "/services" special-casing (a route's "/" is the offer's own root
route, not the storefront root). Wire it into the controller reconcile
(new ReservedPath-reason branch before findHostnameConflict, mirroring
the root-path branch) and into the CLI preflight
(reservedRoutePathCollision, handling both the []map[string]any shape
from --route flags and the []any/json.Unmarshal shape from
--from-json).

(cherry picked from commit 01f1f26dba191fadedba88e3c4922effff1b4cd3)
…don't steamroll bound origins (P1b)

offerBoundHostnames() collapsed two different situations into the same
nil return: "queried fine, zero offers are hostname-bound" and "the
kubectl query itself failed" (cluster down, CRD missing). CreateStorefront
treated both identically — skip the bound-hostname filter — so a
transient kubectl error made the catch-all storefront route silently
reclaim every tracked hostname, including ones already bound to a live
ServiceOffer's dedicated origin.

offerBoundHostnames now returns (map[string]bool, error), with a query
failure surfaced as a real error instead of nil. CreateStorefront checks
that error first and, on failure, logs a warning and returns without
touching the storefront route at all — leaving whatever hostname set is
already published in place — rather than proceeding as if nothing were
bound.

(cherry picked from commit cbeb2b76feb453e83916f81c441a6e63970f3fb9)
job-broker had no NetworkPolicy: any in-cluster pod could POST directly
to job-broker.x402.svc with forged X-Obol-Upstream-* headers, since the
broker trusts those headers to know where to replay a request and
cannot itself re-verify payment (SSRF / free-compute risk).

Add a NetworkPolicy that selects the job-broker pods and restricts
Ingress to only the x402-verifier pods (the sole intended caller) on
port 8090. Egress is left unrestricted by design — the broker must
reach arbitrary seller upstreams. No HMAC (separate follow-up) and no
image digest changes.

(cherry picked from commit a96333e24c932d9ff29a347a52f265302e6fcf10)
Additively accept the spec transport (docs.x402.org/extensions/sign-in-with-x):
a base64-JSON payload in the SIGN-IN-WITH-X header, alongside the existing
Authorization: SIWX and Bearer/cookie forms. Uses the x402 Go SDK's
signinwithx.ParseHeader + CreateMessage to rebuild the canonical EIP-4361
message, then runs it through the same VerifyMessage path (domain binding,
freshness window, nonce-replay cache, EIP-191 EOA recovery) as the native
form — so a stock x402 client interoperates without a bespoke credential
shape. EVM/EOA only, matching the rest of the SIWx surface; Solana/EIP-1271
payloads get a clear error. Adds a signed round-trip + wrong-domain test.
@bussyjd
bussyjd force-pushed the fix/route-surface-hardening branch from a0b5539 to 99c6a7e Compare July 8, 2026 08:59
@bussyjd bussyjd changed the title fix(route-surface): security + correctness + x402 hardening for #717 (F1,F3,F4,F5,F6,F7,F8,P1b) fix(route-surface): security + correctness + x402 hardening for #717 (F1,F1-HMAC,F3,F3+,F4,F5,F6,F7,F8,P1b) Jul 8, 2026
bussyjd added 2 commits July 8, 2026 14:12
…depth)

The NetworkPolicy (shipped) closes the network path to the job-broker; this
adds the app-layer guarantee the review asked for. When JOB_BROKER_HMAC_SECRET
is set, the verifier signs the security-critical contract fields (upstreamURL,
offer, upstreamAuth) into X-Obol-Broker-Sig and the broker rejects any submit
whose signature doesn't match — so a pod that slips past the network layer
still can't forge an arbitrary-URL, attacker-credentialed job. Empty secret =
disabled on both sides (NetworkPolicy-only), so installs without the key are
unaffected. Secret + optional env wired into both containers; forged/missing/
valid-sig submits covered by a broker test.
…nge (F3 follow-up)

The machine (JSON) 401 challenge now carries the x402 extensions[sign-in-with-x]
block — message info with a freshly-minted server nonce + issuedAt, the
supported EVM chains (eip191/EOA), and the SDK's canonical schema — so a cold
stock x402 client can construct the credential without prior knowledge of obol.
The nonce is stateless (obol accepts client nonces, so advertising a fresh one
is additive, not a tightening). Completes the F3 intake shipped earlier: obol
now both accepts the SIGN-IN-WITH-X transport and tells clients how to build it.
@bussyjd

bussyjd commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Superseded — all commits from this branch (F1/F1-HMAC/F3/F3+/F4/F5/F6/F7/F8/P1b hardening, including #717 which it was stacked on) landed on main via the v0.13.0-rc1 release line (#723, merge e8ca640). git merge-base --is-ancestor confirms this branch's head ae7dbcb is in main. Closing as merged-by-other-path; branch deleted.

@bussyjd bussyjd closed this Jul 8, 2026
@bussyjd
bussyjd deleted the fix/route-surface-hardening branch July 8, 2026 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant