Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions cmd/obol/sell.go
Original file line number Diff line number Diff line change
Expand Up @@ -4903,6 +4903,12 @@ func preflightOfferPathCollision(cfg *config.Config, manifest map[string]any) er
if root := monetizeapi.ReservedPathConflict(path); root != "" {
return fmt.Errorf("path %s collides with the reserved platform path %s (discovery/routing surface) — pass --path to pick a different public path", path, root)
}
// F8: same static check, but over each declared spec.routes[].path
// entry rather than just the offer root. A route landing on "/auth" (or
// nested under it) would shadow the verifier's SIWX sign-in endpoints.
if routePath, root := reservedRoutePathCollision(spec); root != "" {
return fmt.Errorf("route path %s collides with the reserved platform path %s (discovery/routing surface) — pick a different route path", routePath, root)
}
bin, kubeconfig := kubectl.Paths(cfg)
out, err := kubectl.Output(bin, kubeconfig, "get", "serviceoffers.obol.org", "-A", "-o", "json")
if err != nil {
Expand All @@ -4911,6 +4917,39 @@ func preflightOfferPathCollision(cfg *config.Config, manifest map[string]any) er
return offerPathCollisionInList([]byte(out), ns, name, path, hostname)
}

// reservedRoutePathCollision checks spec["routes"] against the route-level
// reserved-path denylist and returns the first colliding route's path and
// the reserved root it hit, or ("", "") when none collide. spec["routes"]
// takes two shapes depending on how the manifest was built: []map[string]any
// from parseRouteFlags (the --route flag path) or []any of
// map[string]interface{} from json.Unmarshal (the --from-json path) — any
// is an alias for interface{}, so both element types assert the same way.
func reservedRoutePathCollision(spec map[string]any) (routePath, root string) {
var routes []any
switch rs := spec["routes"].(type) {
case []map[string]any:
for _, r := range rs {
routes = append(routes, r)
}
case []any:
routes = rs
}
for _, item := range routes {
rm, ok := item.(map[string]any)
if !ok {
continue
}
p, _ := rm["path"].(string)
if p == "" {
continue
}
if r := monetizeapi.ReservedRoutePathConflict(p); r != "" {
return p, r
}
}
return "", ""
}

