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
68 changes: 67 additions & 1 deletion internal/x402/verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httputil"
"net/url"
"sort"
"strings"
"sync/atomic"

Expand All @@ -21,6 +22,16 @@ type Verifier struct {
chain atomic.Pointer[ChainInfo]
chains atomic.Pointer[map[string]ChainInfo] // pre-resolved: chain name → config
metrics *verifierMetrics

// paidPrefixes is the list of URI prefixes the verifier KNOWS are
// paid routes (derived from cfg.Routes patterns on each load). Used
// by HandleVerify to fail-closed when a URI is under a paid prefix
// but no rule matches — the alternative (200 → ForwardAuth allow)
// would silently make the route free.
//
// Sorted by length descending so longer-prefix matches win first
// (defensive — fixes nothing today but cheap insurance).
paidPrefixes atomic.Pointer[[]string]
}

// NewVerifier creates a Verifier with the given initial configuration.
Expand Down Expand Up @@ -64,6 +75,19 @@ func (v *Verifier) load(cfg *PricingConfig) error {
v.chains.Store(&chains)
v.config.Store(cfg)

// Derive paid-prefix tracker from the route patterns. HandleVerify
// uses this to fail-closed when a URI is under a tracked prefix but
// no rule matches (see isUnderPaidPrefix for the rationale).
prefixes := make([]string, 0, len(cfg.Routes))
for _, r := range cfg.Routes {
prefix := patternToPrefix(r.Pattern)
if prefix != "" {
prefixes = append(prefixes, prefix)
}
}
sort.Slice(prefixes, func(i, j int) bool { return len(prefixes[i]) > len(prefixes[j]) })
v.paidPrefixes.Store(&prefixes)

// Drop metric series for offers that are no longer in the route set.
// Without this, deleting an offer leaves its counters + last-success
// gauge in the registry forever, polluting dashboards and silently
Expand Down Expand Up @@ -104,7 +128,17 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) {

rule, requirement, extensions, _, chain, asset, ok := v.matchPaidRouteFull(cfg, uri)
if !ok {
// No pricing rule matches — route is free.
// Check if this URI is under a tracked paid prefix. If yes,
// the route was supposed to match but didn't — fail closed
// rather than silently make it free (Traefik ForwardAuth 200
// means "allow").
if v.isUnderPaidPrefix(uri) {
log.Printf("x402-verifier: URI %q is under a paid prefix but no rule matches — fail closed", uri)
http.Error(w, "no rule matches; route appears to be a paid prefix with stale or missing rule", http.StatusForbidden)

return
}
// Not under any paid prefix — legitimately free route.
w.WriteHeader(http.StatusOK)
return
}
Expand Down Expand Up @@ -277,6 +311,38 @@ func (v *Verifier) matchPaidRouteFull(cfg *PricingConfig, uri string) (*RouteRul
return rule, requirement, extensions, prometheusLabels(rule), chain, asset, true
}

// isUnderPaidPrefix reports whether uri starts with any of the URI
// prefixes the verifier knows are paid routes. Used by HandleVerify
// to fail-closed when matchRoute returns nil but the URI is still
// under a tracked prefix — i.e. the route was supposed to match but
// didn't (stale route table, code bug, etc.).
func (v *Verifier) isUnderPaidPrefix(uri string) bool {
prefixes := v.paidPrefixes.Load()
if prefixes == nil {
return false
}
for _, p := range *prefixes {
if strings.HasPrefix(uri, p) {
return true
}
}
return false
}

// patternToPrefix converts a route Pattern like "/services/foo/*"
// into a directory-style prefix "/services/foo/" suitable for
// strings.HasPrefix matching. Returns "" for patterns without a
// trailing glob — exact-match patterns aren't paid prefixes, so
// fail-closed only applies to the broader "any URI under this path"
// semantic. The trailing slash is preserved so HasPrefix
// distinguishes /services/foo/ from /services/foobar/.
func patternToPrefix(pattern string) string {
if !strings.HasSuffix(pattern, "/*") {
return ""
}
return strings.TrimSuffix(pattern, "*")
}

// mergeAgentExtras adds the agent fields from a RouteRule to the
// requirement's Extra map so buyers probing a 402 see which model and
// skills are powering the offer. No-op for non-agent rules.
Expand Down
82 changes: 82 additions & 0 deletions internal/x402/verifier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,88 @@ func TestVerifier_Reload_PrunesDeletedOfferSeries(t *testing.T) {
}
}

