diff --git a/CHANGELOG.md b/CHANGELOG.md index 821a6c0..d6e9156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ All notable changes to BlockRun MCP will be documented in this file. +## 0.39.1 + +Closes the three money-path holes 0.38.1 documented as "known and unfixed" — +all in `blockrun_video`, all pre-existing, all now pinned by +`test/video-money-path.test.ts`. + +- **`fix(budget)` — an unreadable 402 amount aborts BEFORE signing.** When the + quote's `amount` failed to parse (missing, non-numeric, non-positive), the + old path skipped the re-reserve, signed a payment authorization for the raw + unvalidated value, and booked only the estimate — the last remaining way + past the budget cap. Now: fail closed with "no charge was made", nothing + signed, reservation fully released. Never sign what you could not read. + +- **`fix(ssrf)` — `image_url` / `last_frame_url` get the guard `blockrun_image` + already had.** Scheme check (zod's `.url()` accepts `file://` et al.) plus + resolved-host check via `isBlockedFetchHostResolved` — resolved, not + literal, so wildcard-DNS names like `127.0.0.1.nip.io` are caught. This + process never fetches these URLs (the gateway's fetcher does), so the guard + is defense-in-depth plus a saved round trip: a private/metadata address was + previously forwarded, quoted, and paid for before failing server-side. + +- **`fix(budget)` — a malformed "completed" poll can no longer un-book a real + charge.** Settlement happens server-side on the first poll the gateway + answers with `status:"completed"` — the USDC is gone the moment the client + observes it. The old path validated the payload first: a completed body with + no clip URL threw, the catch returned an error, and `finally` released the + reservation — a real charge the ledger never saw, silently raising the cap + by the lost amount. The spend is now booked at the instant "completed" is + observed, before any payload validation; the caller still gets the error. + + The first draft of this fix double-booked the happy path (poll site AND the + old tail call) — caught by the new test's exactly-once assertion, which is + why it asserts on the ledger, not on the error text. + +309 tests pass. + ## 0.39.0 The Seedance capability tables, re-derived from token360's OWN published diff --git a/package-lock.json b/package-lock.json index 87531a2..a7e1814 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/mcp", - "version": "0.39.0", + "version": "0.39.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/mcp", - "version": "0.39.0", + "version": "0.39.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/package.json b/package.json index ef756b1..e1be31e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.39.0", + "version": "0.39.1", "mcpName": "io.github.BlockRunAI/blockrun-mcp", "description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Paid via x402 micropayments.", "type": "module", diff --git a/src/tools/video.ts b/src/tools/video.ts index 4c0f961..d41af6c 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -9,6 +9,7 @@ import { launchTopUp } from "../utils/onramp.js"; import { fetchWithTimeout, isTimeoutError } from "../utils/http.js"; import type { BudgetState } from "../types.js"; import { getChain, getOrCreateWalletKey } from "../utils/wallet.js"; +import { isBlockedFetchHostResolved } from "../utils/ssrf.js"; import { privateKeyToAccount } from "viem/accounts"; import { createPaymentPayload, @@ -275,6 +276,32 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC } } + // SSRF guard on caller-supplied URLs, mirroring blockrun_image + // (src/tools/image.ts). This process never fetches these URLs — the + // GATEWAY's fetcher does — so this is defense-in-depth plus a saved + // round trip: a URL pointing at localhost / the metadata endpoint / + // the private network was previously forwarded, quoted, and PAID for + // before failing (or worse, succeeding) server-side. Resolved, not + // literal: wildcard-DNS names like 127.0.0.1.nip.io are public strings + // that map to private addresses. zod's .url() accepts any scheme, so + // file:// etc. are rejected here too. + for (const [name, value] of [["image_url", image_url], ["last_frame_url", last_frame_url]] as const) { + if (!value) continue; + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { + content: [{ type: "text", text: formatError(`${name} must be an http(s) URL — got scheme "${parsed.protocol}"`) }], + isError: true, + }; + } + if (await isBlockedFetchHostResolved(parsed.hostname)) { + return { + content: [{ type: "text", text: formatError(`${name} resolves to a private/loopback/link-local address (${parsed.hostname}) — refusing to forward it to the gateway.`) }], + isError: true, + }; + } + } + // Resolution ceilings, per model. Only Seedance // is checked: Sora and Grok bill per second and ignore the parameter, // which is what the schema promises — so for them it is DROPPED from the @@ -356,13 +383,24 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC // 1080p/4K can far exceed the per-second table estimate). Book THIS, not // the estimate, so the budget cap reflects what was actually settled. const settledUsd = amountToUsd(details.amount); + // FAIL CLOSED on a quote we cannot price. amountToUsd returns null for + // a missing / non-numeric / non-positive amount — and the old path then + // SKIPPED the re-reserve, signed a payment for the raw unvalidated + // amount, and booked only the estimate: the last remaining way past the + // budget cap. Never sign what we could not read. + if (settledUsd === null) { + return { + content: [{ type: "text", text: formatError(`The gateway's 402 quote carried an unreadable amount (${JSON.stringify(details.amount)}). Refusing to sign a payment for an amount that could not be validated — no charge was made. This is a gateway fault; retry, and report it if it persists.`) }], + isError: true, + }; + } // The 402 carries the REAL price; Seedance/Sora are token-priced, so a // 1080p/4K render can far exceed the per-second estimate reserved at // the gate. Re-reserve against the cap BEFORE paying so a single high-res // call can't settle past the budget (and concurrent jobs hold the true // amount, not the low estimate, for the whole polling window). - if (settledUsd !== null && settledUsd > estimatedCost) { + if (settledUsd > estimatedCost) { gate?.release(); gate = reserveBudget(budget, agent_id, settledUsd); if (!gate.allowed) { @@ -425,6 +463,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC const startedAt = Date.now(); let lastStatus = submitData.status || "queued"; + let spendBooked = false; let completed: { url: string; source_url?: string; @@ -458,6 +497,20 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC lastStatus = pollData.status || lastStatus; + // Settlement happens SERVER-SIDE on the first poll the gateway + // answers with status "completed" — the USDC is gone the moment we + // observe it, regardless of what the rest of the payload looks like. + // Book immediately: the old path validated the payload first, so a + // malformed completed body threw, the catch returned an error, and + // finally released the reservation — a real charge the ledger never + // saw, silently raising the cap by the lost amount. + if (lastStatus === "completed" && !spendBooked) { + // Backstop only — every reachable path here has already booked at the + // poll site the moment "completed" was observed. + if (!spendBooked) recordActualSpend(budget, settledUsd, estimatedCost, agent_id); + spendBooked = true; + } + if (pollResp.status === 202 && (lastStatus === "queued" || lastStatus === "in_progress")) { continue; } @@ -506,7 +559,9 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC ...(completed.request_id ? [`Request ID: ${completed.request_id}`] : []), ...(completed.txHash ? [`Tx: ${completed.txHash}`] : []), ]; - recordActualSpend(budget, settledUsd, estimatedCost, agent_id); + // Backstop only — every reachable path here has already booked at the + // poll site the moment "completed" was observed. + if (!spendBooked) recordActualSpend(budget, settledUsd, estimatedCost, agent_id); return { content: [{ type: "text", text: lines.join("\n") }], diff --git a/test/video-money-path.test.ts b/test/video-money-path.test.ts new file mode 100644 index 0000000..208fe81 --- /dev/null +++ b/test/video-money-path.test.ts @@ -0,0 +1,169 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// Pins the three money-path holes the 0.38.1 review documented as "known and +// unfixed", now fixed: +// +// 1. An unquotable 402 amount must abort BEFORE any payment is signed — the +// old path skipped the re-reserve, signed the raw unvalidated amount, and +// booked only the estimate: the last remaining way past the budget cap. +// 2. image_url / last_frame_url get the same SSRF guard blockrun_image has — +// scheme check plus resolved-host check, BEFORE any network call, so a +// private/metadata address is never forwarded to (or paid for at) the +// gateway. +// 3. Settlement happens server-side on the first "completed" poll. The spend +// must be booked the moment that status is observed — a malformed +// completed payload used to throw first, releasing the reservation while +// the USDC was already gone. +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import type { BudgetState } from "../src/types.js"; + +const TEST_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; + +function headers(map: Record) { + const lower: Record = {}; + for (const [k, v] of Object.entries(map)) lower[k.toLowerCase()] = v; + return { get: (name: string) => lower[name.toLowerCase()] ?? null }; +} + +// Scriptable fetch: each test sets `script` to the remaining responses; every +// call shifts one. Empty script = the test expected NO network call. +let script: Array<() => unknown> = []; +let fetchCalls = 0; +mock.module("../src/utils/http.js", { + namedExports: { + fetchWithTimeout: async () => { + fetchCalls++; + const next = script.shift(); + if (!next) throw new Error("UNEXPECTED_NETWORK_CALL"); + return next(); + }, + isTimeoutError: () => false, + }, +}); +mock.module("../src/utils/wallet.js", { + namedExports: { + getChain: () => "base", + getOrCreateWalletKey: () => TEST_KEY, + getWalletInfo: async () => ({ address: "0xTEST" }), + }, +}); +// Hostname-keyed, no DNS: the real resolver is covered by ssrf.test.ts; here we +// only need "this hostname is private" to be decidable offline. +mock.module("../src/utils/ssrf.js", { + namedExports: { + isBlockedFetchHostResolved: async (hostname: string) => + hostname === "169.254.169.254" || hostname === "127.0.0.1.nip.io", + isBlockedFetchHost: () => false, + }, +}); +// Scriptable 402 amount + a payment-signing tripwire. +let quotedAmount: unknown = "400000"; +let paymentsSigned = 0; +mock.module("@blockrun/llm", { + namedExports: { + createPaymentPayload: async () => { paymentsSigned++; return "0xpaymentpayloadmock"; }, + parsePaymentRequired: () => ({}), + extractPaymentDetails: () => ({ + amount: quotedAmount, + recipient: "0x0000000000000000000000000000000000000001", + network: "eip155:8453", + resource: { url: "https://blockrun.ai/api/v1/videos/generations", description: "BlockRun Video Generation" }, + maxTimeoutSeconds: 600, + extra: {}, + }), + }, +}); + +const { registerVideoTool } = await import("../src/tools/video.js"); + +function makeHarness() { + let handler: ((args: Record) => Promise) | undefined; + const server = { + registerTool: (_n: string, _c: unknown, h: any) => { handler = h; }, + server: { getClientCapabilities: () => ({}) }, + } as any; + const budget: BudgetState = { limit: null, spent: 0, calls: 0, agents: new Map() }; + registerVideoTool(server, budget); + return { call: (args: Record) => handler!(args), budget }; +} + +const resp402 = () => ({ status: 402, ok: false, headers: headers({ "payment-required": "x402 base ..." }), json: async () => ({}) }); +const respSubmit = () => ({ status: 202, ok: false, headers: headers({}), json: async () => ({ id: "vid_1", poll_url: "/api/v1/videos/poll/vid_1", status: "queued" }) }); +const respPoll = (body: unknown) => ({ status: 200, ok: true, headers: headers({}), json: async () => body }); + +test("SSRF: non-http(s) schemes and private-resolving hosts are refused before ANY network call", async () => { + for (const args of [ + { image_url: "file:///etc/passwd" }, + { image_url: "http://169.254.169.254/latest/meta-data/" }, + { image_url: "https://127.0.0.1.nip.io/a.png" }, + { image_url: "https://ok.example.com/a.png", last_frame_url: "http://169.254.169.254/b.png" }, + ]) { + script = []; fetchCalls = 0; + const { call, budget } = makeHarness(); + const res = await call({ prompt: "a cube", model: "bytedance/seedance-2.0", ...args }); + const text = res.content.map((c: any) => c.text).join("\n"); + assert.equal(res.isError, true, text); + assert.match(text, /http\(s\) URL|private\/loopback\/link-local/); + assert.equal(fetchCalls, 0, `network was reached for ${JSON.stringify(args)}`); + assert.equal(budget.spent, 0); + } +}); + +test("SSRF: a public https seed image still goes through", async () => { + script = [resp402, respSubmit, () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 5 }] })]; + quotedAmount = "400000"; + const { call } = makeHarness(); + const res = await call({ prompt: "a cube", model: "bytedance/seedance-2.0", image_url: "https://ok.example.com/a.png" }); + assert.notEqual(res.isError, true, res.content?.[0]?.text); +}); + +test("an unreadable 402 amount aborts BEFORE signing — nothing signed, nothing booked", async () => { + for (const bad of ["garbage", "", "-5", 0, undefined, {}]) { + script = [resp402]; fetchCalls = 0; paymentsSigned = 0; + quotedAmount = bad; + const { call, budget } = makeHarness(); + const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" }); + const text = res.content.map((c: any) => c.text).join("\n"); + assert.equal(res.isError, true, text); + assert.match(text, /unreadable amount/); + assert.match(text, /no charge was made/); + assert.equal(paymentsSigned, 0, `signed a payment for amount ${JSON.stringify(bad)}`); + assert.equal(fetchCalls, 1, "must stop after the quote — no paid submit"); + assert.equal(budget.spent, 0, "reservation must be fully released"); + } + quotedAmount = "400000"; +}); + +test("a malformed completed poll still BOOKS the settled spend (the money already moved)", async () => { + // Poll answers completed with an empty data[] — the old path threw on the + // missing URL, the catch returned an error, and finally released the + // reservation: $0.40 gone on-chain, $0 in the ledger. + script = [resp402, respSubmit, () => respPoll({ status: "completed", data: [] })]; + quotedAmount = "400000"; + const { call, budget } = makeHarness(); + const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" }); + const text = res.content.map((c: any) => c.text).join("\n"); + assert.equal(res.isError, true, "a payload with no clip URL is still an error for the caller"); + assert.match(text, /missing video URL/); + assert.ok(Math.abs(budget.spent - 0.4) < 1e-9, `settled charge must stay booked: spent=${budget.spent}`); +}); + +test("the happy path books the settled amount exactly once", async () => { + script = [resp402, respSubmit, () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 8 }] })]; + quotedAmount = "400000"; + const { call, budget } = makeHarness(); + const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" }); + assert.notEqual(res.isError, true, res.content?.[0]?.text); + assert.equal(res.structuredContent.cost_usd, 0.4); + assert.ok(Math.abs(budget.spent - 0.4) < 1e-9, `booked once, not twice: spent=${budget.spent}`); +}); + +test("upstream failure before completion books nothing (no charge per gateway contract)", async () => { + script = [resp402, respSubmit, () => respPoll({ status: "failed", error: "render exploded" })]; + quotedAmount = "400000"; + const { call, budget } = makeHarness(); + const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" }); + assert.equal(res.isError, true); + assert.equal(budget.spent, 0, "failed jobs are not charged and must not be booked"); +});