Skip to content
Merged
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
10 changes: 7 additions & 3 deletions internal/serviceoffercontroller/assets/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,9 @@ <h1>{{.Title}}</h1>
],
}];
async function withdraw() {
if (!burner || !wallet || balance <= 0n || withdrawing) return;
// busy = a paid message is mid-flight and may be about to spend this same
// balance via payFetch; withdrawing itself blocks a send below.
if (!burner || !wallet || balance <= 0n || withdrawing || busy) return;
withdrawing = true;
const amt = balance;
try {
Expand Down Expand Up @@ -339,11 +341,13 @@ <h1>{{.Title}}</h1>
const r = sig.slice(0, 66);
const s = "0x" + sig.slice(66, 130);
const v = parseInt(sig.slice(130, 132), 16);
await wallet.writeContract({
const hash = await wallet.writeContract({
account: mainAddr, chain, address: asset, abi: TWA_ABI,
functionName: "transferWithAuthorization",
args: [burner.address, mainAddr, amt, 0n, validBefore, nonce, v, r, s],
});
status("withdrawal sent, waiting for confirmation…", true);
await pub.waitForTransactionReceipt({ hash });
say("sys", "Withdrew $" + formatUnits(amt, 6) + " to " + short(mainAddr));
await refreshBalance();
status("");
Expand Down Expand Up @@ -372,7 +376,7 @@ <h1>{{.Title}}</h1>
ev.preventDefault();
const ta = $("input");
const text = ta?.value.trim();
if (!text || !payFetch || busy) return;
if (!text || !payFetch || busy || withdrawing) return;
busy = true; ta.value = ""; ta.disabled = true;
say("user", text);
messages.push({ role: "user", content: text });
Expand Down
37 changes: 37 additions & 0 deletions internal/serviceoffercontroller/hostoffer_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package serviceoffercontroller

import (
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"testing"

Expand Down Expand Up @@ -317,6 +319,27 @@ func TestBuildHostHTTPRoute_AgentChatWidget(t *testing.T) {
}
}

// /chat holds a hot session key and signs USDC transfers — it must carry
// frame-ancestors 'self' so it can't be clickjacked into a cross-origin
// iframe (the offer's own landing page still embeds it same-origin).
chatRule := rules[4].(map[string]any)
var sawCSP bool
for _, rawFilter := range chatRule["filters"].([]any) {
filter := rawFilter.(map[string]any)
if filter["type"] != "ResponseHeaderModifier" {
continue
}
for _, s := range filter["responseHeaderModifier"].(map[string]any)["set"].([]any) {
h := s.(map[string]any)
if h["name"] == "Content-Security-Policy" && h["value"] == "frame-ancestors 'self'" {
sawCSP = true
}
}
}
if !sawCSP {
t.Errorf("/chat rule missing Content-Security-Policy: frame-ancestors 'self'")
}

// Catch-all must still be last.
last := rules[6].(map[string]any)
match := last["matches"].([]any)[0].(map[string]any)["path"].(map[string]any)
Expand Down Expand Up @@ -401,6 +424,20 @@ func TestStaticSiteServesChatWidget(t *testing.T) {
}
}

// TestChatVendorVersionMatchesBundle guards the ?v= cache-buster on
// chat.html's chat-vendor.js import against a forgotten bump: the bundle is
// served 1-year immutable (buildHostHTTPRoute's exactToShared), so a rebuild
// that forgets to bump ?v= would silently serve returning visitors the OLD
// payment-signing bundle for up to a year. This must fail CI whenever
// assets/chat-vendor.js and assets/chat.html's ?v= go out of sync.
func TestChatVendorVersionMatchesBundle(t *testing.T) {
sum := sha256.Sum256([]byte(chatWidgetVendorJS))
want := "chat-vendor.js?v=" + fmt.Sprintf("%x", sum)[:8]
if !strings.Contains(chatWidgetTmplSrc, want) {
t.Fatalf("chat.html's chat-vendor.js ?v= does not match sha256(assets/chat-vendor.js); want %q (see assets/README.md rebuild steps)", want)
}
}

// TestHostRouteDiscoveryRulesAreGETOnly pins the method scoping: a
// root-priced offer advertises POST <origin>/ as its paid resource, so the
// Exact "/" discovery rule must only capture GETs — POSTs fall through to
Expand Down
26 changes: 25 additions & 1 deletion internal/serviceoffercontroller/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -940,8 +940,15 @@ func hostRouteRules(offer *monetizeapi.ServiceOffer, exactTo, exactToShared func
exactTo("/.well-known/agent-registration.json", "agent-registration.json"),
}
if offer.IsAgent() {
// The chat page holds a hot session key and signs USDC transfers —
// frame-ancestors 'self' stops it being clickjacked into a
// cross-origin iframe (Connect/Fund/Withdraw/Send). The offer's own
// landing page embeds it same-origin (TestOfferLandingChatEmbed), so
// that legitimate embed still works.
chatRule := exactTo("/chat", "chat.html")
addResponseHeader(chatRule, "Content-Security-Policy", "frame-ancestors 'self'")
rules = append(rules,
exactTo("/chat", "chat.html"),
chatRule,
exactToShared("/chat-vendor.js", "chat-vendor.js"),
)
}
Expand All @@ -956,6 +963,23 @@ func hostRouteRules(offer *monetizeapi.ServiceOffer, exactTo, exactToShared func
})
}

// addResponseHeader appends a Set entry to a rule's existing
// ResponseHeaderModifier filter (every exactTo rule has exactly one, from
// cacheFilter) — Gateway API's core filters are unspecified/implementation
// -defined if repeated within one rule, so extra headers merge into the
// existing filter instead of adding a second one.
func addResponseHeader(rule map[string]any, name, value string) {
for _, f := range rule["filters"].([]any) {
fm := f.(map[string]any)
if fm["type"] != "ResponseHeaderModifier" {
continue
}
mod := fm["responseHeaderModifier"].(map[string]any)
mod["set"] = append(mod["set"].([]any), map[string]any{"name": name, "value": value})
return
}
}

// sharedOriginRule is the /services/<name> PathPrefix rule → verifier,
// with the protection middleware attached when spec.limits is set.
func sharedOriginRule(offer *monetizeapi.ServiceOffer) map[string]any {
Expand Down