agentwallet-mcp 1.10.0 → 1.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -12,7 +12,8 @@
12
12
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
- import { deriveX402Payment, isWithinCap } from './x402-payment.js';
15
+ import { assertPublicUrl, safeFetch } from './ssrf-guard.js';
16
+ import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, } from './x402-payment.js';
16
17
  import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
17
18
  import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
18
19
  /** True when either chain family is running non-custodially. */
@@ -186,6 +187,53 @@ async function api(path, method = 'GET', body, extraHeaders) {
186
187
  }
187
188
  return data;
188
189
  }
190
+ /**
191
+ * Resolve an asset's decimals from a source the paying party controls, never
192
+ * from the 402 response.
193
+ *
194
+ * Order: known-asset registry first (offline, works in local self-custody mode
195
+ * where there may be no reachable API), then the token contract's own
196
+ * decimals() via eth_call. If neither answers we refuse rather than guess,
197
+ * because guessing is exactly what let a hostile endpoint inflate the cap.
198
+ */
199
+ const decimalsCache = new Map();
200
+ async function resolveTrustedDecimals(chainId, token) {
201
+ const key = `${chainId}:${token.toLowerCase()}`;
202
+ const cached = decimalsCache.get(key);
203
+ if (typeof cached === 'number')
204
+ return cached;
205
+ const known = lookupTrustedDecimals(chainId, token);
206
+ if (known !== null) {
207
+ decimalsCache.set(key, known);
208
+ return known;
209
+ }
210
+ if (isSolanaChain(chainId)) {
211
+ throw new Error(`x402 blocked: cannot verify decimals for SPL mint ${token} on chain ${chainId}. ` +
212
+ `Unknown mints are refused because the payment cap cannot be checked without trusted decimals.`);
213
+ }
214
+ // decimals() selector
215
+ let onChain = null;
216
+ try {
217
+ const r = (await api('/eth-call', 'POST', {
218
+ chain_id: chainId, to: token, data: '0x313ce567',
219
+ }, { 'X-AGW-SKIP-X402': 'true' }));
220
+ const hex = (r?.result || '').replace(/^0x/, '');
221
+ if (hex && /^[0-9a-fA-F]+$/.test(hex)) {
222
+ const n = parseInt(hex, 16);
223
+ if (Number.isInteger(n) && n >= 0 && n <= 36)
224
+ onChain = n;
225
+ }
226
+ }
227
+ catch {
228
+ onChain = null; // fall through to the refusal below
229
+ }
230
+ if (onChain === null) {
231
+ throw new Error(`x402 blocked: could not resolve on-chain decimals for ${token} on chain ${chainId}. ` +
232
+ `The payment cap cannot be enforced without them, so the payment was refused.`);
233
+ }
234
+ decimalsCache.set(key, onChain);
235
+ return onChain;
236
+ }
189
237
  /**
190
238
  * Handle x402 auto-payment: pay on-chain, then retry the original request
191
239
  */
@@ -207,7 +255,11 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
207
255
  // 402 response cannot drain the wallet. AGENTWALLET_MAX_AUTOPAY is in
208
256
  // human-readable units of the asset; default 1.
209
257
  const maxAutopay = process.env.AGENTWALLET_MAX_AUTOPAY || '1';
210
- const { tokenAddress, rawAmount, decimals } = deriveX402Payment(accept, maxAutopay);
258
+ const assetForDecimals = accept.asset || accept.extra?.token || '';
259
+ const trustedDecimals = assetForDecimals
260
+ ? await resolveTrustedDecimals(chainId, assetForDecimals)
261
+ : 18; // native asset: value is already in wei-equivalent base units
262
+ const { tokenAddress, rawAmount, decimals } = deriveX402Payment(accept, maxAutopay, trustedDecimals);
211
263
  // Send payment using our wallet (with X-AGW-SKIP-X402 to prevent recursion)
212
264
  let txHash;