// offerPathCollisionInList is the pure core of preflightOfferPathCollision.
func offerPathCollisionInList(listJSON []byte, ns, name, path, hostname string) error {
var list struct {
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
Expand All @@ -97,6 +98,7 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/signinwithethereum/siwe-go v1.0.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/supranational/blst v0.3.16 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
Expand Down Expand Up @@ -301,6 +303,8 @@ github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/signinwithethereum/siwe-go v1.0.0 h1:NhGba4ov9Eq+5kDU6APGTfCzbLsjZPPkvrxcgwd8CEM=
github.com/signinwithethereum/siwe-go v1.0.0/go.mod h1:/4nSXYCSkf+o3kZQV7uSrISK9/1Nw4yoUKd98cayPhQ=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
Expand Down
51 changes: 51 additions & 0 deletions internal/embed/infrastructure/base/templates/x402.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ metadata:
type: Opaque
stringData:
WALLET_ADDRESS: ""
# Shared secret for the verifier→broker HMAC (F1 defense in depth over the
# NetworkPolicy). Empty = disabled on both sides; set the same non-empty
# value to require a valid X-Obol-Broker-Sig on every async submit.
JOB_BROKER_HMAC_SECRET: ""

---
apiVersion: v1
Expand Down Expand Up @@ -279,6 +283,16 @@ spec:
- --config=/config/pricing.yaml
- --listen=:8080
- --route-source=kube
env:
# F1 defense in depth: sign async broker-contract headers so a
# NetworkPolicy-allowed pod still can't forge a submit. optional
# so an install without the key runs (HMAC off, NetworkPolicy on).
- name: JOB_BROKER_HMAC_SECRET
valueFrom:
secretKeyRef:
name: x402-secrets
key: JOB_BROKER_HMAC_SECRET
optional: true
volumeMounts:
- name: pricing-config
mountPath: /config
Expand Down Expand Up @@ -514,6 +528,15 @@ spec:
args:
- --listen=:8090
- --db=/data/jobs.db
env:
# Same shared secret the verifier signs with (F1). When set, the
# broker requires a valid X-Obol-Broker-Sig on every submit.
- name: JOB_BROKER_HMAC_SECRET
valueFrom:
secretKeyRef:
name: x402-secrets
key: JOB_BROKER_HMAC_SECRET
optional: true
ports:
- name: http
containerPort: 8090
Expand Down Expand Up @@ -553,3 +576,31 @@ spec:
- name: http
port: 8090
targetPort: 8090
---
# job-broker trusts the X-Obol-Upstream-* headers on every request it
# receives to know where to replay it — it is not itself capable of
# re-verifying payment. Without this policy any in-cluster pod could POST
# straight to job-broker.x402.svc with forged upstream headers (SSRF /
# free-compute against whatever upstream it names). Restrict ingress to
# only the x402-verifier pods, which are the sole intended caller (F1).
# Egress is intentionally left unrestricted: the broker must be able to
# reach arbitrary seller upstreams by design.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: job-broker
namespace: x402
spec:
podSelector:
matchLabels:
app: job-broker
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: x402-verifier
ports:
- protocol: TCP
port: 8090
47 changes: 44 additions & 3 deletions internal/jobbroker/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ package jobbroker

import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
Expand Down Expand Up @@ -44,15 +48,40 @@ const upstreamTimeout = 2 * time.Hour
type Server struct {
store *Store
client *http.Client
// hmacSecret, when non-empty, requires every submit to carry a valid
// X-Obol-Broker-Sig computed by the verifier over the contract headers.
// Empty = disabled (the NetworkPolicy is then the only guard). Sourced
// from JOB_BROKER_HMAC_SECRET, the same value the verifier signs with.
hmacSecret string
// now is swappable for tests.
now func() time.Time
}

// HeaderBrokerSig carries the verifier's HMAC over the contract headers, so
// the broker can reject forged submits even from a pod the NetworkPolicy
// allows (defense in depth with the F1 NetworkPolicy). Mirrored in
// internal/x402 (see authgate.go) — the two packages don't share code.
const HeaderBrokerSig = "X-Obol-Broker-Sig"

// brokerSignature is the HMAC the verifier and broker both compute over the
// security-critical contract fields: the replay URL, the offer identity, and
// the injected upstream credential. Keep byte-identical to the x402 copy.
func brokerSignature(secret, upstreamURL, offer, upstreamAuth string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(upstreamURL + "\n" + offer + "\n" + upstreamAuth))
return hex.EncodeToString(mac.Sum(nil))
}

func NewServer(store *Store) *Server {
secret := os.Getenv("JOB_BROKER_HMAC_SECRET")
if secret == "" {
log.Printf("job-broker: JOB_BROKER_HMAC_SECRET unset — verifier→broker HMAC disabled; relying on the NetworkPolicy alone")
}
return &Server{
store: store,
client: &http.Client{Timeout: upstreamTimeout},
now: time.Now,
store: store,
client: &http.Client{Timeout: upstreamTimeout},
hmacSecret: secret,
now: time.Now,
}
}

Expand Down Expand Up @@ -123,6 +152,18 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not a gated async submit (missing broker contract headers)", http.StatusBadRequest)
return
}
// Defense in depth over the NetworkPolicy: when a shared secret is
// provisioned, header *presence* is not enough — the verifier's HMAC
// over (upstreamURL, offer, upstreamAuth) must check out, so a pod that
// slips past the network layer still can't forge an arbitrary-URL,
// attacker-credentialed submit.
if s.hmacSecret != "" {
want := brokerSignature(s.hmacSecret, upstream, offer, r.Header.Get(HeaderUpstreamAuth))
if !hmac.Equal([]byte(r.Header.Get(HeaderBrokerSig)), []byte(want)) {
http.Error(w, "invalid or missing broker signature", http.StatusForbidden)
return
}
}

body, err := io.ReadAll(io.LimitReader(r.Body, maxSubmitBody+1))
if err != nil {
Expand Down
47 changes: 47 additions & 0 deletions internal/jobbroker/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,50 @@ func TestBroker_SubmitWithoutContractHeaders(t *testing.T) {
t.Errorf("ungated submit = %d, want 400", w.Code)
}
}

