Skip to content

Commit f8df92e

Browse files
JeanDaniel Bussybussyjd
andauthored
fix(x402-buyer): propagate upstream status/body and log every request (#504)
* fix(x402-buyer): propagate upstream status/body and log every request During a live demo against inference.v1337.org/services/aeon, the upstream responded with HTTP 403 + body "error code: 1010" (Cloudflare WAF blocking on User-Agent). LiteLLM surfaced this as: ServiceUnavailableError: OpenAIException - Payment verification failed That misclassification cost ~30 min of debugging before someone called the sidecar directly and saw the actual 403/1010 body. The real fix is two parts: 1. Structured per-request logging: every outbound RoundTrip now emits a log line with method, URL, status code, duration, and a 512-byte body excerpt: x402-buyer: outbound POST https://.../ → 403 (no payment required) body="error code: 1010" x402-buyer: outbound POST https://.../ (with X-PAYMENT) → 403 after 42ms body="error code: 1010" — passing through upstream error x402-buyer: outbound POST https://.../ (with X-PAYMENT) → 200 after 38ms Transport-level errors (dial failures, TLS) are also logged. 2. Non-402 non-2xx status codes are passed through verbatim: the sidecar already returned the upstream response object unchanged in both the probe path (first request, no X-PAYMENT) and the retry path (after X-PAYMENT). No status remapping was happening in proxy.go — the "Payment verification failed" came from Traefik's x402-verifier ForwardAuth (forwardauth.go:147), not the buyer. The per-request log now gives operators the evidence they need to distinguish "verifier blocked" from "upstream blocked" without having to port-forward and call the sidecar manually. The 402-retry payment signing behaviour is unchanged: the sidecar still intercepts 402 probe responses, attaches X-PAYMENT, and retries. Only the observability and error-surface for non-402 non-2xx is improved. Adds TestUpstreamStatusPropagation table test: 7 subtests covering 200/403/ 404/503 (direct) and 200/403/500 (after payment retry). All 35 buyer tests pass. * fix(x402-buyer): sanitize user-controlled log inputs (CWE-117) Addresses CodeQL alerts 4483-4487 from PR #504 review. The 5 new log.Printf calls added for upstream error visibility interpolated req.Method, req.URL, and err directly — an attacker-controlled upstream could embed CR/LF to inject fake log entries (log forging). Adds sanitizeLogString() which replaces any code point < 0x20 or == 0x7f with '_', then wraps every user-controlled argument (Method, URL.String(), err.Error()) in that helper before interpolation. The body excerpt was already %q-escaped and is not changed. Unit test covers \n, \r, \t, \x00, \x1b, DEL, printable ASCII, and UTF-8 multi-byte letters. --------- Co-authored-by: bussyjd <bussyjd@users.noreply.github.com>
1 parent 7fd7d49 commit f8df92e

2 files changed

Lines changed: 305 additions & 0 deletions

File tree

internal/x402/buyer/proxy.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,46 @@ type replayableX402Transport struct {
425425
OnPaymentUnsettled PaymentCallback
426426
}
427427

428+
// maxLogBodyBytes is the maximum number of bytes read from a response body for
429+
// structured log output. Truncation prevents huge bodies from flooding logs.
430+
const maxLogBodyBytes = 512
431+
432+
// peekResponseBody reads up to maxLogBodyBytes from resp.Body for logging and
433+
// reassembles the full body (via a TeeReader drain) so the caller (ReverseProxy)
434+
// can still copy it to the client. The body is replaced in-place on resp.
435+
func peekResponseBody(resp *http.Response) string {
436+
if resp == nil || resp.Body == nil {
437+
return ""
438+
}
439+
440+
// Drain up to maxLogBodyBytes+1 bytes via a buffer, then prepend those
441+
// bytes back using io.MultiReader so the rest of the body is still readable.
442+
limitedBuf := new(bytes.Buffer)
443+
_, _ = io.Copy(limitedBuf, io.LimitReader(resp.Body, maxLogBodyBytes+1))
444+
445+
preview := limitedBuf.Bytes()
446+
// Reassemble: preview bytes + remainder of original body.
447+
resp.Body = io.NopCloser(io.MultiReader(bytes.NewReader(preview), resp.Body))
448+
449+
s := string(preview)
450+
if len(s) > maxLogBodyBytes {
451+
s = s[:maxLogBodyBytes] + "…"
452+
}
453+
return s
454+
}
455+
456+
// sanitizeLogString strips control characters (including CR/LF) from values
457+
// that may originate from upstream HTTP requests or responses, preventing log
458+
// injection (CWE-117) when the strings are interpolated into log lines.
459+
func sanitizeLogString(s string) string {
460+
return strings.Map(func(r rune) rune {
461+
if r < 0x20 || r == 0x7f {
462+
return '_'
463+
}
464+
return r
465+
}, s)
466+
}
467+
428468
func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response, error) {
429469
if t.Base == nil {
430470
t.Base = http.DefaultTransport
@@ -440,9 +480,21 @@ func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response,
440480
firstReq.Header.Set("User-Agent", userAgent)
441481
resp, err := t.Base.RoundTrip(firstReq)
442482
if err != nil {
483+
log.Printf("x402-buyer: outbound %s %s → transport error: %s",
484+
sanitizeLogString(req.Method),
485+
sanitizeLogString(req.URL.String()),
486+
sanitizeLogString(err.Error()))
443487
return nil, err
444488
}
445489
if resp.StatusCode != http.StatusPaymentRequired {
490+
// Non-402 response on the probe request — pass through verbatim so the
491+
// caller (LiteLLM) sees the real upstream status and body, not a
492+
// remapped "Payment verification failed" generic error.
493+
excerpt := peekResponseBody(resp)
494+
log.Printf("x402-buyer: outbound %s %s → %d (no payment required) body=%q",
495+
sanitizeLogString(req.Method),
496+
sanitizeLogString(req.URL.String()),
497+
resp.StatusCode, excerpt)
446498
return resp, nil
447499
}
448500

@@ -531,6 +583,11 @@ func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response,
531583

532584
if err != nil {
533585
releaseHeldPreSignedSpend(t.Signers, heldAuth)
586+
log.Printf("x402-buyer: outbound %s %s (with X-PAYMENT) → transport error after %s: %s",
587+
sanitizeLogString(req.Method),
588+
sanitizeLogString(req.URL.String()),
589+
duration.Round(time.Millisecond),
590+
sanitizeLogString(err.Error()))
534591
if t.OnPaymentFailure != nil {
535592
t.OnPaymentFailure(PaymentEvent{
536593
Type: PaymentEventFailure,
@@ -545,10 +602,25 @@ func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response,
545602
}
546603

547604
if respRetry.StatusCode >= http.StatusBadRequest {
605+
// Non-2xx after payment retry: release the held auth (not consumed) and
606+
// pass the upstream status+body verbatim to the caller. This ensures the
607+
// real upstream error (e.g. 403 Cloudflare WAF, 404, 500) reaches
608+
// LiteLLM instead of being misclassified as "Payment verification failed".
609+
excerpt := peekResponseBody(respRetry)
610+
log.Printf("x402-buyer: outbound %s %s (with X-PAYMENT) → %d after %s body=%q — passing through upstream error",
611+
sanitizeLogString(req.Method),
612+
sanitizeLogString(req.URL.String()),
613+
respRetry.StatusCode, duration.Round(time.Millisecond), excerpt)
548614
releaseHeldPreSignedSpend(t.Signers, heldAuth)
549615
return respRetry, nil
550616
}
551617

618+
// 2xx — log the successful paid response.
619+
log.Printf("x402-buyer: outbound %s %s (with X-PAYMENT) → %d after %s",
620+
sanitizeLogString(req.Method),
621+
sanitizeLogString(req.URL.String()),
622+
respRetry.StatusCode, duration.Round(time.Millisecond))
623+
552624
if len(t.Signers) == 1 {
553625
if ps, ok := t.Signers[0].(*PreSignedSigner); ok && heldAuth != nil {
554626
if confirmErr := ps.ConfirmSpend(heldAuth); confirmErr != nil {

internal/x402/buyer/proxy_test.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2124,3 +2124,236 @@ func TestProxy_UserAgentOnPaidRequest(t *testing.T) {
21242124
t.Errorf("paid User-Agent = %q, want %q", capturedPaidUA, wantUA)
21252125
}
21262126
}
2127+
2128+
// TestUpstreamStatusPropagation exercises the pass-through behaviour added in
2129+
// fix(x402-buyer): propagate upstream status/body and log every request.
2130+
//
2131+
// Evidence: during a live demo against inference.v1337.org the upstream
2132+
// returned HTTP 403 + body "error code: 1010" (Cloudflare WAF) but LiteLLM
2133+
// surfaced it as "ServiceUnavailableError: Payment verification failed" — a
2134+
// misleading remapping that cost ~30 min of fruitless debugging. The sidecar
2135+
// must forward non-402 non-2xx responses verbatim so the real error is visible.
2136+
func TestUpstreamStatusPropagation(t *testing.T) {
2137+
// paymentRequiredBody is the 402 body the upstream emits on the probe request.
2138+
paymentRequiredBody := `{
2139+
"x402Version": 1,
2140+
"accepts": [{
2141+
"scheme": "exact",
2142+
"network": "base-sepolia",
2143+
"maxAmountRequired": "1000",
2144+
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
2145+
"payTo": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
2146+
}]
2147+
}`
2148+
2149+
type upstreamBehaviour int
2150+
const (
2151+
// directStatus: upstream returns the given status immediately (no 402 handshake).
2152+
directStatus upstreamBehaviour = iota
2153+
// afterPayment: upstream first returns 402, then returns the given status when X-PAYMENT is present.
2154+
afterPayment
2155+
)
2156+
2157+
tests := []struct {
2158+
name string
2159+
behaviour upstreamBehaviour
2160+
upstreamCode int
2161+
upstreamBody string
2162+
wantCode int
2163+
wantBodySub string
2164+
}{
2165+
{
2166+
name: "200 direct — forwarded as 200",
2167+
behaviour: directStatus,
2168+
upstreamCode: http.StatusOK,
2169+
upstreamBody: `{"choices":[{"message":{"content":"ok"}}]}`,
2170+
wantCode: http.StatusOK,
2171+
wantBodySub: "ok",
2172+
},
2173+
{
2174+
name: "403 direct (no payment) — forwarded as 403 with real body",
2175+
behaviour: directStatus,
2176+
upstreamCode: http.StatusForbidden,
2177+
upstreamBody: "error code: 1010",
2178+
wantCode: http.StatusForbidden,
2179+
wantBodySub: "error code: 1010",
2180+
},
2181+
{
2182+
name: "404 direct — forwarded as 404",
2183+
behaviour: directStatus,
2184+
upstreamCode: http.StatusNotFound,
2185+
upstreamBody: "not found",
2186+
wantCode: http.StatusNotFound,
2187+
wantBodySub: "not found",
2188+
},
2189+
{
2190+
name: "503 direct — forwarded as 503, NOT remapped",
2191+
behaviour: directStatus,
2192+
upstreamCode: http.StatusServiceUnavailable,
2193+
upstreamBody: "upstream overloaded",
2194+
wantCode: http.StatusServiceUnavailable,
2195+
wantBodySub: "upstream overloaded",
2196+
},
2197+
{
2198+
name: "200 after 402 payment — forwarded as 200",
2199+
behaviour: afterPayment,
2200+
upstreamCode: http.StatusOK,
2201+
upstreamBody: `{"choices":[{"message":{"content":"paid ok"}}]}`,
2202+
wantCode: http.StatusOK,
2203+
wantBodySub: "paid ok",
2204+
},
2205+
{
2206+
name: "403 after 402 payment (WAF block) — forwarded as 403 with real body",
2207+
behaviour: afterPayment,
2208+
upstreamCode: http.StatusForbidden,
2209+
upstreamBody: "error code: 1010",
2210+
wantCode: http.StatusForbidden,
2211+
wantBodySub: "error code: 1010",
2212+
},
2213+
{
2214+
name: "500 after 402 payment — forwarded as 500, NOT remapped",
2215+
behaviour: afterPayment,
2216+
upstreamCode: http.StatusInternalServerError,
2217+
upstreamBody: "internal server error",
2218+
wantCode: http.StatusInternalServerError,
2219+
wantBodySub: "internal server error",
2220+
},
2221+
}
2222+
2223+
for _, tt := range tests {
2224+
tt := tt
2225+
t.Run(tt.name, func(t *testing.T) {
2226+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2227+
switch tt.behaviour {
2228+
case directStatus:
2229+
w.WriteHeader(tt.upstreamCode)
2230+
fmt.Fprint(w, tt.upstreamBody)
2231+
case afterPayment:
2232+
if r.Header.Get("X-Payment") == "" {
2233+
w.Header().Set("Content-Type", "application/json")
2234+
w.WriteHeader(http.StatusPaymentRequired)
2235+
fmt.Fprint(w, paymentRequiredBody)
2236+
return
2237+
}
2238+
if tt.upstreamCode == http.StatusOK {
2239+
w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString([]byte(
2240+
`{"success":true,"transaction":"0xtest","network":"base-sepolia","payer":"0xpayer"}`,
2241+
)))
2242+
}
2243+
w.WriteHeader(tt.upstreamCode)
2244+
fmt.Fprint(w, tt.upstreamBody)
2245+
}
2246+
}))
2247+
defer upstream.Close()
2248+
2249+
cfg := &Config{
2250+
Upstreams: map[string]UpstreamConfig{
2251+
"seller": {
2252+
URL: upstream.URL,
2253+
Network: "base-sepolia",
2254+
PayTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
2255+
Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
2256+
Price: "1000",
2257+
},
2258+
},
2259+
}
2260+
auths := AuthsFile{"seller": {makeAuth("0xsig-prop")}}
2261+
2262+
proxy, err := NewProxy(cfg, auths, nil)
2263+
if err != nil {
2264+
t.Fatalf("NewProxy: %v", err)
2265+
}
2266+
2267+
srv := httptest.NewServer(proxy)
2268+
defer srv.Close()
2269+
2270+
resp, err := http.Post(
2271+
srv.URL+"/upstream/seller/v1/chat/completions",
2272+
"application/json",
2273+
strings.NewReader(`{"model":"seller","messages":[{"role":"user","content":"hi"}]}`),
2274+
)
2275+
if err != nil {
2276+
t.Fatalf("request: %v", err)
2277+
}
2278+
defer resp.Body.Close()
2279+
2280+
body, _ := io.ReadAll(resp.Body)
2281+
2282+
if resp.StatusCode != tt.wantCode {
2283+
t.Errorf("status = %d, want %d (body: %s)", resp.StatusCode, tt.wantCode, string(body))
2284+
}
2285+
2286+
if tt.wantBodySub != "" && !strings.Contains(string(body), tt.wantBodySub) {
2287+
t.Errorf("body %q does not contain %q", string(body), tt.wantBodySub)
2288+
}
2289+
})
2290+
}
2291+
}
2292+
2293+
func TestSanitizeLogString(t *testing.T) {
2294+
tests := []struct {
2295+
name string
2296+
input string
2297+
want string
2298+
}{
2299+
{
2300+
name: "newline replaced",
2301+
input: "foo\nbar",
2302+
want: "foo_bar",
2303+
},
2304+
{
2305+
name: "carriage return replaced",
2306+
input: "foo\rbar",
2307+
want: "foo_bar",
2308+
},
2309+
{
2310+
name: "tab replaced",
2311+
input: "foo\tbar",
2312+
want: "foo_bar",
2313+
},
2314+
{
2315+
name: "null byte replaced",
2316+
input: "foo\x00bar",
2317+
want: "foo_bar",
2318+
},
2319+
{
2320+
name: "escape replaced",
2321+
input: "foo\x1bbar",
2322+
want: "foo_bar",
2323+
},
2324+
{
2325+
name: "DEL replaced",
2326+
input: "foo\x7fbar",
2327+
want: "foo_bar",
2328+
},
2329+
{
2330+
name: "multiple control chars",
2331+
input: "\r\ninjected-log-entry\r\n",
2332+
want: "__injected-log-entry__",
2333+
},
2334+
{
2335+
name: "printable ASCII unchanged",
2336+
input: "GET /v1/chat/completions HTTP/1.1",
2337+
want: "GET /v1/chat/completions HTTP/1.1",
2338+
},
2339+
{
2340+
name: "UTF-8 letters unchanged",
2341+
input: "café résumé",
2342+
want: "café résumé",
2343+
},
2344+
{
2345+
name: "empty string",
2346+
input: "",
2347+
want: "",
2348+
},
2349+
}
2350+
2351+
for _, tt := range tests {
2352+
t.Run(tt.name, func(t *testing.T) {
2353+
got := sanitizeLogString(tt.input)
2354+
if got != tt.want {
2355+
t.Errorf("sanitizeLogString(%q) = %q, want %q", tt.input, got, tt.want)
2356+
}
2357+
})
2358+
}
2359+
}

0 commit comments

Comments
 (0)