213
265
  try {
@@ -360,82 +412,82 @@ function resolveChainId(network) {
360
412
  // ─── Server ──────────────────────────────────────────────────────
361
413
  const server = new McpServer({
362
414
  name: 'agentwallet',
363
- version: '1.8.0',
415
+ version: '1.10.1',
364
416
  }, {
365
- instructions: `AgentWallet gives AI agents their own blockchain wallets. Private keys are encrypted server-side and never exposed — agents sign and broadcast transactions without ever touching raw keys.
366
-
367
- ## Getting Started
368
- 1. Call list_wallets to see existing wallets, or create_wallet to make a new one.
369
- 2. Fund the wallet by sending tokens to its address from an external source.
370
- 3. Use transfer (native tokens) or transfer_token (ERC-20/SPL tokens) to send funds.
371
-
372
- ## Supported Chains
373
- - **EVM chains:** Ethereum (1), Base (8453), Arbitrum (42161), Optimism (10), Polygon (137), Avalanche (43114), BSC (56), Zora (7777777), PulseChain (369)
374
- - **Solana:** Mainnet (900), Devnet (901)
375
- - Call get_chains for the full list with native tokens and stablecoin contract addresses.
376
-
377
- ## EVM vs Solana
378
- - **Addresses:** EVM uses 0x-prefixed hex (42 chars). Solana uses Base58 (32-44 chars).
379
- - **Native tokens:** EVM = ETH/POL/BNB/etc (18 decimals, wei). Solana = SOL (9 decimals, lamports).
380
- - **Token standards:** EVM uses ERC-20. Solana uses SPL tokens.
381
- - **EVM-only tools:** approve_token, get_allowance, wrap_eth, unwrap_eth, call_contract, get_token_info. These do not work on Solana.
382
- - **Works on both:** create_wallet, transfer, transfer_token, send_transaction, sign_transaction, get_balance, get_token_balance.
383
-
384
- ## Common Workflows
385
-
386
- ### Send native tokens (ETH, SOL, etc.)
387
- Use the transfer tool with a human-readable amount (e.g. "0.1" ETH). It auto-converts to wei/lamports.
388
-
389
- ### Send ERC-20 or SPL tokens
390
- Use transfer_token. You need: wallet_id, token contract address, recipient, amount, chain_id, and decimals (6 for USDC, 18 for most ERC-20s). Call get_chains to find stablecoin addresses.
391
-
392
- ### Interact with DeFi (EVM only)
393
- 1. Check allowance with get_allowance.
394
- 2. If needed, call approve_token to let the DeFi contract spend your tokens.
395
- 3. Use send_transaction with the contract's calldata to execute the interaction.
396
-
397
- ### Wrap/unwrap native tokens (EVM only)
398
- Use wrap_eth to convert ETH → WETH (required by many DeFi protocols). Use unwrap_eth to convert back.
399
-
400
- ### Read on-chain data (EVM only)
401
- Use call_contract for gas-free read-only calls (eth_call). Provide ABI-encoded calldata.
402
-
403
- ## x402 Payments — Paying for Resources
404
- The pay_x402 tool handles the full x402 payment flow automatically:
405
- 1. Fetches the URL.
406
- 2. If the server returns HTTP 402, parses the payment requirements.
407
- 3. Pays on-chain using your wallet.
408
- 4. Retries the request with proof of payment.
409
- Always set max_payment to prevent overspending (e.g. "1.00" for max 1 USDC).
410
-
411
- ## x402 Paywalls — Accepting Payments
412
- Create paywalls to charge other agents for accessing your resources:
413
- 1. create_paywall — set price, token, chain, and the resource URL to protect.
414
- 2. Share the access URL. Clients that pay on-chain get the resource; others get HTTP 402.
415
- 3. list_paywalls and get_paywall_payments to track revenue.
416
- 4. get_x402_revenue for aggregate stats across all paywalls.
417
-
418
- ## Wallet Security
419
- - Use pause_wallet to immediately freeze a wallet if compromised. No transactions can be signed while paused.
420
- - Use unpause_wallet to resume operations.
421
- - Use delete_wallet to permanently disable a wallet.
422
- - Wallets have server-enforced spending limits.
423
-
424
- ## Tool Selection Guide
425
- | Goal | Tool |
426
- |------|------|
427
- | Send ETH/SOL/native tokens | transfer |
428
- | Send USDC/ERC-20/SPL tokens | transfer_token |
429
- | Check native balance | get_balance |
430
- | Check token balance | get_token_balance |
431
- | Look up token details | get_token_info (EVM only) |
432
- | Approve DeFi spending | approve_token (EVM only) |
433
- | Check approval amount | get_allowance (EVM only) |
434
- | Read smart contract | call_contract (EVM only) |
435
- | Custom transaction | send_transaction |
436
- | Sign without broadcasting | sign_transaction |
437
- | Pay for x402 resource | pay_x402 |
438
- | Charge for your resource | create_paywall |
417
+ instructions: `AgentWallet gives AI agents their own blockchain wallets. Private keys are encrypted server-side and never exposed — agents sign and broadcast transactions without ever touching raw keys.
418
+
419
+ ## Getting Started
420
+ 1. Call list_wallets to see existing wallets, or create_wallet to make a new one.
421
+ 2. Fund the wallet by sending tokens to its address from an external source.
422
+ 3. Use transfer (native tokens) or transfer_token (ERC-20/SPL tokens) to send funds.
423
+
424
+ ## Supported Chains
425
+ - **EVM chains:** Ethereum (1), Base (8453), Arbitrum (42161), Optimism (10), Polygon (137), Avalanche (43114), BSC (56), Zora (7777777), PulseChain (369)
426
+ - **Solana:** Mainnet (900), Devnet (901)
427
+ - Call get_chains for the full list with native tokens and stablecoin contract addresses.
428
+
429
+ ## EVM vs Solana
430
+ - **Addresses:** EVM uses 0x-prefixed hex (42 chars). Solana uses Base58 (32-44 chars).
431
+ - **Native tokens:** EVM = ETH/POL/BNB/etc (18 decimals, wei). Solana = SOL (9 decimals, lamports).
432
+ - **Token standards:** EVM uses ERC-20. Solana uses SPL tokens.
433
+ - **EVM-only tools:** approve_token, get_allowance, wrap_eth, unwrap_eth, call_contract, get_token_info. These do not work on Solana.
434
+ - **Works on both:** create_wallet, transfer, transfer_token, send_transaction, sign_transaction, get_balance, get_token_balance.
435
+
436
+ ## Common Workflows
437
+
438
+ ### Send native tokens (ETH, SOL, etc.)
439
+ Use the transfer tool with a human-readable amount (e.g. "0.1" ETH). It auto-converts to wei/lamports.
440
+
441
+ ### Send ERC-20 or SPL tokens
442
+ Use transfer_token. You need: wallet_id, token contract address, recipient, amount, chain_id, and decimals (6 for USDC, 18 for most ERC-20s). Call get_chains to find stablecoin addresses.
443
+
444
+ ### Interact with DeFi (EVM only)
445
+ 1. Check allowance with get_allowance.
446
+ 2. If needed, call approve_token to let the DeFi contract spend your tokens.
447
+ 3. Use send_transaction with the contract's calldata to execute the interaction.
448
+
449
+ ### Wrap/unwrap native tokens (EVM only)
450
+ Use wrap_eth to convert ETH → WETH (required by many DeFi protocols). Use unwrap_eth to convert back.
451
+
452
+ ### Read on-chain data (EVM only)
453
+ Use call_contract for gas-free read-only calls (eth_call). Provide ABI-encoded calldata.
454
+
455
+ ## x402 Payments — Paying for Resources
456
+ The pay_x402 tool handles the full x402 payment flow automatically:
457
+ 1. Fetches the URL.
458
+ 2. If the server returns HTTP 402, parses the payment requirements.
459
+ 3. Pays on-chain using your wallet.
460
+ 4. Retries the request with proof of payment.
461
+ Always set max_payment to prevent overspending (e.g. "1.00" for max 1 USDC).
462
+
463
+ ## x402 Paywalls — Accepting Payments
464
+ Create paywalls to charge other agents for accessing your resources:
465
+ 1. create_paywall — set price, token, chain, and the resource URL to protect.
466
+ 2. Share the access URL. Clients that pay on-chain get the resource; others get HTTP 402.
467
+ 3. list_paywalls and get_paywall_payments to track revenue.
468
+ 4. get_x402_revenue for aggregate stats across all paywalls.
469
+
470
+ ## Wallet Security
471
+ - Use pause_wallet to immediately freeze a wallet if compromised. No transactions can be signed while paused.
472
+ - Use unpause_wallet to resume operations.
473
+ - Use delete_wallet to permanently disable a wallet.
474
+ - Wallets have server-enforced spending limits.
475
+
476
+ ## Tool Selection Guide
477
+ | Goal | Tool |
478
+ |------|------|
479
+ | Send ETH/SOL/native tokens | transfer |
480
+ | Send USDC/ERC-20/SPL tokens | transfer_token |
481
+ | Check native balance | get_balance |
482
+ | Check token balance | get_token_balance |
483
+ | Look up token details | get_token_info (EVM only) |
484
+ | Approve DeFi spending | approve_token (EVM only) |
485
+ | Check approval amount | get_allowance (EVM only) |
486
+ | Read smart contract | call_contract (EVM only) |
487
+ | Custom transaction | send_transaction |
488
+ | Sign without broadcasting | sign_transaction |
489
+ | Pay for x402 resource | pay_x402 |
490
+ | Charge for your resource | create_paywall |
439
491
  | Check usage/billing | get_usage |`,
440
492
  });
441
493
  // ─── Tool: create_wallet ─────────────────────────────────────────
@@ -900,19 +952,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
900
952
  throw new Error('Invalid headers JSON. Must be a JSON object (e.g. {"Authorization": "Bearer ..."}).');
901
953
  }
902
954
  }
903
- // Validate URL — block private IPs and cloud metadata endpoints
904
- const urlObj = new URL(url);
905
- const hostname = urlObj.hostname.toLowerCase();
906
- const blockedHosts = ['localhost', '127.0.0.1', '0.0.0.0', '[::1]', '169.254.169.254', 'metadata.google.internal'];
907
- const blockedPrefixes = ['10.', '172.16.', '172.17.', '172.18.', '172.19.', '172.20.', '172.21.',
908
- '172.22.', '172.23.', '172.24.', '172.25.', '172.26.', '172.27.', '172.28.', '172.29.',
909
- '172.30.', '172.31.', '192.168.'];
910
- if (blockedHosts.includes(hostname) || blockedPrefixes.some(p => hostname.startsWith(p))) {
911
- throw new Error('URL points to a private/internal address. Only public URLs are allowed.');
912
- }
913
- if (urlObj.protocol !== 'https:') {
914
- throw new Error('Only HTTPS URLs are supported for x402 payments.');
915
- }
955
+ // Validate URL: canonicalizes IP literals (including IPv4-mapped IPv6) and
956
+ // resolves DNS names, rejecting any private/internal destination. Redirect
957
+ // hops are re-validated by safeFetch below.
958
+ await assertPublicUrl(url);
916
959
  // Step 1: Make initial request
917
960
  const reqOptions = {
918
961
  method,
@@ -924,7 +967,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
924
967
  if (!reqHeaders['Content-Type'])
925
968
  reqHeaders['Content-Type'] = 'application/json';
926
969
  }
927
- const initialRes = await fetch(url, reqOptions);
970
+ const initialRes = await safeFetch(url, reqOptions);
928
971
  // If not 402, return the response as-is (no payment needed)
929
972
  if (initialRes.status !== 402) {
930
973
  const text = await initialRes.text();
@@ -966,8 +1009,18 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
966
1009
  throw new Error(`Unsupported x402 network: "${option.network}". ` +
967
1010
  `Supported: ${Object.keys(X402_NETWORKS).join(', ')}, or any CAIP-2 / numeric chain ID.`);
968
1011
  }
969
- // Calculate human-readable amount
970
- const amount = formatUnits(option.maxAmountRequired, option.requiredDecimals);
1012
+ // Decimals come from the registry or the token contract, NEVER from the 402
1013
+ // body. The endpoint controls requiredDecimals, so trusting it lets a
1014
+ // hostile server inflate the cap AND misreport the amount shown to the
1015
+ // agent. The declared value is only used to detect a mismatch and refuse.
1016
+ const assetForDecimals = option.asset || option.extra?.token || '';
1017
+ const trustedDecimals = assetForDecimals
1018
+ ? await resolveTrustedDecimals(chainId, assetForDecimals)
1019
+ : 18;
1020
+ assertDeclaredDecimals(option.requiredDecimals, trustedDecimals);
1021
+ // Human-readable amount, computed with trusted decimals so what the agent
1022
+ // is told matches what will actually leave the wallet.
1023
+ const amount = formatUnits(option.maxAmountRequired, trustedDecimals);
971
1024
  // Step 4: Enforce a hard per-payment cap. ALWAYS applied — if the caller
972
1025
  // omits max_payment we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"),
973
1026
  // mirroring the internal auto-pay path (deriveX402Payment). Without this, an
@@ -975,7 +1028,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
975
1028
  // maxAmountRequired and drain the wallet. The cap is never silently skipped.
976
1029
  const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
977
1030
  const effectiveMax = max_payment || process.env.AGENTWALLET_MAX_AUTOPAY || '1';
978
- if (!isWithinCap(option.maxAmountRequired, option.requiredDecimals, effectiveMax)) {
1031
+ if (!isWithinCap(option.maxAmountRequired, trustedDecimals, effectiveMax)) {
979
1032
  return jsonResponse({
980
1033
  status: 402,
981
1034
  payment_required: true,
@@ -1007,7 +1060,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1007
1060
  to: option.payTo,
1008
1061
  value: option.maxAmountRequired,
1009
1062
  token_mint: tokenAddress,
1010
- token_decimals: option.requiredDecimals,
1063
+ token_decimals: trustedDecimals,
1011
1064
  chain_id: chainId,
1012
1065
  }));
1013
1066
  }
@@ -1058,7 +1111,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1058
1111
  if (reqBody && method !== 'GET') {
1059
1112
  retryOptions.body = reqBody;
1060
1113
  }
1061
- const retryRes = await fetch(url, retryOptions);
1114
+ const retryRes = await safeFetch(url, retryOptions);
1062
1115
  const retryText = await retryRes.text();
1063
1116
  let retryParsed;
1064
1117
  try {
@@ -64,10 +64,6 @@ export interface LocalSendResult {
64
64
  mode: 'local';
65
65
  signed_locally: true;
66
66
  }
67
- /**
68
- * Sign and broadcast a transaction. viem fills nonce, gas and EIP-1559 fees
69
- * from the RPC unless they are supplied.
70
- */
71
67
  export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex): Promise<LocalSendResult>;
72
68
  /** ERC-20 transfer, encoded locally. */
73
69
  export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
@@ -17,6 +17,7 @@
17
17
  import { readFileSync } from 'node:fs';
18
18
  import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
19
19
  import { privateKeyToAccount } from 'viem/accounts';
20
+ import { lookupTrustedDecimals } from './x402-payment.js';
20
21
  /* ── Key loading ─────────────────────────────────────────────────── */
21
22
  /**
22
23
  * Read the key once at startup. Kept module-private: nothing exports the key
@@ -166,8 +167,53 @@ export async function localTokenBalance(chainId, token) {
166
167
  * Sign and broadcast a transaction. viem fills nonce, gas and EIP-1559 fees
167
168
  * from the RPC unless they are supplied.
168
169
  */
170
+ /**
171
+ * Bound ERC-20 movement, which the native cap does not see.
172
+ *
173
+ * A token transfer is `to = tokenContract, value = 0, data = transfer(...)`, so
174
+ * assertWithinNativeCap always passed it. In local mode there is no server-side
175
+ * limit behind this, and the README tells operators the local guard is the only
176
+ * one they have, so a wallet holding USDC was effectively uncapped. Covers
177
+ * transfer, transferFrom and approve, since an unbounded approval is a drain
178
+ * waiting to happen.
179
+ *
180
+ * Opt-in via AGENTWALLET_MAX_TX_TOKEN, expressed in human units of the token.
181
+ * When the token's decimals are not known locally we assume 6, the smallest in
182
+ * common use, which makes the raw ceiling the tightest one and fails closed
183
+ * rather than open.
184
+ */
185
+ function assertWithinTokenCap(chainId, token, data) {
186
+ const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
187
+ if (!cap)
188
+ return;
189
+ if (!/^\d+(\.\d+)?$/.test(cap)) {
190
+ throw new Error(`AGENTWALLET_MAX_TX_TOKEN must be a decimal number, got "${cap}".`);
191
+ }
192
+ const hex = data.slice(2);
193
+ const selector = hex.slice(0, 8).toLowerCase();
194
+ // transfer(address,uint256) | approve(address,uint256) | transferFrom(address,address,uint256)
195
+ const layouts = { a9059cbb: 1, '095ea7b3': 1, '23b872dd': 2 };
196
+ const argIndex = layouts[selector];
197
+ if (argIndex === undefined)
198
+ return; // not a value-moving ERC-20 call
199
+ const word = hex.slice(8 + argIndex * 64, 8 + (argIndex + 1) * 64);
200
+ if (word.length !== 64)
201
+ return; // malformed; leave it to the node to reject
202
+ const amount = BigInt('0x' + word);
203
+ const decimals = lookupTrustedDecimals(chainId, token) ?? 6;
204
+ const [whole, frac = ''] = cap.split('.');
205
+ const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
206
+ if (amount > capRaw) {
207
+ const what = selector === '095ea7b3' ? 'approval' : 'token transfer';
208
+ throw new Error(`Blocked by local guard: ${what} of ${amount} base units exceeds ` +
209
+ `AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
210
+ `Raise the cap deliberately if this is intended.`);
211
+ }
212
+ }
169
213
  export async function localSend(chainId, to, valueWei, data) {
170
214
  assertWithinNativeCap(valueWei);
215
+ if (data && data.length >= 10)
216
+ assertWithinTokenCap(chainId, to, data);
171
217
  const { wallet } = clients(chainId);
172
218
  const hash = await wallet.sendTransaction({
173
219
  to,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * SSRF guard for outbound x402 requests.
3
+ *
4
+ * The previous guard compared `new URL(url).hostname` against literal strings
5
+ * and dotted-decimal prefixes. That misses every alternate spelling of a
6
+ * private destination: IPv4-mapped IPv6 (`[::ffff:127.0.0.1]` parses to
7
+ * `[::ffff:7f00:1]`), the rest of 127.0.0.0/8, IPv6 unique-local and
8
+ * link-local, the whole 169.254.0.0/16 link-local range rather than only the
9
+ * metadata address, and 100.64.0.0/10.
10
+ *
11
+ * This module canonicalizes the host into actual IP addresses and tests them
12
+ * against numeric ranges, then resolves DNS names and applies the same test to
13
+ * every answer, so a public name pointing at a private address is refused too.
14
+ *
15
+ * Reported privately by ARC Security Research, 2026-07-31.
16
+ */
17
+ /**
18
+ * Reduce a host to the IP address it actually denotes.
19
+ * Strips IPv6 brackets and unwraps IPv4-mapped and IPv4-compatible IPv6.
20
+ */
21
+ export declare function canonicalizeHost(hostRaw: string): {
22
+ ip: string | null;
23
+ family: 0 | 4 | 6;
24
+ };
25
+ /** True when the address is loopback, private, link-local, multicast or reserved. */
26
+ export declare function isPrivateAddress(ipRaw: string): boolean;
27
+ /**
28
+ * Throw unless `url` is an https URL pointing at a public destination.
29
+ * DNS names are resolved and every answer is checked, so a public name that
30
+ * maps to a private address is rejected.
31
+ */
32
+ export declare function assertPublicUrl(url: string): Promise<void>;
33
+ /**
34
+ * fetch() that validates the target, and every redirect hop, against
35
+ * assertPublicUrl. Redirects are followed manually because the automatic
36
+ * follower would happily land on a private address after a public first hop.
37
+ */
38
+ export declare function safeFetch(url: string, options?: RequestInit, maxHops?: number): Promise<Response>;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * SSRF guard for outbound x402 requests.
3
+ *
4
+ * The previous guard compared `new URL(url).hostname` against literal strings
5
+ * and dotted-decimal prefixes. That misses every alternate spelling of a
6
+ * private destination: IPv4-mapped IPv6 (`[::ffff:127.0.0.1]` parses to
7
+ * `[::ffff:7f00:1]`), the rest of 127.0.0.0/8, IPv6 unique-local and
8
+ * link-local, the whole 169.254.0.0/16 link-local range rather than only the
9
+ * metadata address, and 100.64.0.0/10.
10
+ *
11
+ * This module canonicalizes the host into actual IP addresses and tests them
12
+ * against numeric ranges, then resolves DNS names and applies the same test to
13
+ * every answer, so a public name pointing at a private address is refused too.
14
+ *
15
+ * Reported privately by ARC Security Research, 2026-07-31.
16
+ */
17
+ import { isIP } from 'node:net';
18
+ import { lookup } from 'node:dns/promises';
19
+ /** Hostnames that must never be reached regardless of what they resolve to. */
20
+ const BLOCKED_NAMES = new Set([
21
+ 'localhost',
22
+ 'metadata.google.internal',
23
+ 'metadata.goog',
24
+ 'instance-data',
25
+ ]);
26
+ function ipv4ToInt(ip) {
27
+ const parts = ip.split('.');
28
+ if (parts.length !== 4)
29
+ return null;
30
+ let n = 0;
31
+ for (const p of parts) {
32
+ const v = Number(p);
33
+ if (!Number.isInteger(v) || v < 0 || v > 255)
34
+ return null;
35
+ n = (n << 8 >>> 0) + v;
36
+ }
37
+ return n >>> 0;
38
+ }
39
+ function inV4Range(ip, cidr) {
40
+ const [base, bitsRaw] = cidr.split('/');
41
+ const bits = Number(bitsRaw);
42
+ const a = ipv4ToInt(ip);
43
+ const b = ipv4ToInt(base);
44
+ if (a === null || b === null)
45
+ return false;
46
+ if (bits === 0)
47
+ return true;
48
+ const mask = (0xffffffff << (32 - bits)) >>> 0;
49
+ return (a & mask) === (b & mask);
50
+ }
51
+ /** Ranges that are never a legitimate x402 payment endpoint. */
52
+ const V4_BLOCKED = [
53
+ '0.0.0.0/8', // this network / unspecified
54
+ '10.0.0.0/8', // private
55
+ '100.64.0.0/10', // carrier-grade NAT
56
+ '127.0.0.0/8', // loopback, all of it
57
+ '169.254.0.0/16', // link-local, includes cloud metadata
58
+ '172.16.0.0/12', // private
59
+ '192.0.0.0/24', // IETF protocol assignments
60
+ '192.168.0.0/16', // private
61
+ '198.18.0.0/15', // benchmarking
62
+ '224.0.0.0/4', // multicast
63
+ '240.0.0.0/4', // reserved, includes 255.255.255.255
64
+ ];
65
+ /**
66
+ * Reduce a host to the IP address it actually denotes.
67
+ * Strips IPv6 brackets and unwraps IPv4-mapped and IPv4-compatible IPv6.
68
+ */
69
+ export function canonicalizeHost(hostRaw) {
70
+ let host = hostRaw.trim().toLowerCase();
71
+ if (host.startsWith('[') && host.endsWith(']'))
72
+ host = host.slice(1, -1);
73
+ const zone = host.indexOf('%'); // fe80::1%eth0
74
+ if (zone !== -1)
75
+ host = host.slice(0, zone);
76
+ const fam = isIP(host);
77
+ if (fam === 0)
78
+ return { ip: null, family: 0 };
79
+ if (fam === 4)
80
+ return { ip: host, family: 4 };
81
+ // IPv6: unwrap ::ffff:a.b.c.d and its hex spelling ::ffff:7f00:1
82
+ const mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
83
+ if (mapped)
84
+ return { ip: mapped[1], family: 4 };
85
+ const hexMapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
86
+ if (hexMapped) {
87
+ const hi = parseInt(hexMapped[1], 16);
88
+ const lo = parseInt(hexMapped[2], 16);
89
+ return { ip: [(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255].join('.'), family: 4 };
90
+ }
91
+ return { ip: host, family: 6 };
92
+ }
93
+ /** True when the address is loopback, private, link-local, multicast or reserved. */
94
+ export function isPrivateAddress(ipRaw) {
95
+ const { ip, family } = canonicalizeHost(ipRaw);
96
+ if (!ip)
97
+ return false;
98
+ if (family === 4)
99
+ return V4_BLOCKED.some(c => inV4Range(ip, c));
100
+ const v6 = ip;
101
+ if (v6 === '::' || v6 === '::1')
102
+ return true; // unspecified, loopback
103
+ if (/^f[cd][0-9a-f]{2}:/.test(v6))
104
+ return true; // fc00::/7 unique-local
105
+ if (/^fe[89ab][0-9a-f]:/.test(v6))
106
+ return true; // fe80::/10 link-local
107
+ if (/^ff[0-9a-f]{2}:/.test(v6))
108
+ return true; // ff00::/8 multicast
109
+ if (/^(64:ff9b|2002):/.test(v6))
110
+ return true; // NAT64 / 6to4 translation
111
+ return false;
112
+ }
113
+ /**
114
+ * Throw unless `url` is an https URL pointing at a public destination.
115
+ * DNS names are resolved and every answer is checked, so a public name that
116
+ * maps to a private address is rejected.
117
+ */
118
+ export async function assertPublicUrl(url) {
119
+ let u;
120
+ try {
121
+ u = new URL(url);
122
+ }
123
+ catch {
124
+ throw new Error('Invalid URL.');
125
+ }
126
+ if (u.protocol !== 'https:') {
127
+ throw new Error('Only HTTPS URLs are supported for x402 payments.');
128
+ }
129
+ const hostRaw = u.hostname.toLowerCase();
130
+ const bare = hostRaw.startsWith('[') && hostRaw.endsWith(']') ? hostRaw.slice(1, -1) : hostRaw;
131
+ if (BLOCKED_NAMES.has(bare)) {
132
+ throw new Error('URL points to a private/internal address. Only public URLs are allowed.');
133
+ }
134
+ const { ip, family } = canonicalizeHost(hostRaw);
135
+ if (family !== 0) {
136
+ if (isPrivateAddress(ip)) {
137
+ throw new Error('URL points to a private/internal address. Only public URLs are allowed.');
138
+ }
139
+ return;
140
+ }
141
+ // A name: every address it resolves to must be public.
142
+ let answers;
143
+ try {
144
+ answers = await lookup(bare, { all: true });
145
+ }
146
+ catch {
147
+ throw new Error(`Could not resolve host "${bare}".`);
148
+ }
149
+ if (!answers.length)
150
+ throw new Error(`Could not resolve host "${bare}".`);
151
+ for (const a of answers) {
152
+ if (isPrivateAddress(a.address)) {
153
+ throw new Error('URL resolves to a private/internal address. Only public URLs are allowed.');
154
+ }
155
+ }
156
+ }
157
+ /**
158
+ * fetch() that validates the target, and every redirect hop, against
159
+ * assertPublicUrl. Redirects are followed manually because the automatic
160
+ * follower would happily land on a private address after a public first hop.
161
+ */
162
+ export async function safeFetch(url, options = {}, maxHops = 3) {
163
+ let current = url;
164
+ for (let hop = 0; hop <= maxHops; hop++) {
165
+ await assertPublicUrl(current);
166
+ const res = await fetch(current, { ...options, redirect: 'manual' });
167
+ const isRedirect = res.status >= 300 && res.status < 400 && res.headers.has('location');
168
+ if (!isRedirect)
169
+ return res;
170
+ const next = new URL(res.headers.get('location'), current).toString();
171
+ // A redirected request must not replay the body or method blindly.
172
+ if (res.status === 303 || ((res.status === 301 || res.status === 302) && options.method && options.method !== 'GET')) {
173
+ options = { ...options, method: 'GET', body: undefined };
174
+ }
175
+ current = next;
176
+ }
177
+ throw new Error('Too many redirects while contacting the x402 endpoint.');
178
+ }