// TestVerifier_HandleVerify_FailClosed_ManualPrefixInjection sanity checks
// that an arbitrary prefix in paidPrefixes triggers fail-closed (403) when
// no rule matches. The manual prefix injection simulates the case where the
// verifier KNOWS about a paid prefix (because a route was previously loaded)
// but the matcher rejects the URI — config drift, code bug, etc.
func TestVerifier_HandleVerify_FailClosed_ManualPrefixInjection(t *testing.T) {
fac := newMockFacilitator(t, mockFacilitatorOpts{})
v := newTestVerifier(t, fac.URL, []RouteRule{
// No rules; matchRoute will return nil for everything.
})

// Manually inject a paid prefix (simulating a stale prefix state).
prefixes := []string{"/services/gated/"}
v.paidPrefixes.Store(&prefixes)

req := httptest.NewRequest(http.MethodPost, "/verify", nil)
req.Header.Set("X-Forwarded-Uri", "/services/gated/foo")
rec := httptest.NewRecorder()
v.HandleVerify(rec, req)

if rec.Code != http.StatusForbidden {
t.Errorf("expected 403 (fail-closed) for URI under tracked paid prefix, got %d", rec.Code)
}
}

// TestVerifier_HandleVerify_FreeRoute_OutsidePrefixes asserts that URIs
// outside all tracked paid prefixes still return 200 (legitimate free pass).
// The verifier is mounted on routes that may or may not be paid; only URIs
// under a known paid prefix should fail closed.
func TestVerifier_HandleVerify_FreeRoute_OutsidePrefixes(t *testing.T) {
fac := newMockFacilitator(t, mockFacilitatorOpts{})
v := newTestVerifier(t, fac.URL, []RouteRule{
{Pattern: "/services/known/*", Price: "0.0001"},
})

req := httptest.NewRequest(http.MethodPost, "/verify", nil)
req.Header.Set("X-Forwarded-Uri", "/health") // Not under any paid prefix.
rec := httptest.NewRecorder()
v.HandleVerify(rec, req)

if rec.Code != http.StatusOK {
t.Errorf("expected 200 for free route outside paid prefixes, got %d", rec.Code)
}
}

// TestVerifier_HandleVerify_PrefixBoundary_NoFalseMatch verifies that the
// trailing slash on paid prefixes prevents false matches between siblings
// like /services/foo/ and /services/foobar/. Without the trailing slash,
// a request to /services/foobar/x would falsely match /services/foo/*.
func TestVerifier_HandleVerify_PrefixBoundary_NoFalseMatch(t *testing.T) {
fac := newMockFacilitator(t, mockFacilitatorOpts{})
v := newTestVerifier(t, fac.URL, []RouteRule{
{Pattern: "/services/foo/*", Price: "0.0001"},
})

// /services/foobar/x is NOT under /services/foo/ — must return 200.
req := httptest.NewRequest(http.MethodPost, "/verify", nil)
req.Header.Set("X-Forwarded-Uri", "/services/foobar/x")
rec := httptest.NewRecorder()
v.HandleVerify(rec, req)

if rec.Code != http.StatusOK {
t.Errorf("expected 200 for sibling path not under prefix, got %d", rec.Code)
}
}

func TestPatternToPrefix(t *testing.T) {
cases := []struct{ pattern, want string }{
{"/services/foo/*", "/services/foo/"},
{"/rpc/*", "/rpc/"},
{"/health", ""}, // No glob, returns empty.
{"/*", "/"},
{"", ""},
{"/exact/match", ""}, // Exact pattern, not a prefix.
}
for _, c := range cases {
if got := patternToPrefix(c.pattern); got != c.want {
t.Errorf("patternToPrefix(%q) = %q, want %q", c.pattern, got, c.want)
}
}
}

// findVerifierMetricValue returns the value of the series in `family` whose
// labels match `wantLabels` exactly, failing the test if no such series exists.
func findVerifierMetricValue(t *testing.T, family *dto.MetricFamily, wantLabels map[string]string) float64 {
Expand Down