Skip to content

Commit 97c14e3

Browse files
authored
Merge pull request #599 from ObolNetwork/fix/integration-593plus-review
review fixes for #598: Scalar SRI pin, buy-x402 guard order, 402 token sanitization
2 parents a38db07 + b39fe3c commit 97c14e3

4 files changed

Lines changed: 98 additions & 23 deletions

File tree

internal/embed/skills/buy-x402/scripts/buy.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,6 +1632,16 @@ def _set_agent_default_model(model_id, auto_refill):
16321632
file=sys.stderr,
16331633
)
16341634
return False
1635+
# Existence guard first: if we're going to refuse anyway, don't emit the
1636+
# auto-refill warning below — it describes a primary-model failure mode
1637+
# that can't happen when the default was never switched.
1638+
if not _litellm_has_model(alias):
1639+
print(
1640+
f" Refusing --set-default: {alias!r} is not selectable in LiteLLM; "
1641+
f"leaving the agent default unchanged.",
1642+
file=sys.stderr,
1643+
)
1644+
return False
16351645
# Safety: a paid primary model bricks chat once the pre-signed pool empties.
16361646
if not (auto_refill and auto_refill.get("enabled")):
16371647
print(
@@ -1646,14 +1656,6 @@ def _set_agent_default_model(model_id, auto_refill):
16461656
" Re-run with --auto-refill, or run 'process --all' on a schedule.",
16471657
file=sys.stderr,
16481658
)
1649-
# Existence guard: never point the agent at an unpublished model.
1650-
if not _litellm_has_model(alias):
1651-
print(
1652-
f" Refusing --set-default: {alias!r} is not selectable in LiteLLM; "
1653-
f"leaving the agent default unchanged.",
1654-
file=sys.stderr,
1655-
)
1656-
return False
16571659
# Primary path: native Hermes writer (atomic; per-request re-read, no restart).
16581660
hermes_bin = _find_hermes_bin()
16591661
if hermes_bin:

internal/serviceoffercontroller/scalar_html.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ package serviceoffercontroller
66
const scalarBundleVersion = "1.34.0"
77

88
// scalarBundleSRI is the Subresource Integrity hash for the pinned bundle.
9-
// Left empty in phase 1 — the bundle still loads, browsers just skip the
10-
// integrity check. Populate by running:
9+
// The /api page is served over the public tunnel, so the third-party Scalar
10+
// JS it pulls from jsdelivr must be integrity-checked: without this the
11+
// browser executes whatever the CDN returns, unverified. Re-derive on every
12+
// version bump (Renovate touches scalarBundleVersion above) by running:
1113
//
1214
// curl -sL https://cdn.jsdelivr.net/npm/@scalar/api-reference@<version> \
1315
// | openssl dgst -sha384 -binary | base64
1416
//
15-
// and prefixing the result with `sha384-`. Re-derive on every version bump.
16-
const scalarBundleSRI = ""
17+
// and prefixing the result with `sha384-`. The hash is taken over the exact
18+
// (jsdelivr-minified) bytes that the pinned URL serves; it must be refreshed
19+
// in lockstep with scalarBundleVersion or the browser will block the script.
20+
const scalarBundleSRI = "sha384-tNJHhVh8smfB4VJcBxQf3Q0Soj15UqqyVJ6Q6OTwqGVEyxy57gfDLo7DGcSclH7I"
1721

1822
// scalarHTML returns the static HTML shell served at /api. It loads the
1923
// pinned @scalar/api-reference bundle from jsdelivr, points it at the

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)