Skip to content

Commit b39fe3c

Browse files
committed
security(x402): sanitize ServiceOffer-sourced tokens in 402 copy-paste commands
spec.model.name and metadata.name flow from the ServiceOffer CR into copy-pasteable 'obol buy inference ...' commands rendered on the public 402 page. A hostile or fat-fingered offer could smuggle shell metacharacters into a command a reader might paste. Add sanitizeDisplayToken at the render boundary: CR-sourced tokens must match the model-id/k8s-name charset (^[A-Za-z0-9._:/-]+$) or collapse to the existing safe placeholder. Real ids like qwen3.5:9b and anthropic/claude-3-5-sonnet-latest pass through unchanged.
1 parent 8e31c42 commit b39fe3c

2 files changed

Lines changed: 80 additions & 11 deletions

File tree

internal/x402/paymentrequired.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,34 @@ import (
88
"html/template"
99
"math/big"
1010
"net/http"
11+
"regexp"
1112
"strings"
1213

1314
x402types "github.com/coinbase/x402/go/types"
1415
)
1516

17+
// displayTokenRe is the allowed charset for ServiceOffer-sourced strings
18+
// (offer name, model id) that get interpolated into copy-pasteable CLI
19+
// commands and prompts on the 402 page. It permits the model-id / k8s-name
20+
// vocabulary — alphanumerics plus `. _ : / -` — and nothing else, so shell
21+
// metacharacters can never reach a command a reader might paste.
22+
var displayTokenRe = regexp.MustCompile(`^[A-Za-z0-9._:/-]+$`)
23+
24+
// sanitizeDisplayToken guards a CR-sourced string before it lands in a
25+
// copy-pasteable command on the public 402 page. A ServiceOffer is
26+
// operator-authored, but the page is served over the public tunnel, so a
27+
// hostile or fat-fingered spec.model.name / metadata.name must not smuggle
28+
// shell metacharacters into the rendered command. Anything that isn't a clean
29+
// model-id/k8s-name token (including empty/whitespace) collapses to the
30+
// caller's placeholder.
31+
func sanitizeDisplayToken(s, placeholder string) string {
32+
s = strings.TrimSpace(s)
33+
if s == "" || !displayTokenRe.MatchString(s) {
34+
return placeholder
35+
}
36+
return s
37+
}
38+
1639
//go:embed templates/payment_required.html
1740
var paymentRequiredHTMLSrc string
1841

@@ -229,7 +252,7 @@ func sendPaymentRequiredHTML(w http.ResponseWriter, r *http.Request, requirement
229252
NetworkLabel: networkLabel,
230253
PriceDisplay: priceDisplay,
231254
PayToDisplay: payToDisplay,
232-
PayToFull: payToFull,
255+
PayToFull: payToFull,
233256
ExplorerURL: display.ExplorerURL,
234257
OfferDescription: display.OfferDescription,
235258
Skills: display.AgentSkills,
@@ -347,14 +370,8 @@ func normalizeOfferType(t string) string {
347370
// raw-JSON paths, but reframed so users understand they're buying remote
348371
// model time, not an agent with tools/memory.
349372
func inferenceCopy(url string, d PaymentDisplay) typeCopy {
350-
model := strings.TrimSpace(d.Model)
351-
if model == "" {
352-
model = "<model-id>"
353-
}
354-
name := strings.TrimSpace(d.OfferName)
355-
if name == "" {
356-
name = "remote-inference"
357-
}
373+
model := sanitizeDisplayToken(d.Model, "<model-id>")
374+
name := sanitizeDisplayToken(d.OfferName, "remote-inference")
358375

359376
cmd := fmt.Sprintf(
360377
"obol buy inference %s \\\n --seller %s \\\n --model %s \\\n --budget 1 \\\n --no-verify-identity",
@@ -397,7 +414,7 @@ func inferenceCopy(url string, d PaymentDisplay) typeCopy {
397414
// example sits next to the raw x402 JSON in the Pay-manually card to
398415
// make the wire shape obvious to readers walking the spec by hand.
399416
func agentCopy(url string, d PaymentDisplay) typeCopy {
400-
model := strings.TrimSpace(d.Model)
417+
model := sanitizeDisplayToken(d.Model, "")
401418
modelClause := ""
402419
modelLine := ""
403420
if model != "" {

internal/x402/paymentrequired_test.go

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func TestHTMLAware_DegradeWithoutDisplay(t *testing.T) {
173173
}
174174
body := w.Body.String()
175175
mustContain(t, body, "Payment required")
176-
mustContain(t, body, "/anything") // endpoint falls back to URL.Path
176+
mustContain(t, body, "/anything") // endpoint falls back to URL.Path
177177
mustContain(t, body, "1000 (atomic units)") // price falls back to atomic units
178178
}
179179

@@ -367,3 +367,55 @@ func mustContain(t *testing.T, haystack, needle string) {
367367
t.Errorf("body does not contain %q", needle)
368368
}
369369
}
370+
371+
// sanitizeDisplayToken must pass real model ids / offer names through
372+
// untouched while collapsing anything carrying shell metacharacters to the
373+
// placeholder — the values land in copy-pasteable commands on the public
374+
// 402 page.
375+
func TestSanitizeDisplayToken(t *testing.T) {
376+
const ph = "<model-id>"
377+
cases := []struct {
378+
in string
379+
want string
380+
}{
381+
{"qwen3.5:9b", "qwen3.5:9b"},
382+
{"AEON-7", "AEON-7"},
383+
{"anthropic/claude-3-5-sonnet-latest", "anthropic/claude-3-5-sonnet-latest"},
384+
{"gpt-5.4", "gpt-5.4"},
385+
{" qwen3.5:9b ", "qwen3.5:9b"}, // trimmed
386+
{"", ph},
387+
{" ", ph},
388+
{"x; rm -rf ~", ph},
389+
{"$(whoami)", ph},
390+
{"a`id`b", ph},
391+
{"a && b", ph},
392+
{"a|b", ph},
393+
{"a$b", ph},
394+
{"a\nb", ph},
395+
{`a"b`, ph},
396+
}
397+
for _, c := range cases {
398+
if got := sanitizeDisplayToken(c.in, ph); got != c.want {
399+
t.Errorf("sanitizeDisplayToken(%q) = %q, want %q", c.in, got, c.want)
400+
}
401+
}
402+
}
403+
404+
// A hostile ServiceOffer must never get its raw spec.model.name /
405+
// metadata.name reflected into the rendered copy-paste command.
406+
func TestInferenceCopy_StripsShellMetacharsFromCommand(t *testing.T) {
407+
d := PaymentDisplay{
408+
OfferType: "inference",
409+
Model: "x; rm -rf ~",
410+
OfferName: "a && curl evil",
411+
}
412+
c := inferenceCopy("https://agent.example.tunnel.dev/services/x", d)
413+
for _, bad := range []string{"rm -rf", "&&", "curl evil", ";"} {
414+
if strings.Contains(c.PrimaryPayload, bad) {
415+
t.Fatalf("hostile token leaked into command payload %q (contains %q)", c.PrimaryPayload, bad)
416+
}
417+
}
418+
// Falls back to the safe placeholders instead.
419+
mustContain(t, c.PrimaryPayload, "--model <model-id>")
420+
mustContain(t, c.PrimaryPayload, "obol buy inference remote-inference")
421+
}

0 commit comments

Comments
 (0)