agentwallet-mcp 1.10.3 → 1.10.4
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/README.md +9 -2
- package/build/index.js +5 -3
- package/build/ssrf-guard.d.ts +47 -7
- package/build/ssrf-guard.js +113 -19
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -320,8 +320,15 @@ All guards are active by default, no configuration required.
|
|
|
320
320
|
each redirect hop. IP literals are canonicalized (including IPv4-mapped IPv6
|
|
321
321
|
such as `[::ffff:127.0.0.1]`) and hostnames are resolved, with loopback,
|
|
322
322
|
private, link-local, carrier-grade NAT, multicast and cloud-metadata
|
|
323
|
-
destinations refused.
|
|
324
|
-
|
|
323
|
+
destinations refused.
|
|
324
|
+
|
|
325
|
+
Validation and connection use the same DNS answer. Each hop resolves once, and
|
|
326
|
+
the socket is pinned to an address from that answer, so a hostname cannot
|
|
327
|
+
resolve public for the check and private for the connection. The hostname is
|
|
328
|
+
still used for the `Host` header and for TLS SNI and certificate validation, so
|
|
329
|
+
pinning is invisible to legitimate endpoints.
|
|
330
|
+
|
|
331
|
+
Report security issues privately to security@hifriendbot.com.
|
|
325
332
|
|
|
326
333
|
## License
|
|
327
334
|
|
package/build/index.js
CHANGED
|
@@ -424,7 +424,7 @@ function resolveChainId(network) {
|
|
|
424
424
|
const AddressSchema = z.string().regex(/^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$/, 'Must be a 0x-prefixed 40-hex EVM address or a base58 Solana address');
|
|
425
425
|
const server = new McpServer({
|
|
426
426
|
name: 'agentwallet',
|
|
427
|
-
version: '1.10.
|
|
427
|
+
version: '1.10.4',
|
|
428
428
|
}, {
|
|
429
429
|
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.
|
|
430
430
|
|
|
@@ -965,8 +965,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
965
965
|
}
|
|
966
966
|
}
|
|
967
967
|
// Validate URL: canonicalizes IP literals (including IPv4-mapped IPv6) and
|
|
968
|
-
// resolves DNS names, rejecting any private/internal destination.
|
|
969
|
-
//
|
|
968
|
+
// resolves DNS names, rejecting any private/internal destination. An early
|
|
969
|
+
// check here gives a clear error before any request is built; safeFetch
|
|
970
|
+
// below re-validates every hop and pins each connection to the addresses it
|
|
971
|
+
// validated, so DNS cannot be rebound between the check and the connect.
|
|
970
972
|
await assertPublicUrl(url);
|
|
971
973
|
// Step 1: Make initial request
|
|
972
974
|
const reqOptions = {
|
package/build/ssrf-guard.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SSRF guard for outbound x402 requests.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
* and dotted-decimal prefixes. That
|
|
4
|
+
* The original guard compared `new URL(url).hostname` against literal strings
|
|
5
|
+
* and dotted-decimal prefixes. That missed every alternate spelling of a
|
|
6
6
|
* private destination: IPv4-mapped IPv6 (`[::ffff:127.0.0.1]` parses to
|
|
7
7
|
* `[::ffff:7f00:1]`), the rest of 127.0.0.0/8, IPv6 unique-local and
|
|
8
8
|
* link-local, the whole 169.254.0.0/16 link-local range rather than only the
|
|
@@ -12,7 +12,17 @@
|
|
|
12
12
|
* against numeric ranges, then resolves DNS names and applies the same test to
|
|
13
13
|
* every answer, so a public name pointing at a private address is refused too.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* Validating a name is not enough on its own. If the request is then handed to
|
|
16
|
+
* fetch() as a URL, the name is resolved a second time by the HTTP client, and
|
|
17
|
+
* an attacker who controls the authoritative DNS can answer public for the
|
|
18
|
+
* check and private for the connection. So `safeFetch` pins the connection to
|
|
19
|
+
* the exact addresses that were validated: the resolver runs once, and the
|
|
20
|
+
* socket is only allowed to reach an address from that answer. The hostname is
|
|
21
|
+
* still used for the Host header and for TLS SNI and certificate validation,
|
|
22
|
+
* so pinning is invisible to legitimate endpoints.
|
|
23
|
+
*
|
|
24
|
+
* Reported privately by ARC Security Research, 2026-07-31 (spelling bypasses)
|
|
25
|
+
* and by an anonymous researcher, 2026-08-02 (the DNS rebinding race).
|
|
16
26
|
*/
|
|
17
27
|
/**
|
|
18
28
|
* Reduce a host to the IP address it actually denotes.
|
|
@@ -24,15 +34,45 @@ export declare function canonicalizeHost(hostRaw: string): {
|
|
|
24
34
|
};
|
|
25
35
|
/** True when the address is loopback, private, link-local, multicast or reserved. */
|
|
26
36
|
export declare function isPrivateAddress(ipRaw: string): boolean;
|
|
37
|
+
/** An address that passed validation and is therefore allowed to be connected to. */
|
|
38
|
+
export type ValidatedAddress = {
|
|
39
|
+
address: string;
|
|
40
|
+
family: 4 | 6;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Validate `url` and return the exact set of addresses a connection to it is
|
|
44
|
+
* allowed to use. DNS names are resolved once here; that single answer is both
|
|
45
|
+
* what gets checked and what gets connected to, which is what removes the
|
|
46
|
+
* time-of-check/time-of-use gap.
|
|
47
|
+
*
|
|
48
|
+
* Throws unless the URL is https and every address it denotes is public.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolvePublicUrl(url: string): Promise<{
|
|
51
|
+
target: URL;
|
|
52
|
+
addresses: ValidatedAddress[];
|
|
53
|
+
}>;
|
|
27
54
|
/**
|
|
28
55
|
* Throw unless `url` is an https URL pointing at a public destination.
|
|
29
|
-
*
|
|
30
|
-
* maps to a private address is rejected.
|
|
56
|
+
* Kept for callers that only want the check and not the resolved addresses.
|
|
31
57
|
*/
|
|
32
58
|
export declare function assertPublicUrl(url: string): Promise<void>;
|
|
59
|
+
type LookupCallback = (err: NodeJS.ErrnoException | null, addressOrList?: string | ValidatedAddress[], family?: number) => void;
|
|
60
|
+
/**
|
|
61
|
+
* A drop-in replacement for dns.lookup that answers only from an already
|
|
62
|
+
* validated set and never queries a resolver. Handing this to the socket layer
|
|
63
|
+
* is what pins the connection: whatever the authoritative server says on a
|
|
64
|
+
* second query cannot reach the socket.
|
|
65
|
+
*/
|
|
66
|
+
export declare function createPinnedLookup(addresses: ValidatedAddress[]): (_hostname: string, options: unknown, callback?: LookupCallback) => void;
|
|
33
67
|
/**
|
|
34
68
|
* fetch() that validates the target, and every redirect hop, against
|
|
35
|
-
*
|
|
36
|
-
*
|
|
69
|
+
* resolvePublicUrl, and connects only to the addresses that validation
|
|
70
|
+
* returned. Redirects are followed manually because the automatic follower
|
|
71
|
+
* would happily land on a private address after a public first hop, and
|
|
72
|
+
* because each hop needs its own resolve-then-pin cycle.
|
|
73
|
+
*
|
|
74
|
+
* The body is buffered so the pinned connection can be torn down before the
|
|
75
|
+
* response is handed back.
|
|
37
76
|
*/
|
|
38
77
|
export declare function safeFetch(url: string, options?: RequestInit, maxHops?: number): Promise<Response>;
|
|
78
|
+
export {};
|
package/build/ssrf-guard.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SSRF guard for outbound x402 requests.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
* and dotted-decimal prefixes. That
|
|
4
|
+
* The original guard compared `new URL(url).hostname` against literal strings
|
|
5
|
+
* and dotted-decimal prefixes. That missed every alternate spelling of a
|
|
6
6
|
* private destination: IPv4-mapped IPv6 (`[::ffff:127.0.0.1]` parses to
|
|
7
7
|
* `[::ffff:7f00:1]`), the rest of 127.0.0.0/8, IPv6 unique-local and
|
|
8
8
|
* link-local, the whole 169.254.0.0/16 link-local range rather than only the
|
|
@@ -12,10 +12,21 @@
|
|
|
12
12
|
* against numeric ranges, then resolves DNS names and applies the same test to
|
|
13
13
|
* every answer, so a public name pointing at a private address is refused too.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* Validating a name is not enough on its own. If the request is then handed to
|
|
16
|
+
* fetch() as a URL, the name is resolved a second time by the HTTP client, and
|
|
17
|
+
* an attacker who controls the authoritative DNS can answer public for the
|
|
18
|
+
* check and private for the connection. So `safeFetch` pins the connection to
|
|
19
|
+
* the exact addresses that were validated: the resolver runs once, and the
|
|
20
|
+
* socket is only allowed to reach an address from that answer. The hostname is
|
|
21
|
+
* still used for the Host header and for TLS SNI and certificate validation,
|
|
22
|
+
* so pinning is invisible to legitimate endpoints.
|
|
23
|
+
*
|
|
24
|
+
* Reported privately by ARC Security Research, 2026-07-31 (spelling bypasses)
|
|
25
|
+
* and by an anonymous researcher, 2026-08-02 (the DNS rebinding race).
|
|
16
26
|
*/
|
|
17
27
|
import { isIP } from 'node:net';
|
|
18
|
-
import { lookup } from 'node:dns/promises';
|
|
28
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
29
|
+
import { Agent, fetch as undiciFetch } from 'undici';
|
|
19
30
|
/** Hostnames that must never be reached regardless of what they resolve to. */
|
|
20
31
|
const BLOCKED_NAMES = new Set([
|
|
21
32
|
'localhost',
|
|
@@ -111,11 +122,14 @@ export function isPrivateAddress(ipRaw) {
|
|
|
111
122
|
return false;
|
|
112
123
|
}
|
|
113
124
|
/**
|
|
114
|
-
*
|
|
115
|
-
* DNS names are resolved
|
|
116
|
-
*
|
|
125
|
+
* Validate `url` and return the exact set of addresses a connection to it is
|
|
126
|
+
* allowed to use. DNS names are resolved once here; that single answer is both
|
|
127
|
+
* what gets checked and what gets connected to, which is what removes the
|
|
128
|
+
* time-of-check/time-of-use gap.
|
|
129
|
+
*
|
|
130
|
+
* Throws unless the URL is https and every address it denotes is public.
|
|
117
131
|
*/
|
|
118
|
-
export async function
|
|
132
|
+
export async function resolvePublicUrl(url) {
|
|
119
133
|
let u;
|
|
120
134
|
try {
|
|
121
135
|
u = new URL(url);
|
|
@@ -136,41 +150,121 @@ export async function assertPublicUrl(url) {
|
|
|
136
150
|
if (isPrivateAddress(ip)) {
|
|
137
151
|
throw new Error('URL points to a private/internal address. Only public URLs are allowed.');
|
|
138
152
|
}
|
|
139
|
-
|
|
153
|
+
// An IP literal is never resolved, so there is nothing to rebind: the
|
|
154
|
+
// socket layer connects to the literal we just checked.
|
|
155
|
+
return { target: u, addresses: [{ address: ip, family }] };
|
|
140
156
|
}
|
|
141
157
|
// A name: every address it resolves to must be public.
|
|
142
158
|
let answers;
|
|
143
159
|
try {
|
|
144
|
-
answers = await
|
|
160
|
+
answers = await dnsLookup(bare, { all: true });
|
|
145
161
|
}
|
|
146
162
|
catch {
|
|
147
163
|
throw new Error(`Could not resolve host "${bare}".`);
|
|
148
164
|
}
|
|
149
165
|
if (!answers.length)
|
|
150
166
|
throw new Error(`Could not resolve host "${bare}".`);
|
|
167
|
+
const addresses = [];
|
|
151
168
|
for (const a of answers) {
|
|
152
169
|
if (isPrivateAddress(a.address)) {
|
|
153
170
|
throw new Error('URL resolves to a private/internal address. Only public URLs are allowed.');
|
|
154
171
|
}
|
|
172
|
+
addresses.push({ address: a.address, family: a.family === 6 ? 6 : 4 });
|
|
155
173
|
}
|
|
174
|
+
return { target: u, addresses };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Throw unless `url` is an https URL pointing at a public destination.
|
|
178
|
+
* Kept for callers that only want the check and not the resolved addresses.
|
|
179
|
+
*/
|
|
180
|
+
export async function assertPublicUrl(url) {
|
|
181
|
+
await resolvePublicUrl(url);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* A drop-in replacement for dns.lookup that answers only from an already
|
|
185
|
+
* validated set and never queries a resolver. Handing this to the socket layer
|
|
186
|
+
* is what pins the connection: whatever the authoritative server says on a
|
|
187
|
+
* second query cannot reach the socket.
|
|
188
|
+
*/
|
|
189
|
+
export function createPinnedLookup(addresses) {
|
|
190
|
+
const pinned = addresses.slice();
|
|
191
|
+
return function pinnedLookup(_hostname, options, callback) {
|
|
192
|
+
const cb = (typeof options === 'function' ? options : callback);
|
|
193
|
+
const opts = (typeof options === 'object' && options !== null ? options : {});
|
|
194
|
+
let list = pinned;
|
|
195
|
+
if (opts.family === 4 || opts.family === 6) {
|
|
196
|
+
list = pinned.filter(a => a.family === opts.family);
|
|
197
|
+
}
|
|
198
|
+
queueMicrotask(() => {
|
|
199
|
+
if (!list.length) {
|
|
200
|
+
const err = new Error('No validated address is available for this host.');
|
|
201
|
+
err.code = 'ENOTFOUND';
|
|
202
|
+
cb(err);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (opts.all) {
|
|
206
|
+
cb(null, list.map(a => ({ address: a.address, family: a.family })));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
cb(null, list[0].address, list[0].family);
|
|
210
|
+
});
|
|
211
|
+
};
|
|
156
212
|
}
|
|
213
|
+
/** Statuses whose Response must be constructed with a null body. */
|
|
214
|
+
const NULL_BODY_STATUS = new Set([101, 103, 204, 205, 304]);
|
|
157
215
|
/**
|
|
158
216
|
* fetch() that validates the target, and every redirect hop, against
|
|
159
|
-
*
|
|
160
|
-
*
|
|
217
|
+
* resolvePublicUrl, and connects only to the addresses that validation
|
|
218
|
+
* returned. Redirects are followed manually because the automatic follower
|
|
219
|
+
* would happily land on a private address after a public first hop, and
|
|
220
|
+
* because each hop needs its own resolve-then-pin cycle.
|
|
221
|
+
*
|
|
222
|
+
* The body is buffered so the pinned connection can be torn down before the
|
|
223
|
+
* response is handed back.
|
|
161
224
|
*/
|
|
162
225
|
export async function safeFetch(url, options = {}, maxHops = 3) {
|
|
163
226
|
let current = url;
|
|
227
|
+
let opts = options;
|
|
164
228
|
for (let hop = 0; hop <= maxHops; hop++) {
|
|
165
|
-
await
|
|
166
|
-
|
|
167
|
-
|
|
229
|
+
const { addresses } = await resolvePublicUrl(current);
|
|
230
|
+
// One agent per hop, carrying that hop's pinned addresses. The hostname is
|
|
231
|
+
// untouched, so Host and TLS SNI/certificate validation still use the name.
|
|
232
|
+
const agent = new Agent({
|
|
233
|
+
connect: { lookup: createPinnedLookup(addresses) },
|
|
234
|
+
});
|
|
235
|
+
let status;
|
|
236
|
+
let statusText;
|
|
237
|
+
let headers;
|
|
238
|
+
let body;
|
|
239
|
+
try {
|
|
240
|
+
const res = await undiciFetch(current, {
|
|
241
|
+
...opts,
|
|
242
|
+
redirect: 'manual',
|
|
243
|
+
dispatcher: agent,
|
|
244
|
+
});
|
|
245
|
+
status = res.status;
|
|
246
|
+
statusText = res.statusText;
|
|
247
|
+
headers = [];
|
|
248
|
+
for (const [k, v] of res.headers) {
|
|
249
|
+
// The body below is already decoded, so the transfer-level description
|
|
250
|
+
// of it would be wrong if carried over.
|
|
251
|
+
if (k === 'content-encoding' || k === 'content-length')
|
|
252
|
+
continue;
|
|
253
|
+
headers.push([k, v]);
|
|
254
|
+
}
|
|
255
|
+
body = NULL_BODY_STATUS.has(status) ? null : await res.arrayBuffer();
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
await agent.destroy();
|
|
259
|
+
}
|
|
260
|
+
const out = new Response(body, { status, statusText, headers });
|
|
261
|
+
const isRedirect = status >= 300 && status < 400 && out.headers.has('location');
|
|
168
262
|
if (!isRedirect)
|
|
169
|
-
return
|
|
170
|
-
const next = new URL(
|
|
263
|
+
return out;
|
|
264
|
+
const next = new URL(out.headers.get('location'), current).toString();
|
|
171
265
|
// A redirected request must not replay the body or method blindly.
|
|
172
|
-
if (
|
|
173
|
-
|
|
266
|
+
if (status === 303 || ((status === 301 || status === 302) && opts.method && opts.method !== 'GET')) {
|
|
267
|
+
opts = { ...opts, method: 'GET', body: undefined };
|
|
174
268
|
}
|
|
175
269
|
current = next;
|
|
176
270
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentwallet-mcp",
|
|
3
3
|
"mcpName": "io.github.hifriendbot/agentwallet",
|
|
4
|
-
"version": "1.10.
|
|
4
|
+
"version": "1.10.4",
|
|
5
5
|
"description": "MCP server for AgentWallet: self-custody local signing on EVM and Solana, or hosted wallets. Sign, broadcast, pay x402 invoices, and accept x402 payments.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "build/index.js",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"@modelcontextprotocol/sdk": "^1.11.4",
|
|
59
59
|
"@solana/web3.js": "^1.98.4",
|
|
60
60
|
"bs58": "^6.0.0",
|
|
61
|
+
"undici": "^8.9.0",
|
|
61
62
|
"viem": "^2.55.10",
|
|
62
63
|
"zod": "^3.24.0"
|
|
63
64
|
},
|