diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index 60e2fa8..370ba1e 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httputil" "net/url" + "sort" "strings" "sync/atomic" @@ -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. @@ -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 @@ -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 } @@ -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. diff --git a/internal/x402/verifier_test.go b/internal/x402/verifier_test.go index 3b62c81..98996cd 100644 --- a/internal/x402/verifier_test.go +++ b/internal/x402/verifier_test.go @@ -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 {