@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.
@@ -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
- const resp = await fetch(url, { signal: AbortSignal.timeout(30000), redirect: 'follow' });
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
- const resp = await fetch(url, { signal: AbortSignal.timeout(15000) });
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);
@@ -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);
@@ -20,7 +20,7 @@ const mockApi = {
20
20
  getConfig() {
21
21
  return {
22
22
  network: 'mainnet',
23
- rpcEndpoint: 'https://proton.eosusa.io',
23
+ rpcEndpoint: 'https://api.protonnz.com',
24
24
  };
25
25
  },
26
26
  };
@@ -5,7 +5,7 @@
5
5
  * Usage: node test-read.mjs
6
6
  */
7
7
 
8
- const RPC = 'https://proton.eosusa.io';
8
+ const RPC = 'https://api.protonnz.com';
9
9
  const GOV_API = 'https://gov.api.xprnetwork.org/api/v1/proposals';
10
10
  const GOV = 'gov';
11
11
 
@@ -5,7 +5,7 @@
5
5
  * Usage: node test-read.mjs
6
6
  */
7
7
 
8
- const RPC = 'https://proton.eosusa.io';
8
+ const RPC = 'https://api.protonnz.com';
9
9
 
10
10
  async function getTableRows(opts) {
11
11
  const resp = await fetch(`${RPC}/v1/chain/get_table_rows`, {
@@ -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({
@@ -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
- const resp = await fetch(url, {
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
- const resp = await fetch(url, {
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
+ }
@@ -5,7 +5,7 @@
5
5
  * Usage: node test-read.mjs
6
6
  */
7
7
 
8
- const RPC = 'https://proton.eosusa.io';
8
+ const RPC = 'https://api.protonnz.com';
9
9
  const XMD_TOKEN = 'xmd.token';
10
10
  const XMD_TREASURY = 'xmd.treasury';
11
11
  const ORACLE = 'oracles';