@1agh/maude 0.46.0 → 0.47.0
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/apps/studio/acp/bootstrap-brief.ts +8 -0
- package/apps/studio/acp/bridge.ts +113 -5
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/api.ts +417 -2
- package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
- package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
- package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
- package/apps/studio/bin/_curl-local.mjs +349 -0
- package/apps/studio/bin/_curl-local.test.mjs +280 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +678 -11
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +3 -0
- package/apps/studio/canvas-shell.tsx +646 -26
- package/apps/studio/client/app.jsx +898 -52
- package/apps/studio/client/panels/ChatPanel.jsx +47 -2
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1103 -1103
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +118 -0
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
- package/apps/studio/test/browse-posture.test.tsx +107 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
- package/apps/studio/test/canvas-meta-api.test.ts +70 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/comment-mount.test.ts +2 -1
- package/apps/studio/test/component-map.test.ts +48 -0
- package/apps/studio/test/convert-to-absolute.test.ts +333 -0
- package/apps/studio/test/detach-component.test.ts +94 -0
- package/apps/studio/test/edit-scope-api.test.ts +8 -4
- package/apps/studio/test/element-structural-api.test.ts +74 -0
- package/apps/studio/test/element-structural-edit.test.ts +113 -0
- package/apps/studio/test/grid-track-handles.test.ts +160 -0
- package/apps/studio/test/handoff.test.ts +48 -0
- package/apps/studio/test/input-router.test.ts +82 -8
- package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
- package/apps/studio/test/use-tool-mode.test.tsx +10 -2
- package/apps/studio/tool-palette.tsx +3 -1
- package/apps/studio/use-canvas-media-drop.tsx +126 -0
- package/apps/studio/use-element-resize.tsx +3 -1
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/commands/design.mjs +16 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +4 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// _curl-local.mjs — loopback-only HTTP request, reached via
|
|
3
|
+
// `maude design curl-local` (DDR-062 dispatch; DDR-185, hardened per its
|
|
4
|
+
// security addendum's second round).
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS: an ACP chat session auto-approves `Bash(maude:*)` (DDR-184)
|
|
7
|
+
// but NOT a bare `curl` — every raw `curl` call still prompts, even one aimed
|
|
8
|
+
// at the user's own localhost dev server (a common ask mid design-workflow:
|
|
9
|
+
// "is my backend up on :3000?"). Claude Code's `Bash(prefix:*)` allowlist is a
|
|
10
|
+
// plain string-prefix match with no host awareness, so a prefix-list can't
|
|
11
|
+
// express "curl, but only to localhost" reliably. This verb does the real
|
|
12
|
+
// check instead. Covered for free by the EXISTING `Bash(maude:*)` allow-list
|
|
13
|
+
// rule — no widening of the session's Bash surface.
|
|
14
|
+
//
|
|
15
|
+
// SECURITY ADDENDUM, ROUND 2 — this file's FIRST hardening pass (an argv
|
|
16
|
+
// allowlist over raw curl flags: reject -K/--resolve/--connect-to/etc.,
|
|
17
|
+
// forward everything else to a real `curl` child) was itself bypassed, live,
|
|
18
|
+
// by the SAME class of gap it was written to close: curl supports
|
|
19
|
+
// CONCATENATED short-flag syntax (`-K<path>`, no space or `=`), which
|
|
20
|
+
// `validateArgv`'s `a.split('=')[0]` check never recognized — so `-K<path>`,
|
|
21
|
+
// `-x<url>`, `-T<path>` all sailed through unrejected, reproducing the
|
|
22
|
+
// EXACT original `-K`-config-smuggling bypass verbatim. A SEPARATE gap in
|
|
23
|
+
// the same pass: `--noproxy` (the flag this file forces to `*`) was never
|
|
24
|
+
// itself added to the reject list, so a caller-supplied `--noproxy ""`
|
|
25
|
+
// placed after the forced one silently re-enabled an ambient proxy
|
|
26
|
+
// (curl is last-flag-wins for repeated options).
|
|
27
|
+
//
|
|
28
|
+
// The lesson, stated plainly rather than patched around again: hand-parsing
|
|
29
|
+
// an EXTERNAL binary's own CLI grammar (short flags, concatenation,
|
|
30
|
+
// bundling, `=`-forms, config files, env-var equivalents) to decide what's
|
|
31
|
+
// "safe" is an unbounded surface — every fix closes the specific bypass
|
|
32
|
+
// found and leaves the next one for the next binary quirk nobody thought to
|
|
33
|
+
// test. So this file no longer accepts raw curl arguments AT ALL. It defines
|
|
34
|
+
// its OWN small, Maude-owned flag vocabulary (below) that this file fully
|
|
35
|
+
// parses itself (plain, unambiguous `--flag value` pairs — no short forms,
|
|
36
|
+
// no concatenation, no bundling, nothing to mis-parse), and constructs the
|
|
37
|
+
// real curl invocation from FIXED, hardcoded flag names with the caller's
|
|
38
|
+
// values riding as separate argv array elements (never string-interpolated,
|
|
39
|
+
// so a value containing e.g. "-K" or "; rm -rf" is inert data to curl, not a
|
|
40
|
+
// new flag token — spawnSync with an argv array bypasses the shell
|
|
41
|
+
// entirely). The caller can no longer name a curl flag, so there is nothing
|
|
42
|
+
// left to allowlist/rejectlist AGAINST — the bypass class this addendum is
|
|
43
|
+
// closing cannot recur here by construction, not by enumeration.
|
|
44
|
+
//
|
|
45
|
+
// SCOPE, named explicitly (not left implicit): this verb allows "any
|
|
46
|
+
// loopback address," not "only Maude's own dev-server port" — the user's
|
|
47
|
+
// explicit ask was checking THEIR OWN arbitrary local dev servers, not just
|
|
48
|
+
// Maude's, so narrowing to one port would defeat the feature. This means a
|
|
49
|
+
// call CAN reach another unauthenticated-by-convention loopback service
|
|
50
|
+
// (Docker's API proxy, `kubectl proxy`, Node's inspector protocol) if the
|
|
51
|
+
// ACP session is steered there — accepted, because enumerating every
|
|
52
|
+
// "trusted because it's local" service on every user's machine isn't
|
|
53
|
+
// tractable; recorded here and in DDR-185's addendum, not accepted
|
|
54
|
+
// implicitly.
|
|
55
|
+
//
|
|
56
|
+
// Reuses `_fetch-asset.mjs`'s battle-tested IPv4/IPv6 literal parsers
|
|
57
|
+
// (`parseIPv4`/`parseIPv6`) rather than reinventing address parsing, but with
|
|
58
|
+
// the OPPOSITE accept condition: fetch-asset's `classifyAddress` rejects
|
|
59
|
+
// loopback/private/link-local/etc. and allows everything else (it's fetching
|
|
60
|
+
// attacker-controlled URLs from the open web); curl-local requires STRICT
|
|
61
|
+
// loopback and rejects everything else, including ordinary private-LAN
|
|
62
|
+
// addresses (192.168.x.x etc.) — this verb is for "my own machine", not
|
|
63
|
+
// "anything reachable on my network".
|
|
64
|
+
//
|
|
65
|
+
// Exit: curl's own exit code on success · 2 usage/rejected argv · 3
|
|
66
|
+
// non-loopback target (or DNS resolution failure) rejected · 1 other.
|
|
67
|
+
|
|
68
|
+
import { spawnSync } from 'node:child_process';
|
|
69
|
+
import { lookup } from 'node:dns/promises';
|
|
70
|
+
import { isIP } from 'node:net';
|
|
71
|
+
import { pathToFileURL } from 'node:url';
|
|
72
|
+
import { parseIPv4, parseIPv6 } from './_fetch-asset.mjs';
|
|
73
|
+
|
|
74
|
+
/** True iff this parsed 4-byte IPv4 is in the loopback range 127.0.0.0/8. */
|
|
75
|
+
function isLoopbackIPv4(bytes) {
|
|
76
|
+
return bytes[0] === 127;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True iff this parsed 16-byte IPv6 is ::1, or an IPv4-mapped/NAT64 loopback. */
|
|
80
|
+
function isLoopbackIPv6(bytes) {
|
|
81
|
+
const allZeroThrough = (n) => bytes.slice(0, n).every((x) => x === 0);
|
|
82
|
+
if (allZeroThrough(15) && bytes[15] === 1) return true; // ::1
|
|
83
|
+
if (allZeroThrough(10) && bytes[10] === 0xff && bytes[11] === 0xff) {
|
|
84
|
+
return isLoopbackIPv4(bytes.slice(12, 16)); // ::ffff:127.x.x.x
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True iff this IP literal is strictly loopback — never any other private/reserved range. */
|
|
90
|
+
export function isLoopbackAddress(addr) {
|
|
91
|
+
const kind = isIP(addr);
|
|
92
|
+
if (kind === 4) {
|
|
93
|
+
const bytes = parseIPv4(addr);
|
|
94
|
+
return !!bytes && isLoopbackIPv4(bytes);
|
|
95
|
+
}
|
|
96
|
+
if (kind === 6) {
|
|
97
|
+
const bytes = parseIPv6(addr);
|
|
98
|
+
return !!bytes && isLoopbackIPv6(bytes);
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class CurlLocalError extends Error {
|
|
104
|
+
constructor(code, message) {
|
|
105
|
+
super(message);
|
|
106
|
+
this.code = code;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Pure check over an already-resolved record set (the shape `dns.lookup(host,
|
|
112
|
+
* {all:true})` returns): every record must be loopback, or this returns a
|
|
113
|
+
* block-reason string naming the first offending address. A single
|
|
114
|
+
* non-loopback record among several (multi-record DNS rebinding) fails the
|
|
115
|
+
* whole host, not just that one record — split out from `resolveLoopbackIp`
|
|
116
|
+
* so it's testable with a plain array literal, no DNS/module mocking needed.
|
|
117
|
+
*/
|
|
118
|
+
export function classifyRecords(host, records) {
|
|
119
|
+
if (!records?.length) return `no DNS records for ${host}`;
|
|
120
|
+
for (const { address } of records) {
|
|
121
|
+
if (!isLoopbackAddress(address)) {
|
|
122
|
+
return `${host} resolves to non-loopback ${address} — refusing`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve every DNS record for `host`, require ALL of them to be loopback,
|
|
130
|
+
* and return the address to PIN the connection to (the first record) — the
|
|
131
|
+
* caller must pass this to curl's own `--resolve` so the later, independent
|
|
132
|
+
* connection can't re-resolve to something different (closes the
|
|
133
|
+
* validate-then-reconnect DNS-rebinding TOCTOU, the same bug class as
|
|
134
|
+
* CVE-2026-27826). `lookupFn` is injectable (defaults to the real
|
|
135
|
+
* `node:dns/promises` `lookup`) purely so tests can supply a deterministic
|
|
136
|
+
* multi-record response without mocking `node:dns`.
|
|
137
|
+
*/
|
|
138
|
+
export async function resolveLoopbackIp(host, { lookupFn = lookup } = {}) {
|
|
139
|
+
if (isIP(host)) {
|
|
140
|
+
if (!isLoopbackAddress(host)) {
|
|
141
|
+
throw new CurlLocalError(3, `${host} is not a loopback address`);
|
|
142
|
+
}
|
|
143
|
+
return host;
|
|
144
|
+
}
|
|
145
|
+
let records;
|
|
146
|
+
try {
|
|
147
|
+
records = await lookupFn(host, { all: true, verbatim: true });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
throw new CurlLocalError(3, `DNS resolution failed for ${host}: ${err?.code ?? err?.message}`);
|
|
150
|
+
}
|
|
151
|
+
const blockReason = classifyRecords(host, records);
|
|
152
|
+
if (blockReason) throw new CurlLocalError(3, blockReason);
|
|
153
|
+
return records[0].address;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Maude-owned request vocabulary ──────────────────────────────────────────
|
|
157
|
+
// A small, fully-Maude-parsed flag set — deliberately NOT curl's own syntax.
|
|
158
|
+
// Every flag here takes its value from the NEXT argv element (no `=`-form, no
|
|
159
|
+
// short/concatenated form, nothing to mis-parse); `--insecure`/`--include`/
|
|
160
|
+
// `--verbose` are boolean. Anything not in this set is rejected outright —
|
|
161
|
+
// an allowlist of RECOGNIZED tokens, not a rejectlist of known-bad ones, so
|
|
162
|
+
// an unanticipated curl-style flag simply never has a matching case instead
|
|
163
|
+
// of silently riding through unexamined.
|
|
164
|
+
const METHODS = Object.freeze(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
|
|
165
|
+
const VALUE_FLAGS = Object.freeze([
|
|
166
|
+
'--method',
|
|
167
|
+
'--header',
|
|
168
|
+
'--data',
|
|
169
|
+
'--user-agent',
|
|
170
|
+
'--max-time',
|
|
171
|
+
'--output',
|
|
172
|
+
]);
|
|
173
|
+
const BOOLEAN_FLAGS = Object.freeze(['--insecure', '--include', '--verbose']);
|
|
174
|
+
|
|
175
|
+
export class CurlLocalArgvError extends Error {}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parse this file's OWN small flag vocabulary (never curl's). Returns
|
|
179
|
+
* `{url, method, headers, data, userAgent, maxTime, output, insecure,
|
|
180
|
+
* include, verbose}` or throws CurlLocalArgvError with a human-readable
|
|
181
|
+
* reason. `--header` may repeat. Exactly one positional (non-flag) argument
|
|
182
|
+
* is accepted: the target URL.
|
|
183
|
+
*/
|
|
184
|
+
export function parseRequestArgv(argv) {
|
|
185
|
+
const out = {
|
|
186
|
+
url: null,
|
|
187
|
+
method: 'GET',
|
|
188
|
+
headers: [],
|
|
189
|
+
data: null,
|
|
190
|
+
userAgent: null,
|
|
191
|
+
maxTime: null,
|
|
192
|
+
output: null,
|
|
193
|
+
insecure: false,
|
|
194
|
+
include: false,
|
|
195
|
+
verbose: false,
|
|
196
|
+
};
|
|
197
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
198
|
+
const a = argv[i];
|
|
199
|
+
if (BOOLEAN_FLAGS.includes(a)) {
|
|
200
|
+
out[a.slice(2)] = true;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (VALUE_FLAGS.includes(a)) {
|
|
204
|
+
const value = argv[i + 1];
|
|
205
|
+
if (value === undefined) throw new CurlLocalArgvError(`${a} requires a value`);
|
|
206
|
+
i += 1;
|
|
207
|
+
if (a === '--method') {
|
|
208
|
+
const m = value.toUpperCase();
|
|
209
|
+
if (!METHODS.includes(m)) {
|
|
210
|
+
throw new CurlLocalArgvError(
|
|
211
|
+
`--method must be one of ${METHODS.join(', ')} (got "${value}")`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
out.method = m;
|
|
215
|
+
} else if (a === '--header') {
|
|
216
|
+
if (!/^[\w-]+:.*/.test(value)) {
|
|
217
|
+
throw new CurlLocalArgvError(`--header must look like "Name: value" (got "${value}")`);
|
|
218
|
+
}
|
|
219
|
+
out.headers.push(value);
|
|
220
|
+
} else if (a === '--data') {
|
|
221
|
+
out.data = value;
|
|
222
|
+
} else if (a === '--user-agent') {
|
|
223
|
+
out.userAgent = value;
|
|
224
|
+
} else if (a === '--max-time') {
|
|
225
|
+
const n = Number(value);
|
|
226
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
227
|
+
throw new CurlLocalArgvError(`--max-time must be a positive number (got "${value}")`);
|
|
228
|
+
out.maxTime = n;
|
|
229
|
+
} else if (a === '--output') {
|
|
230
|
+
out.output = value;
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (a.startsWith('-')) {
|
|
235
|
+
throw new CurlLocalArgvError(
|
|
236
|
+
`unrecognized flag "${a}" — curl-local has its OWN small flag set, not curl's; run with --help`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (out.url !== null)
|
|
240
|
+
throw new CurlLocalArgvError(`unexpected extra argument "${a}" (URL already given)`);
|
|
241
|
+
out.url = a;
|
|
242
|
+
}
|
|
243
|
+
if (!out.url) throw new CurlLocalArgvError('a target URL is required');
|
|
244
|
+
let parsed;
|
|
245
|
+
try {
|
|
246
|
+
parsed = new URL(out.url);
|
|
247
|
+
} catch {
|
|
248
|
+
throw new CurlLocalArgvError(`"${out.url}" is not a valid URL`);
|
|
249
|
+
}
|
|
250
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
251
|
+
throw new CurlLocalArgvError(
|
|
252
|
+
`only http:// and https:// are supported (got ${parsed.protocol}//)`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
out.parsedUrl = parsed;
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Build the fixed-shape curl argv for a parsed request. Every user-supplied
|
|
260
|
+
* value rides as its OWN argv array element (never concatenated into a
|
|
261
|
+
* flag), and every flag NAME is one of this file's own hardcoded literals —
|
|
262
|
+
* the caller never gets to name a curl flag. */
|
|
263
|
+
export function buildCurlArgs(req, pinIp, port) {
|
|
264
|
+
const args = [
|
|
265
|
+
'-q', // MUST be first — disables ~/.curlrc auto-load (config-file persistence)
|
|
266
|
+
'-sS',
|
|
267
|
+
'--proto',
|
|
268
|
+
'=http,https',
|
|
269
|
+
'--proto-redir',
|
|
270
|
+
'=http,https',
|
|
271
|
+
'--max-redirs',
|
|
272
|
+
'0', // no redirects — the validated target's response can't hand off elsewhere
|
|
273
|
+
'--noproxy',
|
|
274
|
+
'*', // ignore *_PROXY env — no egress via a poisoned proxy; this file OWNS every flag, so nothing can override it back
|
|
275
|
+
'--resolve',
|
|
276
|
+
`${req.parsedUrl.hostname}:${port}:${pinIp}`, // pin — defeats the validate-then-reconnect DNS-rebinding TOCTOU
|
|
277
|
+
'-X',
|
|
278
|
+
req.method,
|
|
279
|
+
];
|
|
280
|
+
for (const h of req.headers) args.push('-H', h);
|
|
281
|
+
if (req.data !== null) args.push('--data-raw', req.data); // --data-raw (not -d/--data): never treats a leading "@" as a file path
|
|
282
|
+
if (req.userAgent) args.push('-A', req.userAgent);
|
|
283
|
+
if (req.maxTime) args.push('-m', String(req.maxTime));
|
|
284
|
+
if (req.output) args.push('-o', req.output);
|
|
285
|
+
if (req.insecure) args.push('-k');
|
|
286
|
+
if (req.include) args.push('-i');
|
|
287
|
+
if (req.verbose) args.push('-v');
|
|
288
|
+
args.push('--', req.url);
|
|
289
|
+
return args;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const HELP = `curl-local — loopback-only HTTP request (reached via \`maude design curl-local\`)
|
|
293
|
+
|
|
294
|
+
Usage:
|
|
295
|
+
maude design curl-local <url> [--method GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS]
|
|
296
|
+
[--header "Name: value"]... [--data <body>]
|
|
297
|
+
[--user-agent <ua>] [--max-time <seconds>]
|
|
298
|
+
[--output <path>] [--insecure] [--include] [--verbose]
|
|
299
|
+
|
|
300
|
+
This is curl-local's OWN small flag vocabulary, not curl's — it does not
|
|
301
|
+
accept raw curl arguments (see the file's own header comment for why).
|
|
302
|
+
Resolves the URL's host and refuses to run at all unless EVERY resolved
|
|
303
|
+
address is loopback (127.0.0.0/8 or ::1); pins the connection to the
|
|
304
|
+
validated address.
|
|
305
|
+
|
|
306
|
+
Exit: curl's own exit code · 2 usage/rejected argv · 3 non-loopback target
|
|
307
|
+
rejected · 1 other.`;
|
|
308
|
+
|
|
309
|
+
async function main() {
|
|
310
|
+
const argv = process.argv.slice(2);
|
|
311
|
+
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
|
|
312
|
+
process.stdout.write(`${HELP}\n`);
|
|
313
|
+
process.exit(argv.length === 0 ? 2 : 0);
|
|
314
|
+
}
|
|
315
|
+
let req;
|
|
316
|
+
try {
|
|
317
|
+
req = parseRequestArgv(argv);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
process.stderr.write(`curl-local: ${err.message}\n`);
|
|
320
|
+
process.exit(2);
|
|
321
|
+
}
|
|
322
|
+
let pinIp;
|
|
323
|
+
try {
|
|
324
|
+
pinIp = await resolveLoopbackIp(req.parsedUrl.hostname);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
process.stderr.write(`curl-local: ${err.message}\n`);
|
|
327
|
+
process.exit(err instanceof CurlLocalError ? err.code : 1);
|
|
328
|
+
}
|
|
329
|
+
const port = req.parsedUrl.port
|
|
330
|
+
? Number(req.parsedUrl.port)
|
|
331
|
+
: req.parsedUrl.protocol === 'https:'
|
|
332
|
+
? 443
|
|
333
|
+
: 80;
|
|
334
|
+
const curlArgs = buildCurlArgs(req, pinIp, port);
|
|
335
|
+
const result = spawnSync('curl', curlArgs, { stdio: 'inherit' });
|
|
336
|
+
if (result.error) {
|
|
337
|
+
process.stderr.write(`curl-local: failed to run curl: ${result.error.message}\n`);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
process.exit(result.status ?? 1);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Run only when invoked directly (not when imported by the test). This shim
|
|
344
|
+
// runs under real `node` on a real on-disk path (never embedded in
|
|
345
|
+
// `bun --compile`), so the classic argv[1] guard is correct here — see the
|
|
346
|
+
// v0.38.0 self-heal memory.
|
|
347
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
348
|
+
main();
|
|
349
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// DDR-185 — the loopback-only curl gate behind `maude design curl-local`
|
|
2
|
+
// (apps/studio/bin/_curl-local.mjs). This suite locks the SECURITY CORE: the
|
|
3
|
+
// strict-loopback address classifier (accepts ONLY 127.0.0.0/8 / ::1 / the
|
|
4
|
+
// IPv4-mapped form — unlike `_fetch-asset.mjs`'s `classifyAddress`, an
|
|
5
|
+
// ordinary private-LAN address like 192.168.1.1 is REJECTED here, not
|
|
6
|
+
// allowed), the every-record DNS check (closes the multi-A-record rebinding
|
|
7
|
+
// gap), and — round 2 of the security addendum — the Maude-owned request
|
|
8
|
+
// parser (`parseRequestArgv`) that REPLACED the raw-curl-argv allowlist a
|
|
9
|
+
// live security fan-out found still bypassable via curl's own concatenated
|
|
10
|
+
// short-flag syntax (`-K<path>`, `-x<url>`, `-T<path>` all slipped past the
|
|
11
|
+
// `a.split('=')[0]` check) and a missing `--noproxy` reject entry. This file
|
|
12
|
+
// no longer recognizes ANY curl flag at all — only its own small vocabulary
|
|
13
|
+
// — so there is no curl-syntax bypass class left to enumerate against.
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import {
|
|
17
|
+
buildCurlArgs,
|
|
18
|
+
CurlLocalArgvError,
|
|
19
|
+
CurlLocalError,
|
|
20
|
+
classifyRecords,
|
|
21
|
+
isLoopbackAddress,
|
|
22
|
+
parseRequestArgv,
|
|
23
|
+
resolveLoopbackIp,
|
|
24
|
+
} from './_curl-local.mjs';
|
|
25
|
+
|
|
26
|
+
describe('isLoopbackAddress', () => {
|
|
27
|
+
test('accepts IPv4 loopback (127.0.0.0/8, not just 127.0.0.1)', () => {
|
|
28
|
+
expect(isLoopbackAddress('127.0.0.1')).toBe(true);
|
|
29
|
+
expect(isLoopbackAddress('127.9.9.9')).toBe(true);
|
|
30
|
+
expect(isLoopbackAddress('127.255.255.255')).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('accepts IPv6 ::1 and the IPv4-mapped loopback form', () => {
|
|
34
|
+
expect(isLoopbackAddress('::1')).toBe(true);
|
|
35
|
+
expect(isLoopbackAddress('::ffff:127.0.0.1')).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("rejects ordinary private-LAN addresses — this is STRICTER than fetch-asset's classifier", () => {
|
|
39
|
+
// fetch-asset's classifyAddress blocks these too (as "not a safe egress
|
|
40
|
+
// target"), but for a different reason (SSRF from an external URL). Here
|
|
41
|
+
// the bar is "is this literally my own machine" — a private-LAN IP is a
|
|
42
|
+
// real, different host, not loopback, and must still prompt.
|
|
43
|
+
expect(isLoopbackAddress('192.168.1.1')).toBe(false);
|
|
44
|
+
expect(isLoopbackAddress('10.0.0.1')).toBe(false);
|
|
45
|
+
expect(isLoopbackAddress('172.16.0.1')).toBe(false);
|
|
46
|
+
expect(isLoopbackAddress('169.254.169.254')).toBe(false); // cloud IMDS
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('rejects public addresses and link-local/multicast/reserved ranges', () => {
|
|
50
|
+
for (const ip of ['8.8.8.8', '1.1.1.1', '224.0.0.1', '240.0.0.1', 'fe80::1']) {
|
|
51
|
+
expect(isLoopbackAddress(ip)).toBe(false);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('rejects a non-IP string', () => {
|
|
56
|
+
expect(isLoopbackAddress('not-an-ip')).toBe(false);
|
|
57
|
+
expect(isLoopbackAddress('localhost')).toBe(false); // a hostname, not a literal — resolved separately
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('classifyRecords — the every-record check (multi-A-record DNS rebinding)', () => {
|
|
62
|
+
test('all-loopback records pass', () => {
|
|
63
|
+
expect(
|
|
64
|
+
classifyRecords('localhost', [{ address: '127.0.0.1' }, { address: '127.0.0.2' }])
|
|
65
|
+
).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a single non-loopback record among several loopback ones still fails the whole host', () => {
|
|
69
|
+
const reason = classifyRecords('rebind.example', [
|
|
70
|
+
{ address: '127.0.0.1' },
|
|
71
|
+
{ address: '93.184.216.34' },
|
|
72
|
+
]);
|
|
73
|
+
expect(reason).toContain('93.184.216.34');
|
|
74
|
+
expect(reason).toContain('non-loopback');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('empty record set fails', () => {
|
|
78
|
+
expect(classifyRecords('empty.example', [])).toContain('no DNS records');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('resolveLoopbackIp', () => {
|
|
83
|
+
test('an IP-literal host skips DNS entirely — loopback passes, returns itself', async () => {
|
|
84
|
+
await expect(resolveLoopbackIp('127.0.0.1')).resolves.toBe('127.0.0.1');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('an IP-literal host that is not loopback throws CurlLocalError(3)', async () => {
|
|
88
|
+
await expect(resolveLoopbackIp('8.8.8.8')).rejects.toThrow(CurlLocalError);
|
|
89
|
+
try {
|
|
90
|
+
await resolveLoopbackIp('8.8.8.8');
|
|
91
|
+
throw new Error('should have thrown');
|
|
92
|
+
} catch (err) {
|
|
93
|
+
expect(err.code).toBe(3);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a hostname resolving to all-loopback records (injected lookup) passes, returns the pin IP', async () => {
|
|
98
|
+
const lookupFn = async () => [
|
|
99
|
+
{ address: '127.0.0.1', family: 4 },
|
|
100
|
+
{ address: '::1', family: 6 },
|
|
101
|
+
];
|
|
102
|
+
await expect(resolveLoopbackIp('localhost', { lookupFn })).resolves.toBe('127.0.0.1');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a hostname with ONE non-loopback record among several is rejected (DNS-rebinding defense)', async () => {
|
|
106
|
+
const lookupFn = async () => [
|
|
107
|
+
{ address: '127.0.0.1', family: 4 },
|
|
108
|
+
{ address: '203.0.113.5', family: 4 }, // TEST-NET-3 — a real "other" address
|
|
109
|
+
];
|
|
110
|
+
await expect(resolveLoopbackIp('rebind.example', { lookupFn })).rejects.toThrow(CurlLocalError);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('DNS resolution failure is a rejection, not a silent pass', async () => {
|
|
114
|
+
const lookupFn = async () => {
|
|
115
|
+
const err = new Error('getaddrinfo ENOTFOUND nope.invalid');
|
|
116
|
+
err.code = 'ENOTFOUND';
|
|
117
|
+
throw err;
|
|
118
|
+
};
|
|
119
|
+
await expect(resolveLoopbackIp('nope.invalid', { lookupFn })).rejects.toThrow(CurlLocalError);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('parseRequestArgv — the Maude-owned flag vocabulary (never raw curl syntax)', () => {
|
|
124
|
+
test('a bare URL parses as a GET with no headers/body', () => {
|
|
125
|
+
const req = parseRequestArgv(['http://localhost:3000/api']);
|
|
126
|
+
expect(req.url).toBe('http://localhost:3000/api');
|
|
127
|
+
expect(req.method).toBe('GET');
|
|
128
|
+
expect(req.headers).toEqual([]);
|
|
129
|
+
expect(req.data).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('--method, repeated --header, and --data all parse', () => {
|
|
133
|
+
const req = parseRequestArgv([
|
|
134
|
+
'http://localhost:3000/api',
|
|
135
|
+
'--method',
|
|
136
|
+
'post',
|
|
137
|
+
'--header',
|
|
138
|
+
'Content-Type: application/json',
|
|
139
|
+
'--header',
|
|
140
|
+
'X-Test: 1',
|
|
141
|
+
'--data',
|
|
142
|
+
'{"a":1}',
|
|
143
|
+
]);
|
|
144
|
+
expect(req.method).toBe('POST'); // uppercased
|
|
145
|
+
expect(req.headers).toEqual(['Content-Type: application/json', 'X-Test: 1']);
|
|
146
|
+
expect(req.data).toBe('{"a":1}');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('boolean flags (--insecure/--include/--verbose) parse with no value consumed', () => {
|
|
150
|
+
const req = parseRequestArgv([
|
|
151
|
+
'http://localhost:3000/',
|
|
152
|
+
'--insecure',
|
|
153
|
+
'--include',
|
|
154
|
+
'--verbose',
|
|
155
|
+
]);
|
|
156
|
+
expect(req.insecure).toBe(true);
|
|
157
|
+
expect(req.include).toBe(true);
|
|
158
|
+
expect(req.verbose).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('rejects an unrecognized method', () => {
|
|
162
|
+
expect(() => parseRequestArgv(['http://localhost:3000/', '--method', 'TRACE'])).toThrow(
|
|
163
|
+
CurlLocalArgvError
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('rejects a malformed header (must look like "Name: value")', () => {
|
|
168
|
+
expect(() => parseRequestArgv(['http://localhost:3000/', '--header', 'not-a-header'])).toThrow(
|
|
169
|
+
CurlLocalArgvError
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('rejects a non-positive --max-time', () => {
|
|
174
|
+
expect(() => parseRequestArgv(['http://localhost:3000/', '--max-time', '0'])).toThrow(
|
|
175
|
+
CurlLocalArgvError
|
|
176
|
+
);
|
|
177
|
+
expect(() => parseRequestArgv(['http://localhost:3000/', '--max-time', 'nope'])).toThrow(
|
|
178
|
+
CurlLocalArgvError
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('rejects a non-http(s) URL scheme', () => {
|
|
183
|
+
expect(() => parseRequestArgv(['file:///etc/passwd'])).toThrow(CurlLocalArgvError);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('rejects a value-taking flag with no value', () => {
|
|
187
|
+
expect(() => parseRequestArgv(['http://localhost:3000/', '--header'])).toThrow(
|
|
188
|
+
CurlLocalArgvError
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('rejects more than one positional argument', () => {
|
|
193
|
+
expect(() => parseRequestArgv(['http://localhost:3000/', 'http://localhost:4000/'])).toThrow(
|
|
194
|
+
CurlLocalArgvError
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('rejects when no URL is given at all', () => {
|
|
199
|
+
expect(() => parseRequestArgv(['--method', 'GET'])).toThrow(CurlLocalArgvError);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ── Round-2 security addendum: these are exactly the argv forms that
|
|
203
|
+
// bypassed the PREVIOUS raw-curl-argv allowlist. None of them are
|
|
204
|
+
// "rejected flags" here — they're simply not part of this file's flag
|
|
205
|
+
// vocabulary at all, so they fall through to the generic
|
|
206
|
+
// "unrecognized flag" / "unexpected extra argument" path.
|
|
207
|
+
test('rejects the concatenated -K<path> config-file-smuggling PoC verbatim (round-2 finding #1)', () => {
|
|
208
|
+
expect(() => parseRequestArgv(['http://127.0.0.1:1/', '-Kevil.conf'])).toThrow(
|
|
209
|
+
CurlLocalArgvError
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('rejects a caller-supplied --noproxy override verbatim (round-2 finding #2)', () => {
|
|
214
|
+
expect(() => parseRequestArgv(['http://127.0.0.1:1/', '--noproxy', ''])).toThrow(
|
|
215
|
+
CurlLocalArgvError
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('rejects the concatenated -x<url>/-T<path> short forms (round-2 finding #3)', () => {
|
|
220
|
+
expect(() => parseRequestArgv(['-xhttp://evil.example', 'http://127.0.0.1:1/'])).toThrow(
|
|
221
|
+
CurlLocalArgvError
|
|
222
|
+
);
|
|
223
|
+
expect(() => parseRequestArgv(['http://127.0.0.1:1/', '-T/etc/passwd'])).toThrow(
|
|
224
|
+
CurlLocalArgvError
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('rejects the original --resolve/--connect-to target-override PoC verbatim', () => {
|
|
229
|
+
expect(() =>
|
|
230
|
+
parseRequestArgv(['--resolve', 'localhost:80:203.0.113.5', 'http://localhost/'])
|
|
231
|
+
).toThrow(CurlLocalArgvError);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('a value containing curl-flag-shaped text is inert data, not a flag — no injection via --data', () => {
|
|
235
|
+
// "@/etc/passwd" as a --data value must never be treated as curl's
|
|
236
|
+
// "read a file" shorthand (that's -d/--data's behavior, not
|
|
237
|
+
// --data-raw's, which buildCurlArgs always uses — see the live
|
|
238
|
+
// end-to-end test below for the full round-trip proof).
|
|
239
|
+
const req = parseRequestArgv([
|
|
240
|
+
'http://localhost:3000/',
|
|
241
|
+
'--data',
|
|
242
|
+
'@/etc/passwd; -K evil.conf',
|
|
243
|
+
]);
|
|
244
|
+
expect(req.data).toBe('@/etc/passwd; -K evil.conf');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("buildCurlArgs — every flag name is one of THIS file's own hardcoded literals", () => {
|
|
249
|
+
test('a bare GET pins the connection and forces the safety flags', () => {
|
|
250
|
+
const req = parseRequestArgv(['http://localhost:3000/']);
|
|
251
|
+
const args = buildCurlArgs(req, '127.0.0.1', 3000);
|
|
252
|
+
expect(args).toContain('--resolve');
|
|
253
|
+
expect(args[args.indexOf('--resolve') + 1]).toBe('localhost:3000:127.0.0.1');
|
|
254
|
+
expect(args).toContain('--noproxy');
|
|
255
|
+
expect(args).toContain('-q'); // curlrc disabled
|
|
256
|
+
expect(args).toEqual(expect.arrayContaining(['--max-redirs', '0']));
|
|
257
|
+
expect(args.at(-1)).toBe('http://localhost:3000/'); // the URL, always last, after `--`
|
|
258
|
+
expect(args.at(-2)).toBe('--');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('POST + headers + data map to -X/-H/--data-raw (never -d, which treats a leading @ as a file path)', () => {
|
|
262
|
+
const req = parseRequestArgv([
|
|
263
|
+
'http://localhost:3000/api',
|
|
264
|
+
'--method',
|
|
265
|
+
'POST',
|
|
266
|
+
'--header',
|
|
267
|
+
'Content-Type: application/json',
|
|
268
|
+
'--data',
|
|
269
|
+
'@/etc/passwd',
|
|
270
|
+
]);
|
|
271
|
+
const args = buildCurlArgs(req, '127.0.0.1', 3000);
|
|
272
|
+
expect(args).toContain('-X');
|
|
273
|
+
expect(args[args.indexOf('-X') + 1]).toBe('POST');
|
|
274
|
+
expect(args).toContain('-H');
|
|
275
|
+
expect(args[args.indexOf('-H') + 1]).toBe('Content-Type: application/json');
|
|
276
|
+
expect(args).toContain('--data-raw');
|
|
277
|
+
expect(args).not.toContain('-d');
|
|
278
|
+
expect(args[args.indexOf('--data-raw') + 1]).toBe('@/etc/passwd');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# agent-browser-safe.sh — hardened agent-browser wrapper. Thin shim over
|
|
3
|
+
# _agent-browser-safe.mjs; reached via `maude design agent-browser-safe`
|
|
4
|
+
# (DDR-062), never a raw bin path. See _agent-browser-safe.mjs for the full
|
|
5
|
+
# security rationale (DDR-185 security addendum).
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# agent-browser-safe.sh <subcommand> [args...]
|
|
9
|
+
#
|
|
10
|
+
# Allowed subcommands: open, eval, screenshot, snapshot, get, wait, close.
|
|
11
|
+
# Forces --allowed-domains localhost,127.0.0.1 and a non-persistent profile.
|
|
12
|
+
# Exit: agent-browser's own exit code · 2 usage/rejected argv · 1 other.
|
|
13
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
14
|
+
|
|
15
|
+
if ! command -v agent-browser >/dev/null 2>&1; then
|
|
16
|
+
echo "agent-browser-safe.sh: agent-browser is required." >&2
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# Prefer node (always present with a maude install); fall back to bun in a dev
|
|
21
|
+
# tree that has bun but a shimmed node. The module is pure Node ESM — no .ts.
|
|
22
|
+
if command -v node >/dev/null 2>&1; then
|
|
23
|
+
exec node "$SCRIPT_DIR/_agent-browser-safe.mjs" "$@"
|
|
24
|
+
elif command -v bun >/dev/null 2>&1; then
|
|
25
|
+
exec bun run "$SCRIPT_DIR/_agent-browser-safe.mjs" "$@"
|
|
26
|
+
else
|
|
27
|
+
echo "agent-browser-safe.sh: node (or bun) is required." >&2
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|