// TestBroker_HMAC_RejectsForgedSubmit pins F1 defense-in-depth: with a shared
// secret set, a submit whose X-Obol-Broker-Sig doesn't match the contract
// headers is refused (403) — so a NetworkPolicy-allowed pod still can't forge
// an arbitrary-URL, attacker-credentialed job. A correctly-signed submit passes.
func TestBroker_HMAC_RejectsForgedSubmit(t *testing.T) {
t.Setenv("JOB_BROKER_HMAC_SECRET", "test-shared-secret")
dir := t.TempDir()
store, err := OpenStore(dir + "/jobs.db")
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
srv := NewServer(store)

upstream := "http://upstream.internal/work"
offer := "sec/audit"
newReq := func(sig string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/jobs", strings.NewReader(`{}`))
req.Header.Set(HeaderUpstreamURL, upstream)
req.Header.Set(HeaderOffer, offer)
if sig != "" {
req.Header.Set(HeaderBrokerSig, sig)
}
return req
}

// Forged / missing signature → 403.
w := httptest.NewRecorder()
srv.handleSubmit(w, newReq("deadbeef"))
if w.Code != http.StatusForbidden {
t.Fatalf("forged-sig submit = %d, want 403", w.Code)
}
w = httptest.NewRecorder()
srv.handleSubmit(w, newReq(""))
if w.Code != http.StatusForbidden {
t.Fatalf("missing-sig submit = %d, want 403", w.Code)
}

// Correct signature (matching the verifier's computation) → accepted (202).
good := brokerSignature("test-shared-secret", upstream, offer, "")
w = httptest.NewRecorder()
srv.handleSubmit(w, newReq(good))
if w.Code != http.StatusAccepted {
t.Fatalf("correctly-signed submit = %d (%s), want 202", w.Code, w.Body.String())
}
}
21 changes: 21 additions & 0 deletions internal/monetizeapi/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,27 @@ func ReservedPathConflict(path string) string {
return ""
}

// routeReservedPathRoots extends reservedPathRoots with the verifier's own
// sign-in surface for gate:auth offers. A route declared at "/auth" or
// "/auth/verify" would shadow the SIWX challenge/verify endpoints the
// verifier serves at those paths.
var routeReservedPathRoots = append(append([]string{}, reservedPathRoots...), "/auth")

// ReservedRoutePathConflict returns the reserved root a route's
// offer-relative path (spec.routes[].path) collides with, or "". Unlike
// ReservedPathConflict this does not special-case "/" or "/services" — "/"
// is a normal, meaningful relative route path (the offer's own root route)
// — it only guards the shared verifier surfaces plus "/auth".
func ReservedRoutePathConflict(path string) string {
p := strings.TrimSuffix(path, "/")
for _, root := range routeReservedPathRoots {
if p == root || strings.HasPrefix(p, root+"/") {
return root
}
}
return ""
}

// EffectiveOrigin returns the offer's dedicated public origin
// ("https://<hostname>") when spec.hostname is set, else "". Discovery
// surfaces use it to advertise hostname-bound offers at their own origin
Expand Down
25 changes: 25 additions & 0 deletions internal/monetizeapi/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,28 @@ func TestReservedPathConflict(t *testing.T) {
}
}
}

// TestReservedRoutePathConflict pins the route-level denylist (F8): a
// spec.routes[].path may not land on "/auth" or "/auth/verify" (the
// verifier's SIWX sign-in endpoints for gate:auth offers) or nest under any
// of the shared reservedPathRoots. Unlike the offer-root check, "/" is a
// legitimate relative route path and must stay unreserved.
func TestReservedRoutePathConflict(t *testing.T) {
tests := []struct{ path, wantRoot string }{
{"/", ""},
{"/v1/*", ""},
{"/healthz", ""},
{"/auth", "/auth"},
{"/auth/", "/auth"},
{"/auth/verify", "/auth"},
{"/authorize", ""}, // prefix must respect segment boundaries
{"/api", "/api"},
{"/api/services.json", "/api"},
{"/.well-known/x402", "/.well-known"},
}
for _, tt := range tests {
if got := ReservedRoutePathConflict(tt.path); got != tt.wantRoot {
t.Errorf("ReservedRoutePathConflict(%q) = %q, want %q", tt.path, got, tt.wantRoot)
}
}
}
13 changes: 13 additions & 0 deletions internal/serviceoffercontroller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,19 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error {
setCondition(&status, "Draining", "False", "Active", "Offer is active")
setCondition(&status, "PaymentGateReady", "False", "ReservedPath", msg)
setCondition(&status, "RoutePublished", "False", "ReservedPath", msg)
} else if routePath, root := reservedRouteConflict(offer); root != "" {
// Same reserved-surface treatment as above, but for an individual
// spec.routes[].path entry (F8) — e.g. a route declared at "/auth"
// would shadow the verifier's own SIWX sign-in endpoints for
// gate:auth offers.
msg := fmt.Sprintf("route path %s collides with the reserved platform path %s — set a different spec.routes[].path", routePath, root)
log.Printf("serviceoffer-controller: %s/%s reserved route path: %s", offer.Namespace, offer.Name, msg)
if err := c.deleteRouteChildren(ctx, offer); err != nil {
return err
}
setCondition(&status, "Draining", "False", "Active", "Offer is active")
setCondition(&status, "PaymentGateReady", "False", "ReservedPath", msg)
setCondition(&status, "RoutePublished", "False", "ReservedPath", msg)
} else if conflict := c.findHostnameConflict(offer); conflict != "" {
// One offer per public origin — same first-claimant-wins treatment
// as a path conflict.
Expand Down
Loading