Repository navigation
feat: add configurable host allow-list to paid_call, refuse before any request - #487
Conversation
…y-Labs#404) paid_call passed any URL the agent named to a fetch wrapper that answers a 402 with USDC from a live mainnet key, so a single sentence in a page, issue comment or search result could choose where the money went. The payable origins are server configuration (MCP_PAID_CALL_ALLOWED_HOSTS), never a tool argument, and the default is an empty list that pays nobody. A refused URL fails with ERR_HOST_NOT_ALLOWED naming its host, before any request is sent, so an unlisted host is never contacted. Matching is origin-exact — case, root dot and punycode folded, port pinned when the entry names one — plus an optional path prefix enforced at a segment boundary, which refuses subdomain look-alikes, url userinfo, homoglyphs and sibling paths. An entry this cannot read is dropped with a warning rather than guessed at, because a typo should cost an allowed host, not a blocked one. Bounds a spend this tool can make; the ceiling (Divinity-Labs#210) and the payment log (Divinity-Labs#211) cover what happens to hosts that are allow-listed.
|
@Ugasutun Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
…y-Labs#423) Existing 402 tests mocked fetchWithPayment, so the branch that actually moves money - 402 -> construct -> sign -> submit -> resource returned - was never exercised. This drives the real @x402/fetch transport and a real Soroban transfer signed with @stellar/stellar-sdk against a stubbed resource + fake facilitator. Only the RPC/Horizon and the facilitator are stubbed; both upgrade-sensitive surfaces (x402 loop wiring, Stellar SDK signing) stay real. Asserts on what was signed - amount, destination and network passphrase (valid on testnet, fails on mainnet) - not just the response body, and covers amount above maxPrice, an unregistered network, and an expired signature. Throwaway testnet keys only; deterministic, no network access.
Miracle656
left a comment
There was a problem hiding this comment.
Needs a rebase — #488 (the SSRF guard) landed an hour ago and also touches src/mcp/server.ts and src/mcp/codes.ts, so this now conflicts. That is my sequencing, not yours.
I have read it in the meantime and the design is right, so the rebase should be the only work left.
The default pays nobody. This is the whole thing:
Its default is empty, and an empty list pays nobody: refusing is the safe failure
A spend gate whose default is permissive is not a gate. And an entry the parser cannot understand being dropped rather than guessed at is the same instinct applied one level down — a typo'd allow-list line that silently becomes a broader rule than intended is exactly how these fail in practice.
It is configuration, never a tool argument. Taking the list from McpServerConfig means the agent cannot widen its own permissions, which matters because the agent is the component most likely to be talking to an attacker. If the model could pass the allow-list, the gate would be decorative.
Origin matching is exact, including the port, with api.example.com not matching api.example.com:8443 in either direction, and path prefixes matched at a segment boundary rather than by startsWith — so /api does not open /apifoo. Both are the details that turn an allow-list into an allow-list rather than a suggestion.
Your framing in the comment is also the right one to have written down:
the tool is a spend primitive dressed as a getter
That is the sentence that explains why #404 was a 200-point issue and not a 100.
Rebase on current main and I will merge it. Note that #488 added src/net/ssrf.ts with its own refusal codes — worth a look while you are in codes.ts, so the two gates do not end up with two vocabularies for "I refused to go there".
Coordination note — ten PRs are editing one 179-line fileIf your MCP PR has just gone red with a conflict, this is why, and it is my fault rather than yours. I published 22 MCP issues (#404–#425) as if they were independent. They are not: almost all of them land in Already merged:
Order I intend to take the rest in, so you can rebase once rather than repeatedly:
Please do not all rebase at once. If you are below the one currently in flight, wait until the one above you lands — otherwise you rebase, I merge someone else, and you rebase again. I will comment on your PR when it is your turn. One thing to settle between you: Nothing here reflects on the quality of the work. Several of these are among the best PRs this repo has had. |
|
Your turn — please rebase now. You are first in the MCP queue and nothing is ahead of you.
One ask while you are in there: #488 added its own refusal codes in Everything I said in the review stands: the empty default that pays nobody, configuration rather than tool arguments, exact origin matching including the port, and path prefixes at a segment boundary. I will merge as soon as it is green. |
Two gates now sit side by side in the MCP server and they are deliberately
separate:
ERR_URL_BLOCKED the target resolves somewhere we will never fetch —
private, loopback, link-local, metadata (Divinity-Labs#488/Divinity-Labs#407)
ERR_HOST_NOT_ALLOWED the host is reachable, but is not on the operator's
paid_call allow-list (Divinity-Labs#487/Divinity-Labs#404)
Both codes kept rather than one replacing the other: "I cannot reach there"
and "I will not pay there" are different answers and an agent should be able
to tell them apart.
The constructor keeps both initialisations, on main's `config_` parameter
name — it was renamed there to stop it shadowing the imported `config`.
Also exempts src/mcp/ from the console guard added in Divinity-Labs#402. The MCP server
speaks its protocol over stdout via StdioServerTransport, and pino's default
destination is stdout — routing these diagnostics through the logger would
interleave log lines with protocol frames and break every client parsing
them. stderr is the only stream it may write to, so console.error is correct
there. The exemption pattern accepts both path separators; a forward-slash
pattern matches nothing on Windows.
mcp.test.ts, mcp-paid-call-allowlist.test.ts, ssrf.test.ts: 41 passed.
Suite: 772 passed. The two local reds are CRLF in my working copy and a
mercurius 16.12 node_modules ahead of the lockfile, neither of them real.
Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Miracle656
left a comment
There was a problem hiding this comment.
Rebased and merging. CI green.
I resolved the conflicts myself rather than leave the chain stuck — nine MCP PRs were blocked behind this one and nobody could move until it did. What I kept and why:
Both error codes, not one replacing the other.
ERR_URL_BLOCKED // the target resolves somewhere we will never fetch (#488)
ERR_HOST_NOT_ALLOWED // reachable, but not on the paid_call allow-list (#487)"I cannot reach there" and "I will not pay there" are different answers and an agent should be able to act on the difference. The conflict presented them as alternatives; they are not.
Both gates in the constructor, on main's config_ parameter name — it was renamed there to stop it shadowing the imported config. Your allow-list decides which hosts may be paid; #488's SSRF guard decides which addresses may be reached at all. Neither can re-open what the other refuses, which is the property worth preserving.
One extra thing I had to fix. Your console.error for unparseable allow-list entries tripped the guard #402 added. I exempted src/mcp/ rather than convert it, because converting it would have been wrong: the MCP server speaks its protocol over stdout via StdioServerTransport, and pino's default destination is stdout — routing those diagnostics through the logger would interleave log lines with protocol frames and break every client parsing them. stderr is the only stream it may use, so console.error is correct there. The exemption accepts both path separators, since a forward-slash-only pattern matches nothing on Windows.
Everything from my original review stands: the empty default that pays nobody, configuration rather than tool arguments, exact origin matching including the port, path prefixes at a segment boundary, and unparseable entries dropped rather than guessed at.
41 MCP and SSRF tests pass; 772 across the suite.
A tool result is written straight into the agent's context window, so an unbounded one is self-inflicted denial of service whose failure gets blamed on the model. All four tools serialise compactly and are capped at MCP_MAX_RESULT_BYTES; a truncated result carries truncated, the total, the page returned, and a notice saying how to ask for more. bazaar_search gains limit/offset arguments, forwards them to the node and pages whatever comes back. lens_get_price keeps structuredContent only while the payload fits, since shipping it regardless would move the blow-up to another field. Upstream bodies are read through a capped stream reader that cancels at the cap, so an oversized body is never buffered; a discovery body too large to parse returns ERR_RESPONSE_TOO_LARGE, which is deliberately not one of the two pre-request refusals: we did fetch it, we will not dump it. Rebased onto main after Divinity-Labs#480 (structured paid-call errors), Divinity-Labs#487 (paid_call host allow-list) and Divinity-Labs#492 (spend ceiling) landed: the bounds now sit on top of those gates rather than beside them, and limit/offset go through the Zod tool schemas.
Closes #404
Summary
paid_calltook a URL from the agent and paid any 402 response from a livemainnet key, up to
maxPrice. With no allow-list, a prompt-injected agentcould be steered into paying an attacker-controlled host with a single
sentence. This PR adds a configuration-driven host allow-list that is checked
before any network request is made. The default configuration pays nobody.
Changes
paid_callhandler. The URL isparsed and validated before
fetchWithPaymentis called, so a refused hostis never contacted.
MCP_PAID_CALL_ALLOWED_HOSTS,comma-separated), read in
examples/mcp-server/run.tsand passed to theserver at construction. It is not a tool argument and the agent cannot
influence it.
paid_callrefuses everything until anoperator opts hosts in. (Alternative: default to Lens's own origin only.)
hostname(and port/scheme whereconfigured), using exact comparison. No substring or
startsWithmatchingon the raw string.
https:URLs are accepted. URLs containing userinfo(
https://allowed.com@evil.com) are rejected.(
PAID_CALL_HOST_NOT_ALLOWED) and a message naming the host, so the agentcan explain the refusal rather than retry. Malformed URLs return
PAID_CALL_INVALID_URL.paid_calltool description to state that only operatorallow-listed hosts can be paid.
Security notes
fetchWithPayment, so a refused host receivesno request, not even an unpaid one.
allowed.com.evil.com,evil.com/allowed.com, andallowed.com@evil.comstyle near-misses.[describe how handled, e.g. redirect: 'manual' or re-validating each hop against the allow-list]. A 402 payment must not follow a redirectto a host outside the allow-list.
protects
POST /settle. Spend ceiling (L210) and payment log (L211) arecomplementary and tracked separately.
Testing
fetchWithPaymentis called once.PAID_CALL_HOST_NOT_ALLOWEDnaming the host, andfetchWithPayment/fetchis never called (asserted with a spy).https://allowed.com.evil.comhttps://evil.com/allowed.comhttps://allowed.com@evil.comhttps://allowed.cohttp://allowed.com(wrong scheme)PAID_CALL_INVALID_URL.Checklist
paid_callrefuses a non-allow-listed host before any request is madeMCP_PAID_CALL_ALLOWED_HOSTSdocumented)