@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
|
@@ -25,61 +25,79 @@ const MAX_TIMEOUT = 30000;
|
|
|
25
25
|
const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
|
26
26
|
|
|
27
27
|
// ── Sandbox helpers ─────────────────────────────
|
|
28
|
+
//
|
|
29
|
+
// SECURITY: the context is given ONLY primitive strings — never a host function or
|
|
30
|
+
// object. That is the whole game with node:vm. `codeGeneration.strings:false` only
|
|
31
|
+
// disables eval/Function *for this context's own realm*; a host closure handed in
|
|
32
|
+
// (a console mock, atob/btoa via Buffer, anything) exposes `fn.constructor` — the
|
|
33
|
+
// HOST realm's Function, where code generation is still allowed — so
|
|
34
|
+
// `console.log.constructor("return process.env")()` would read the runner's secrets
|
|
35
|
+
// and `Object.getPrototypeOf(hostFn).constructor.prototype` would pollute the host.
|
|
36
|
+
// So: console, atob/btoa, INPUT parsing and the *result serialization* all run as
|
|
37
|
+
// sandbox-realm code (below). Serializing inside the sandbox also keeps it under the
|
|
38
|
+
// execution timeout — a malicious getter can no longer stall the host via a
|
|
39
|
+
// host-side JSON.stringify. The only value read back out is a JSON string (a
|
|
40
|
+
// primitive), which the host then parses safely.
|
|
41
|
+
|
|
42
|
+
/** Sandbox-realm preamble: pure-JS console/atob/btoa/INPUT, no host references. */
|
|
43
|
+
const SANDBOX_PREAMBLE = `
|
|
44
|
+
var __logs = [];
|
|
45
|
+
var console = {
|
|
46
|
+
log: function(){ __logs.push(Array.prototype.map.call(arguments, function(a){ return typeof a === 'object' ? JSON.stringify(a) : String(a); }).join(' ')); },
|
|
47
|
+
warn: function(){ __logs.push('[warn] ' + Array.prototype.map.call(arguments, function(a){ return typeof a === 'object' ? JSON.stringify(a) : String(a); }).join(' ')); },
|
|
48
|
+
error: function(){ __logs.push('[error] ' + Array.prototype.map.call(arguments, function(a){ return typeof a === 'object' ? JSON.stringify(a) : String(a); }).join(' ')); }
|
|
49
|
+
};
|
|
50
|
+
var __B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
51
|
+
function btoa(s){ s = String(s); var o = ''; for (var i = 0; i < s.length; ) {
|
|
52
|
+
var c1 = s.charCodeAt(i++), c2 = s.charCodeAt(i++), c3 = s.charCodeAt(i++);
|
|
53
|
+
var e1 = c1 >> 2, e2 = ((c1 & 3) << 4) | (c2 >> 4), e3 = ((c2 & 15) << 2) | (c3 >> 6), e4 = c3 & 63;
|
|
54
|
+
if (isNaN(c2)) { e3 = e4 = 64; } else if (isNaN(c3)) { e4 = 64; }
|
|
55
|
+
o += __B64.charAt(e1) + __B64.charAt(e2) + (e3 === 64 ? '=' : __B64.charAt(e3)) + (e4 === 64 ? '=' : __B64.charAt(e4));
|
|
56
|
+
} return o; }
|
|
57
|
+
function atob(s){ s = String(s).replace(/[^A-Za-z0-9+/=]/g, ''); var o = ''; for (var i = 0; i < s.length; ) {
|
|
58
|
+
var d1 = __B64.indexOf(s.charAt(i++)), d2 = __B64.indexOf(s.charAt(i++)), d3 = __B64.indexOf(s.charAt(i++)), d4 = __B64.indexOf(s.charAt(i++));
|
|
59
|
+
var c1 = (d1 << 2) | (d2 >> 4), c2 = ((d2 & 15) << 4) | (d3 >> 2), c3 = ((d3 & 3) << 6) | d4;
|
|
60
|
+
o += String.fromCharCode(c1); if (d3 !== 64 && d3 >= 0) o += String.fromCharCode(c2); if (d4 !== 64 && d4 >= 0) o += String.fromCharCode(c3);
|
|
61
|
+
} return o; }
|
|
62
|
+
`.trim();
|
|
28
63
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
process: undefined,
|
|
67
|
-
globalThis: undefined,
|
|
68
|
-
global: undefined,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function serializeResult(value: unknown): string {
|
|
73
|
-
if (value === undefined) return 'undefined';
|
|
74
|
-
try {
|
|
75
|
-
const str = JSON.stringify(value, null, 2);
|
|
76
|
-
if (str.length > MAX_OUTPUT_SIZE) {
|
|
77
|
-
return str.slice(0, MAX_OUTPUT_SIZE) + '\n... [truncated at 10MB]';
|
|
78
|
-
}
|
|
79
|
-
return str;
|
|
80
|
-
} catch {
|
|
81
|
-
return String(value);
|
|
82
|
-
}
|
|
64
|
+
/**
|
|
65
|
+
* Run a self-contained expression/body in a fresh vm realm and return the parsed
|
|
66
|
+
* outcome. `body` must be a statement list whose LAST expression is the user value
|
|
67
|
+
* to capture. Everything is serialized to a JSON string inside the sandbox.
|
|
68
|
+
*/
|
|
69
|
+
function runInSandbox(
|
|
70
|
+
body: string,
|
|
71
|
+
timeoutMs: number,
|
|
72
|
+
inputJson: string | undefined,
|
|
73
|
+
): { ok: boolean; result?: unknown; logs: string[]; error?: string; oversized?: boolean } {
|
|
74
|
+
// Only a primitive string crosses into the realm — AND the context global is
|
|
75
|
+
// given a NULL prototype. If we hand vm.createContext an ordinary host object,
|
|
76
|
+
// the sandbox global inherits the HOST realm's Object.prototype, so
|
|
77
|
+
// `this.constructor.constructor("return process.env")()` walks to the host
|
|
78
|
+
// Function (where codeGeneration is allowed) and reads the runner's secrets —
|
|
79
|
+
// codeGeneration:false only covers THIS context's realm. A null-proto global
|
|
80
|
+
// makes `this.constructor` resolve to the sandbox realm's own Function, which
|
|
81
|
+
// the flag then blocks. (Confirmed escape via the global-object path, 2026-09-19.)
|
|
82
|
+
const sandboxGlobal: Record<string, unknown> = Object.create(null);
|
|
83
|
+
sandboxGlobal.__INPUT_JSON = inputJson;
|
|
84
|
+
const context = vm.createContext(sandboxGlobal, {
|
|
85
|
+
codeGeneration: { strings: false, wasm: false },
|
|
86
|
+
});
|
|
87
|
+
const wrapped = `${SANDBOX_PREAMBLE}
|
|
88
|
+
var INPUT = (typeof __INPUT_JSON === 'string') ? JSON.parse(__INPUT_JSON) : undefined;
|
|
89
|
+
var __result, __error = null;
|
|
90
|
+
try { __result = (function(){ ${body} \n})(); } catch (e) { __error = (e && e.message) ? String(e.message) : String(e); }
|
|
91
|
+
(function(){
|
|
92
|
+
try { return JSON.stringify({ ok: __error === null, result: __result === undefined ? null : __result, logs: __logs, error: __error }); }
|
|
93
|
+
catch (e) { return JSON.stringify({ ok: __error === null, result: String(__result), logs: __logs, error: __error }); }
|
|
94
|
+
})();`;
|
|
95
|
+
const script = new vm.Script(wrapped, { filename: 'sandbox.js' });
|
|
96
|
+
const out = script.runInContext(context, { timeout: timeoutMs }) as string;
|
|
97
|
+
if (typeof out !== 'string') return { ok: false, logs: [], error: 'sandbox produced no serializable output' };
|
|
98
|
+
if (out.length > MAX_OUTPUT_SIZE) return { ok: false, logs: [], error: 'Output exceeded 10MB limit', oversized: true };
|
|
99
|
+
const parsed = JSON.parse(out) as { ok: boolean; result?: unknown; logs: string[]; error?: string };
|
|
100
|
+
return parsed;
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
// ── Skill entry point ───────────────────────────
|
|
@@ -113,53 +131,41 @@ export default function codeSandboxSkill(api: SkillApi): void {
|
|
|
113
131
|
}
|
|
114
132
|
|
|
115
133
|
const timeoutMs = Math.min(Math.max(timeout || DEFAULT_TIMEOUT, 100), MAX_TIMEOUT);
|
|
116
|
-
const logs: string[] = [];
|
|
117
134
|
const startTime = Date.now();
|
|
118
135
|
|
|
136
|
+
let inputJson: string | undefined;
|
|
119
137
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// Wrap code so the last expression is returned
|
|
126
|
-
const wrapped = `(function() {\n${code}\n})()`;
|
|
127
|
-
const script = new vm.Script(wrapped, { filename: 'sandbox.js' });
|
|
128
|
-
const result = script.runInContext(context, { timeout: timeoutMs });
|
|
129
|
-
const durationMs = Date.now() - startTime;
|
|
138
|
+
inputJson = input === undefined ? undefined : JSON.stringify(input);
|
|
139
|
+
} catch {
|
|
140
|
+
return { error: 'input could not be serialized to JSON' };
|
|
141
|
+
}
|
|
130
142
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
duration_ms: durationMs,
|
|
137
|
-
warning: 'Output exceeded 10MB limit and was truncated',
|
|
138
|
-
};
|
|
143
|
+
try {
|
|
144
|
+
const out = runInSandbox(code, timeoutMs, inputJson);
|
|
145
|
+
const durationMs = Date.now() - startTime;
|
|
146
|
+
if (out.oversized) {
|
|
147
|
+
return { error: 'Output exceeded 10MB limit', logs: out.logs, duration_ms: durationMs, warning: 'Output exceeded 10MB limit and was truncated' };
|
|
139
148
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
parsed = serialized === 'undefined' ? undefined : serialized;
|
|
149
|
+
if (!out.ok) {
|
|
150
|
+
const message = out.error || 'unknown error';
|
|
151
|
+
if (message.includes('Code generation from strings disallowed')) {
|
|
152
|
+
return { error: 'eval() and Function() constructor are blocked in the sandbox. Use direct code instead.', logs: out.logs, duration_ms: durationMs };
|
|
153
|
+
}
|
|
154
|
+
return { error: message, logs: out.logs, duration_ms: durationMs };
|
|
147
155
|
}
|
|
148
|
-
|
|
149
|
-
return { result: parsed, logs, duration_ms: durationMs };
|
|
156
|
+
return { result: out.result, logs: out.logs, duration_ms: durationMs };
|
|
150
157
|
} catch (err: any) {
|
|
151
158
|
const durationMs = Date.now() - startTime;
|
|
152
159
|
const message = err.message || String(err);
|
|
153
160
|
|
|
154
|
-
//
|
|
155
|
-
if (message.includes('Script execution timed out')) {
|
|
156
|
-
return { error: `Execution timed out after ${timeoutMs}ms. Keep code efficient or increase timeout (max ${MAX_TIMEOUT}ms).`, logs, duration_ms: durationMs };
|
|
161
|
+
// Timeout is a hard interrupt thrown to the host, so it lands here.
|
|
162
|
+
if (message.includes('Script execution timed out') || message.includes('timed out')) {
|
|
163
|
+
return { error: `Execution timed out after ${timeoutMs}ms. Keep code efficient or increase timeout (max ${MAX_TIMEOUT}ms).`, logs: [], duration_ms: durationMs };
|
|
157
164
|
}
|
|
158
165
|
if (message.includes('Code generation from strings disallowed')) {
|
|
159
|
-
return { error: 'eval() and Function() constructor are blocked in the sandbox. Use direct code instead.', logs, duration_ms: durationMs };
|
|
166
|
+
return { error: 'eval() and Function() constructor are blocked in the sandbox. Use direct code instead.', logs: [], duration_ms: durationMs };
|
|
160
167
|
}
|
|
161
|
-
|
|
162
|
-
return { error: message, logs, duration_ms: durationMs };
|
|
168
|
+
return { error: message, logs: [], duration_ms: durationMs };
|
|
163
169
|
}
|
|
164
170
|
},
|
|
165
171
|
});
|
|
@@ -185,27 +191,17 @@ export default function codeSandboxSkill(api: SkillApi): void {
|
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
let serialized: unknown;
|
|
197
|
-
try {
|
|
198
|
-
serialized = JSON.parse(JSON.stringify(result));
|
|
199
|
-
} catch {
|
|
200
|
-
serialized = String(result);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return {
|
|
204
|
-
result: serialized,
|
|
205
|
-
type: result === null ? 'null' : Array.isArray(result) ? 'array' : typeof result,
|
|
206
|
-
};
|
|
194
|
+
// Evaluate as the returned value of the sandbox body (same isolated realm,
|
|
195
|
+
// in-sandbox serialization). The value comes back as parsed JSON.
|
|
196
|
+
const out = runInSandbox(`return (${expression});`, DEFAULT_TIMEOUT, undefined);
|
|
197
|
+
if (!out.ok) return { error: out.error || 'evaluation failed' };
|
|
198
|
+
const r = out.result;
|
|
199
|
+
const type = r === null ? 'null' : Array.isArray(r) ? 'array' : typeof r;
|
|
200
|
+
return { result: r, type };
|
|
207
201
|
} catch (err: any) {
|
|
208
|
-
|
|
202
|
+
const message = err.message || String(err);
|
|
203
|
+
if (message.includes('timed out')) return { error: `Execution timed out after ${DEFAULT_TIMEOUT}ms.` };
|
|
204
|
+
return { error: message };
|
|
209
205
|
}
|
|
210
206
|
},
|
|
211
207
|
});
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
exports.getDeliverable = getDeliverable;
|
|
10
10
|
exports.default = creativeSkill;
|
|
11
|
+
const ssrf_1 = require("./ssrf");
|
|
11
12
|
// ── Shared helpers ──────────────────────────────
|
|
12
13
|
const MAX_DELIVERABLES = 200;
|
|
13
14
|
const deliverables = new Map();
|
|
@@ -121,7 +122,9 @@ async function downloadFromUrl(url) {
|
|
|
121
122
|
if (!/^https?:\/\//.test(url))
|
|
122
123
|
return null;
|
|
123
124
|
try {
|
|
124
|
-
|
|
125
|
+
// SSRF: the URL is agent/job-controlled and this runs inside a private network.
|
|
126
|
+
// guardedFetch refuses private/internal targets and re-validates each redirect.
|
|
127
|
+
const resp = await (0, ssrf_1.guardedFetch)(url, { signal: AbortSignal.timeout(30000) });
|
|
125
128
|
if (!resp.ok)
|
|
126
129
|
return null;
|
|
127
130
|
const contentType = resp.headers.get('content-type') || 'application/octet-stream';
|
|
@@ -156,7 +159,8 @@ function extractImages(text) {
|
|
|
156
159
|
}
|
|
157
160
|
async function downloadImage(url) {
|
|
158
161
|
try {
|
|
159
|
-
|
|
162
|
+
// SSRF: image URLs come from agent-authored markdown — guard + re-validate redirects.
|
|
163
|
+
const resp = await (0, ssrf_1.guardedFetch)(url, { signal: AbortSignal.timeout(15000) });
|
|
160
164
|
if (!resp.ok)
|
|
161
165
|
return null;
|
|
162
166
|
const ct = (resp.headers.get('content-type') || '').split(';')[0].trim();
|
|
@@ -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
|
+
}
|
|
@@ -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);
|