@xpr-agents/openclaw 0.8.1 → 0.8.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/dist/tools/a2a.d.ts.map +1 -1
- package/dist/tools/a2a.js +31 -0
- package/dist/tools/a2a.js.map +1 -1
- package/dist/util/ssrf.d.ts +4 -0
- package/dist/util/ssrf.d.ts.map +1 -0
- package/dist/util/ssrf.js +72 -0
- package/dist/util/ssrf.js.map +1 -0
- package/package.json +3 -3
- package/skills/code-sandbox/dist/index.js +103 -102
- package/skills/code-sandbox/src/index.ts +104 -108
- package/skills/creative/dist/index.js +6 -2
- package/skills/creative/dist/ssrf.js +96 -0
- package/skills/creative/src/index.ts +7 -2
- package/skills/creative/src/ssrf.ts +91 -0
- package/skills/defi/dist/index.js +19 -0
- package/skills/defi/src/index.ts +22 -0
- package/skills/defi/test-read.mjs +1 -1
- package/skills/governance/test-read.mjs +1 -1
- package/skills/lending/test-read.mjs +1 -1
- package/skills/nft/dist/index.js +19 -0
- package/skills/nft/src/index.ts +18 -0
- package/skills/web-scraping/dist/index.js +4 -2
- package/skills/web-scraping/dist/ssrf.js +96 -0
- package/skills/web-scraping/src/index.ts +5 -2
- package/skills/web-scraping/src/ssrf.ts +91 -0
- package/skills/xmd/test-read.mjs +1 -1
package/skills/nft/dist/index.js
CHANGED
|
@@ -247,6 +247,23 @@ function parsePrice(price) {
|
|
|
247
247
|
const contract = getTokenContract(symbol);
|
|
248
248
|
return { amount, symbol, precision, contract };
|
|
249
249
|
}
|
|
250
|
+
// ── Transfer cap ─────────────────────────────────
|
|
251
|
+
// SECURITY: the runner enforces maxTransferAmount on the CORE escrow/agent tools,
|
|
252
|
+
// but skills sign their own transactions, so a prompt-injected NFT purchase/bid
|
|
253
|
+
// could spend far more than the operator's cap. Enforce the same ceiling here on
|
|
254
|
+
// any XPR the agent SENDS. Cap is XPR-denominated via MAX_TRANSFER_XPR (default
|
|
255
|
+
// 1000, matching the core default); set it higher to allow larger trades.
|
|
256
|
+
function assertXprWithinCap(parsed, label) {
|
|
257
|
+
if (parsed.symbol !== 'XPR')
|
|
258
|
+
return; // cap is XPR-denominated
|
|
259
|
+
const capXpr = Number(process.env.MAX_TRANSFER_XPR || '1000');
|
|
260
|
+
if (!Number.isFinite(capXpr) || capXpr <= 0)
|
|
261
|
+
return; // disabled/invalid -> no cap
|
|
262
|
+
const amt = Number(parsed.amount);
|
|
263
|
+
if (Number.isFinite(amt) && amt > capXpr) {
|
|
264
|
+
throw new Error(`${label}: ${parsed.amount} XPR exceeds the transfer cap of ${capXpr} XPR (set MAX_TRANSFER_XPR to raise it).`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
250
267
|
// ── Validation Helpers ───────────────────────────
|
|
251
268
|
function isValidEosioName(name) {
|
|
252
269
|
if (!name || name.length > 12)
|
|
@@ -1301,6 +1318,7 @@ function nftSkill(api) {
|
|
|
1301
1318
|
return { error: 'price is required (must match listing price exactly)' };
|
|
1302
1319
|
try {
|
|
1303
1320
|
const parsed = parsePrice(price);
|
|
1321
|
+
assertXprWithinCap(parsed, 'nft_purchase');
|
|
1304
1322
|
const session = await getNftSession();
|
|
1305
1323
|
await ensureRam(session);
|
|
1306
1324
|
const result = await session.api.transact({
|
|
@@ -1427,6 +1445,7 @@ function nftSkill(api) {
|
|
|
1427
1445
|
return { error: 'bid_amount is required (e.g. "50.0000 XPR")' };
|
|
1428
1446
|
try {
|
|
1429
1447
|
const parsed = parsePrice(bid_amount);
|
|
1448
|
+
assertXprWithinCap(parsed, 'nft_bid');
|
|
1430
1449
|
const session = await getNftSession();
|
|
1431
1450
|
await ensureRam(session);
|
|
1432
1451
|
const result = await session.api.transact({
|
package/skills/nft/src/index.ts
CHANGED
|
@@ -248,6 +248,22 @@ function parsePrice(price: string): { amount: string; symbol: string; precision:
|
|
|
248
248
|
return { amount, symbol, precision, contract };
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
// ── Transfer cap ─────────────────────────────────
|
|
252
|
+
// SECURITY: the runner enforces maxTransferAmount on the CORE escrow/agent tools,
|
|
253
|
+
// but skills sign their own transactions, so a prompt-injected NFT purchase/bid
|
|
254
|
+
// could spend far more than the operator's cap. Enforce the same ceiling here on
|
|
255
|
+
// any XPR the agent SENDS. Cap is XPR-denominated via MAX_TRANSFER_XPR (default
|
|
256
|
+
// 1000, matching the core default); set it higher to allow larger trades.
|
|
257
|
+
function assertXprWithinCap(parsed: { amount: string; symbol: string }, label: string): void {
|
|
258
|
+
if (parsed.symbol !== 'XPR') return; // cap is XPR-denominated
|
|
259
|
+
const capXpr = Number(process.env.MAX_TRANSFER_XPR || '1000');
|
|
260
|
+
if (!Number.isFinite(capXpr) || capXpr <= 0) return; // disabled/invalid -> no cap
|
|
261
|
+
const amt = Number(parsed.amount);
|
|
262
|
+
if (Number.isFinite(amt) && amt > capXpr) {
|
|
263
|
+
throw new Error(`${label}: ${parsed.amount} XPR exceeds the transfer cap of ${capXpr} XPR (set MAX_TRANSFER_XPR to raise it).`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
251
267
|
// ── Validation Helpers ───────────────────────────
|
|
252
268
|
|
|
253
269
|
function isValidEosioName(name: string): boolean {
|
|
@@ -1313,6 +1329,7 @@ export default function nftSkill(api: SkillApi): void {
|
|
|
1313
1329
|
|
|
1314
1330
|
try {
|
|
1315
1331
|
const parsed = parsePrice(price);
|
|
1332
|
+
assertXprWithinCap(parsed, 'nft_purchase');
|
|
1316
1333
|
const session = await getNftSession();
|
|
1317
1334
|
await ensureRam(session);
|
|
1318
1335
|
|
|
@@ -1443,6 +1460,7 @@ export default function nftSkill(api: SkillApi): void {
|
|
|
1443
1460
|
|
|
1444
1461
|
try {
|
|
1445
1462
|
const parsed = parsePrice(bid_amount);
|
|
1463
|
+
assertXprWithinCap(parsed, 'nft_bid');
|
|
1446
1464
|
const session = await getNftSession();
|
|
1447
1465
|
await ensureRam(session);
|
|
1448
1466
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.default = webScrapingSkill;
|
|
9
|
+
const ssrf_1 = require("./ssrf");
|
|
9
10
|
// ── Constants ───────────────────────────────────
|
|
10
11
|
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5MB
|
|
11
12
|
const DEFAULT_TIMEOUT = 30000;
|
|
@@ -15,9 +16,10 @@ async function fetchPage(url, timeout = DEFAULT_TIMEOUT, headers) {
|
|
|
15
16
|
const controller = new AbortController();
|
|
16
17
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
17
18
|
try {
|
|
18
|
-
|
|
19
|
+
// guardedFetch resolves the host and refuses private/internal targets, and
|
|
20
|
+
// re-validates every redirect hop (SSRF: the URL is agent/job-controlled).
|
|
21
|
+
const resp = await (0, ssrf_1.guardedFetch)(url, {
|
|
19
22
|
signal: controller.signal,
|
|
20
|
-
redirect: 'follow',
|
|
21
23
|
headers: {
|
|
22
24
|
'User-Agent': USER_AGENT,
|
|
23
25
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isPrivateAddress = isPrivateAddress;
|
|
4
|
+
exports.assertPublicUrl = assertPublicUrl;
|
|
5
|
+
exports.guardedFetch = guardedFetch;
|
|
6
|
+
/**
|
|
7
|
+
* SSRF guard for skill fetches.
|
|
8
|
+
*
|
|
9
|
+
* These fetches are steered by agent/job-controlled URLs, and the runner sits
|
|
10
|
+
* inside a private network with cloud metadata and internal services reachable.
|
|
11
|
+
* Resolve the host and refuse any loopback / private / link-local / ULA / CGNAT /
|
|
12
|
+
* metadata address, and follow redirects MANUALLY so a public host that
|
|
13
|
+
* 3xx-redirects to an internal one is re-validated at every hop.
|
|
14
|
+
*/
|
|
15
|
+
const promises_1 = require("node:dns/promises");
|
|
16
|
+
const node_net_1 = require("node:net");
|
|
17
|
+
/** True for loopback, private, link-local, ULA, CGNAT, multicast and unparseable addresses. */
|
|
18
|
+
function isPrivateAddress(ip) {
|
|
19
|
+
const v = (0, node_net_1.isIP)(ip);
|
|
20
|
+
if (v === 4) {
|
|
21
|
+
const p = ip.split('.').map(Number);
|
|
22
|
+
if (p.length !== 4 || p.some(n => Number.isNaN(n)))
|
|
23
|
+
return true;
|
|
24
|
+
const [a, b, c] = p;
|
|
25
|
+
return (a === 0 || a === 10 || a === 127 ||
|
|
26
|
+
(a === 169 && b === 254) || // link-local + cloud metadata
|
|
27
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
28
|
+
(a === 192 && b === 168) ||
|
|
29
|
+
(a === 192 && b === 0 && c === 0) || // 192.0.0.0/24 (IETF protocol assignments)
|
|
30
|
+
(a === 198 && (b === 18 || b === 19)) || // 198.18.0.0/15 (benchmarking)
|
|
31
|
+
(a === 100 && b >= 64 && b <= 127) || // CGNAT
|
|
32
|
+
a >= 224 // multicast / reserved
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (v === 6) {
|
|
36
|
+
const s = ip.toLowerCase();
|
|
37
|
+
return (s === '::1' || s === '::' ||
|
|
38
|
+
s.startsWith('::ffff:') || // IPv4-mapped
|
|
39
|
+
s.startsWith('64:ff9b') || // NAT64 well-known prefix
|
|
40
|
+
/^fe[89a-f]/.test(s) || // link-local fe80::/10 + site-local fec0::/10
|
|
41
|
+
s.startsWith('fc') || s.startsWith('fd') // unique-local
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
/** Resolve a URL's host and throw unless every address it maps to is public http(s). */
|
|
47
|
+
async function assertPublicUrl(urlStr) {
|
|
48
|
+
let host;
|
|
49
|
+
try {
|
|
50
|
+
const u = new URL(urlStr);
|
|
51
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
52
|
+
throw new Error(`Blocked non-http(s) URL: ${u.protocol}`);
|
|
53
|
+
}
|
|
54
|
+
host = u.hostname;
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
throw new Error(`Blocked invalid URL: ${e.message}`);
|
|
58
|
+
}
|
|
59
|
+
if ((0, node_net_1.isIP)(host)) {
|
|
60
|
+
if (isPrivateAddress(host))
|
|
61
|
+
throw new Error(`Blocked private address: ${host}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
let addrs;
|
|
65
|
+
try {
|
|
66
|
+
addrs = await (0, promises_1.lookup)(host, { all: true });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new Error(`Blocked host that does not resolve: ${host}`);
|
|
70
|
+
}
|
|
71
|
+
if (addrs.length === 0 || addrs.some(a => isPrivateAddress(a.address))) {
|
|
72
|
+
throw new Error(`Blocked host resolving to a private address: ${host}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* fetch() with an SSRF guard on every hop. Redirects are followed manually (up to
|
|
77
|
+
* maxRedirects) so each Location is re-validated — a public host cannot 302 to an
|
|
78
|
+
* internal one. Throws if a hop targets a private address or the redirect chain is
|
|
79
|
+
* too long.
|
|
80
|
+
*/
|
|
81
|
+
async function guardedFetch(url, init = {}, maxRedirects = 3) {
|
|
82
|
+
let current = url;
|
|
83
|
+
for (let i = 0; i <= maxRedirects; i++) {
|
|
84
|
+
await assertPublicUrl(current);
|
|
85
|
+
const resp = await fetch(current, { ...init, redirect: 'manual' });
|
|
86
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
87
|
+
const loc = resp.headers.get('location');
|
|
88
|
+
if (!loc)
|
|
89
|
+
return resp;
|
|
90
|
+
current = new URL(loc, current).toString();
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
return resp;
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`Too many redirects (>${maxRedirects})`);
|
|
96
|
+
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Zero external dependencies — uses Node.js built-in fetch and regex-based HTML parsing.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { guardedFetch } from './ssrf';
|
|
8
|
+
|
|
7
9
|
interface ToolDef {
|
|
8
10
|
name: string;
|
|
9
11
|
description: string;
|
|
@@ -33,9 +35,10 @@ async function fetchPage(
|
|
|
33
35
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
34
36
|
|
|
35
37
|
try {
|
|
36
|
-
|
|
38
|
+
// guardedFetch resolves the host and refuses private/internal targets, and
|
|
39
|
+
// re-validates every redirect hop (SSRF: the URL is agent/job-controlled).
|
|
40
|
+
const resp = await guardedFetch(url, {
|
|
37
41
|
signal: controller.signal,
|
|
38
|
-
redirect: 'follow',
|
|
39
42
|
headers: {
|
|
40
43
|
'User-Agent': USER_AGENT,
|
|
41
44
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF guard for skill fetches.
|
|
3
|
+
*
|
|
4
|
+
* These fetches are steered by agent/job-controlled URLs, and the runner sits
|
|
5
|
+
* inside a private network with cloud metadata and internal services reachable.
|
|
6
|
+
* Resolve the host and refuse any loopback / private / link-local / ULA / CGNAT /
|
|
7
|
+
* metadata address, and follow redirects MANUALLY so a public host that
|
|
8
|
+
* 3xx-redirects to an internal one is re-validated at every hop.
|
|
9
|
+
*/
|
|
10
|
+
import { lookup } from 'node:dns/promises';
|
|
11
|
+
import { isIP } from 'node:net';
|
|
12
|
+
|
|
13
|
+
/** True for loopback, private, link-local, ULA, CGNAT, multicast and unparseable addresses. */
|
|
14
|
+
export function isPrivateAddress(ip: string): boolean {
|
|
15
|
+
const v = isIP(ip);
|
|
16
|
+
if (v === 4) {
|
|
17
|
+
const p = ip.split('.').map(Number);
|
|
18
|
+
if (p.length !== 4 || p.some(n => Number.isNaN(n))) return true;
|
|
19
|
+
const [a, b, c] = p;
|
|
20
|
+
return (
|
|
21
|
+
a === 0 || a === 10 || a === 127 ||
|
|
22
|
+
(a === 169 && b === 254) || // link-local + cloud metadata
|
|
23
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
24
|
+
(a === 192 && b === 168) ||
|
|
25
|
+
(a === 192 && b === 0 && c === 0) || // 192.0.0.0/24 (IETF protocol assignments)
|
|
26
|
+
(a === 198 && (b === 18 || b === 19)) || // 198.18.0.0/15 (benchmarking)
|
|
27
|
+
(a === 100 && b >= 64 && b <= 127) || // CGNAT
|
|
28
|
+
a >= 224 // multicast / reserved
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (v === 6) {
|
|
32
|
+
const s = ip.toLowerCase();
|
|
33
|
+
return (
|
|
34
|
+
s === '::1' || s === '::' ||
|
|
35
|
+
s.startsWith('::ffff:') || // IPv4-mapped
|
|
36
|
+
s.startsWith('64:ff9b') || // NAT64 well-known prefix
|
|
37
|
+
/^fe[89a-f]/.test(s) || // link-local fe80::/10 + site-local fec0::/10
|
|
38
|
+
s.startsWith('fc') || s.startsWith('fd') // unique-local
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve a URL's host and throw unless every address it maps to is public http(s). */
|
|
45
|
+
export async function assertPublicUrl(urlStr: string): Promise<void> {
|
|
46
|
+
let host: string;
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(urlStr);
|
|
49
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
50
|
+
throw new Error(`Blocked non-http(s) URL: ${u.protocol}`);
|
|
51
|
+
}
|
|
52
|
+
host = u.hostname;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
throw new Error(`Blocked invalid URL: ${(e as Error).message}`);
|
|
55
|
+
}
|
|
56
|
+
if (isIP(host)) {
|
|
57
|
+
if (isPrivateAddress(host)) throw new Error(`Blocked private address: ${host}`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let addrs: { address: string }[];
|
|
61
|
+
try {
|
|
62
|
+
addrs = await lookup(host, { all: true });
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`Blocked host that does not resolve: ${host}`);
|
|
65
|
+
}
|
|
66
|
+
if (addrs.length === 0 || addrs.some(a => isPrivateAddress(a.address))) {
|
|
67
|
+
throw new Error(`Blocked host resolving to a private address: ${host}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* fetch() with an SSRF guard on every hop. Redirects are followed manually (up to
|
|
73
|
+
* maxRedirects) so each Location is re-validated — a public host cannot 302 to an
|
|
74
|
+
* internal one. Throws if a hop targets a private address or the redirect chain is
|
|
75
|
+
* too long.
|
|
76
|
+
*/
|
|
77
|
+
export async function guardedFetch(url: string, init: RequestInit = {}, maxRedirects = 3): Promise<Response> {
|
|
78
|
+
let current = url;
|
|
79
|
+
for (let i = 0; i <= maxRedirects; i++) {
|
|
80
|
+
await assertPublicUrl(current);
|
|
81
|
+
const resp = await fetch(current, { ...init, redirect: 'manual' });
|
|
82
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
83
|
+
const loc = resp.headers.get('location');
|
|
84
|
+
if (!loc) return resp;
|
|
85
|
+
current = new URL(loc, current).toString();
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
return resp;
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`Too many redirects (>${maxRedirects})`);
|
|
91
|
+
}
|