@xpr-agents/openclaw 0.8.0 → 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/README.md +25 -11
- 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/openclaw.plugin.json +94 -1
- 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
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Extracted from the main agent runner to validate the skill module format.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { guardedFetch } from './ssrf';
|
|
9
|
+
|
|
8
10
|
interface ToolDef {
|
|
9
11
|
name: string;
|
|
10
12
|
description: string;
|
|
@@ -132,7 +134,9 @@ const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
|
|
|
132
134
|
async function downloadFromUrl(url: string): Promise<{ buffer: Buffer; mimeType: string } | null> {
|
|
133
135
|
if (!/^https?:\/\//.test(url)) return null;
|
|
134
136
|
try {
|
|
135
|
-
|
|
137
|
+
// SSRF: the URL is agent/job-controlled and this runs inside a private network.
|
|
138
|
+
// guardedFetch refuses private/internal targets and re-validates each redirect.
|
|
139
|
+
const resp = await guardedFetch(url, { signal: AbortSignal.timeout(30000) });
|
|
136
140
|
if (!resp.ok) return null;
|
|
137
141
|
const contentType = resp.headers.get('content-type') || 'application/octet-stream';
|
|
138
142
|
const contentLength = parseInt(resp.headers.get('content-length') || '0');
|
|
@@ -161,7 +165,8 @@ function extractImages(text: string): { alt: string; url: string }[] {
|
|
|
161
165
|
|
|
162
166
|
async function downloadImage(url: string): Promise<{ buffer: Buffer; type: string } | null> {
|
|
163
167
|
try {
|
|
164
|
-
|
|
168
|
+
// SSRF: image URLs come from agent-authored markdown — guard + re-validate redirects.
|
|
169
|
+
const resp = await guardedFetch(url, { signal: AbortSignal.timeout(15000) });
|
|
165
170
|
if (!resp.ok) return null;
|
|
166
171
|
const ct = (resp.headers.get('content-type') || '').split(';')[0].trim();
|
|
167
172
|
if (!ct.startsWith('image/')) return null;
|
|
@@ -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
|
+
}
|
|
@@ -176,6 +176,21 @@ function parseAssetString(s) {
|
|
|
176
176
|
const precision = dotIdx >= 0 ? parts[0].length - dotIdx - 1 : 0;
|
|
177
177
|
return { amount, symbol: parts[1], precision };
|
|
178
178
|
}
|
|
179
|
+
// ── Transfer cap ─────────────────────────────────
|
|
180
|
+
// SECURITY: skills sign their own transactions, bypassing the runner's core
|
|
181
|
+
// maxTransferAmount. Enforce the same XPR ceiling on any XPR the agent SENDS
|
|
182
|
+
// (swaps/deposits/liquidity). MAX_TRANSFER_XPR (default 1000, matching the core
|
|
183
|
+
// default); raise it to allow larger trades.
|
|
184
|
+
function assertXprWithinCap(amount, symbol, label) {
|
|
185
|
+
if ((symbol || '').toUpperCase() !== 'XPR')
|
|
186
|
+
return; // cap is XPR-denominated
|
|
187
|
+
const capXpr = Number(process.env.MAX_TRANSFER_XPR || '1000');
|
|
188
|
+
if (!Number.isFinite(capXpr) || capXpr <= 0)
|
|
189
|
+
return; // disabled/invalid -> no cap
|
|
190
|
+
if (Number.isFinite(amount) && amount > capXpr) {
|
|
191
|
+
throw new Error(`${label}: ${amount} XPR exceeds the transfer cap of ${capXpr} XPR (set MAX_TRANSFER_XPR to raise it).`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
179
194
|
// ── Session Factory ──────────────────────────────
|
|
180
195
|
// Backed by the proton CLI. The agent process never holds a private key —
|
|
181
196
|
// the CLI signs every transaction internally via its encrypted keychain.
|
|
@@ -781,6 +796,7 @@ function defiSkill(api) {
|
|
|
781
796
|
depositQuantity = formatAsset(params.amount, bidToken.precision, bidToken.code);
|
|
782
797
|
depositContract = bidToken.contract;
|
|
783
798
|
}
|
|
799
|
+
assertXprWithinCap(orderSide === 1 ? params.amount * params.price : params.amount, orderSide === 1 ? askToken.code : bidToken.code, 'defi_place_order');
|
|
784
800
|
const { api: eosApi, account, permission } = await getSession();
|
|
785
801
|
const actions = [
|
|
786
802
|
// 1. Deposit tokens to DEX
|
|
@@ -926,6 +942,7 @@ function defiSkill(api) {
|
|
|
926
942
|
if (!params.min_output || params.min_output <= 0)
|
|
927
943
|
return { error: 'min_output must be positive' };
|
|
928
944
|
try {
|
|
945
|
+
assertXprWithinCap(params.amount, fromSpec.symbol, 'defi_swap');
|
|
929
946
|
const { api: eosApi, account, permission } = await getSession();
|
|
930
947
|
const fromQty = formatAsset(params.amount, fromSpec.precision, fromSpec.symbol);
|
|
931
948
|
const minOutQty = formatAsset(params.min_output, toSpec.precision, toSpec.symbol);
|
|
@@ -1010,6 +1027,8 @@ function defiSkill(api) {
|
|
|
1010
1027
|
if (!params.token2_contract)
|
|
1011
1028
|
return { error: 'token2_contract is required' };
|
|
1012
1029
|
try {
|
|
1030
|
+
assertXprWithinCap(t1.amount, t1.symbol, 'defi_add_liquidity');
|
|
1031
|
+
assertXprWithinCap(t2.amount, t2.symbol, 'defi_add_liquidity');
|
|
1013
1032
|
const { api: eosApi, account, permission } = await getSession();
|
|
1014
1033
|
const slip = (params.slippage_pct || 1.0) / 100;
|
|
1015
1034
|
const min1 = formatAsset(t1.amount * (1 - slip), t1.precision, t1.symbol);
|
package/skills/defi/src/index.ts
CHANGED
|
@@ -168,6 +168,20 @@ function parseAssetString(s: string): { amount: number; symbol: string; precisio
|
|
|
168
168
|
return { amount, symbol: parts[1], precision };
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// ── Transfer cap ─────────────────────────────────
|
|
172
|
+
// SECURITY: skills sign their own transactions, bypassing the runner's core
|
|
173
|
+
// maxTransferAmount. Enforce the same XPR ceiling on any XPR the agent SENDS
|
|
174
|
+
// (swaps/deposits/liquidity). MAX_TRANSFER_XPR (default 1000, matching the core
|
|
175
|
+
// default); raise it to allow larger trades.
|
|
176
|
+
function assertXprWithinCap(amount: number, symbol: string, label: string): void {
|
|
177
|
+
if ((symbol || '').toUpperCase() !== 'XPR') return; // cap is XPR-denominated
|
|
178
|
+
const capXpr = Number(process.env.MAX_TRANSFER_XPR || '1000');
|
|
179
|
+
if (!Number.isFinite(capXpr) || capXpr <= 0) return; // disabled/invalid -> no cap
|
|
180
|
+
if (Number.isFinite(amount) && amount > capXpr) {
|
|
181
|
+
throw new Error(`${label}: ${amount} XPR exceeds the transfer cap of ${capXpr} XPR (set MAX_TRANSFER_XPR to raise it).`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
171
185
|
// ── Session Factory ──────────────────────────────
|
|
172
186
|
// Backed by the proton CLI. The agent process never holds a private key —
|
|
173
187
|
// the CLI signs every transaction internally via its encrypted keychain.
|
|
@@ -790,6 +804,11 @@ export default function defiSkill(api: SkillApi): void {
|
|
|
790
804
|
depositContract = bidToken.contract;
|
|
791
805
|
}
|
|
792
806
|
|
|
807
|
+
assertXprWithinCap(
|
|
808
|
+
orderSide === 1 ? params.amount * params.price : params.amount,
|
|
809
|
+
orderSide === 1 ? askToken.code : bidToken.code,
|
|
810
|
+
'defi_place_order',
|
|
811
|
+
);
|
|
793
812
|
const { api: eosApi, account, permission } = await getSession();
|
|
794
813
|
|
|
795
814
|
const actions: any[] = [
|
|
@@ -933,6 +952,7 @@ export default function defiSkill(api: SkillApi): void {
|
|
|
933
952
|
if (!params.min_output || params.min_output <= 0) return { error: 'min_output must be positive' };
|
|
934
953
|
|
|
935
954
|
try {
|
|
955
|
+
assertXprWithinCap(params.amount, fromSpec.symbol, 'defi_swap');
|
|
936
956
|
const { api: eosApi, account, permission } = await getSession();
|
|
937
957
|
|
|
938
958
|
const fromQty = formatAsset(params.amount, fromSpec.precision, fromSpec.symbol);
|
|
@@ -1020,6 +1040,8 @@ export default function defiSkill(api: SkillApi): void {
|
|
|
1020
1040
|
if (!params.token2_contract) return { error: 'token2_contract is required' };
|
|
1021
1041
|
|
|
1022
1042
|
try {
|
|
1043
|
+
assertXprWithinCap(t1.amount, t1.symbol, 'defi_add_liquidity');
|
|
1044
|
+
assertXprWithinCap(t2.amount, t2.symbol, 'defi_add_liquidity');
|
|
1023
1045
|
const { api: eosApi, account, permission } = await getSession();
|
|
1024
1046
|
const slip = (params.slippage_pct || 1.0) / 100;
|
|
1025
1047
|
const min1 = formatAsset(t1.amount * (1 - slip), t1.precision, t1.symbol);
|
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
|
+
}
|