@startsimpli/integrations 0.2.34 → 0.2.36

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.
@@ -0,0 +1,387 @@
1
+ /**
2
+ * safeFetch — server-side fetch of a USER-SUPPLIED url with SSRF guards
3
+ * (crochet_patterns-llmj: "paste a link to a pattern").
4
+ *
5
+ * SERVER-ONLY. Exported from the `@startsimpli/integrations/safe-fetch`
6
+ * subpath, never from the package root (client components import the root, and
7
+ * the default resolver is `node:dns`).
8
+ *
9
+ * Contract:
10
+ * - only http/https urls, no `user:pass@`;
11
+ * - every hop's host is resolved and EVERY address is checked
12
+ * (`isBlockedAddress`); redirects are followed by hand (`redirect: 'manual'`)
13
+ * so each hop is re-checked; at most `maxRedirects` (default 3);
14
+ * - only `allowTypes` content types (default text/html, application/pdf);
15
+ * - aborts over `maxBytes` (header or streamed) and after `timeoutMs`.
16
+ *
17
+ * DNS pinning (crochet_patterns-yfj8 #1, design D5): when `fetch` is not
18
+ * injected, requests go through npm `undici` with an Agent whose
19
+ * `connect.lookup` is `pinnedLookup(lookup)`. The check runs inside the lookup
20
+ * the socket uses, so the address that is checked is the address that is
21
+ * connected to — a record that flips to a private IP between the pre-flight
22
+ * check and connect is refused at connect.
23
+ *
24
+ * `lookup` and `fetch` are injectable so tests never touch the network.
25
+ */
26
+ import { lookup as dnsLookup } from 'node:dns/promises';
27
+ import { isIP, type LookupFunction } from 'node:net';
28
+ import { Agent, fetch as undiciFetch } from 'undici';
29
+
30
+ export type SafeFetchErrorCode =
31
+ | 'BAD_URL'
32
+ | 'BLOCKED_ADDRESS'
33
+ | 'TOO_MANY_REDIRECTS'
34
+ | 'UNSUPPORTED_TYPE'
35
+ | 'TOO_LARGE'
36
+ | 'TIMEOUT'
37
+ | 'HTTP_ERROR';
38
+
39
+ export class SafeFetchError extends Error {
40
+ constructor(
41
+ message: string,
42
+ public readonly code: SafeFetchErrorCode,
43
+ /** The hop that was refused, when there is one. */
44
+ public readonly url?: string,
45
+ ) {
46
+ super(message);
47
+ this.name = 'SafeFetchError';
48
+ }
49
+ }
50
+
51
+ export interface ResolvedAddress {
52
+ address: string;
53
+ family: 4 | 6;
54
+ }
55
+
56
+ export interface SafeFetchOptions {
57
+ /** Allowed content types (parameters like `; charset=` are ignored). Default text/html + application/pdf. */
58
+ allowTypes?: string[];
59
+ /** Byte cap on the body. Default 5 MB. */
60
+ maxBytes?: number;
61
+ /** Redirect hops allowed. Default 3. */
62
+ maxRedirects?: number;
63
+ /** Whole-request time cap. Default 10 s. */
64
+ timeoutMs?: number;
65
+ /** Resolves a hostname to ALL its addresses. Default: node:dns lookup({all:true}). */
66
+ lookup?: (hostname: string) => Promise<ResolvedAddress[]>;
67
+ /** Default: npm undici fetch with a DNS-pinning Agent (see `pinnedLookup`). */
68
+ fetch?: typeof fetch;
69
+ }
70
+
71
+ export interface SafeFetchResult {
72
+ /** The url that finally answered (after redirects). */
73
+ finalUrl: string;
74
+ status: number;
75
+ /** Bare media type, e.g. `text/html`. */
76
+ contentType: string;
77
+ body: Uint8Array;
78
+ }
79
+
80
+ export const SAFE_FETCH_DEFAULTS = {
81
+ allowTypes: ['text/html', 'application/pdf'],
82
+ maxBytes: 5 * 1024 * 1024,
83
+ maxRedirects: 3,
84
+ timeoutMs: 10_000,
85
+ } as const;
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Address blocklist
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function parseV4(addr: string): number[] | null {
92
+ const parts = addr.split('.');
93
+ if (parts.length !== 4) return null;
94
+ const bytes = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN));
95
+ return bytes.every((b) => b >= 0 && b <= 255) ? bytes : null;
96
+ }
97
+
98
+ function parseV6(addr: string): number[] | null {
99
+ let s = addr.toLowerCase();
100
+ const zone = s.indexOf('%');
101
+ if (zone !== -1) s = s.slice(0, zone);
102
+ // A trailing dotted quad (::ffff:1.2.3.4) becomes two hextets.
103
+ const lastColon = s.lastIndexOf(':');
104
+ const tail = s.slice(lastColon + 1);
105
+ if (tail.includes('.')) {
106
+ const v4 = parseV4(tail);
107
+ if (!v4) return null;
108
+ s = `${s.slice(0, lastColon + 1)}${((v4[0]! << 8) | v4[1]!).toString(16)}:${((v4[2]! << 8) | v4[3]!).toString(16)}`;
109
+ }
110
+ const halves = s.split('::');
111
+ if (halves.length > 2) return null;
112
+ const head = halves[0] ? halves[0].split(':') : [];
113
+ const rest = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
114
+ const missing = 8 - head.length - rest.length;
115
+ if (halves.length === 1 ? missing !== 0 : missing < 1) return null;
116
+ const hextets = [...head, ...Array<string>(halves.length === 2 ? missing : 0).fill('0'), ...rest];
117
+ const bytes: number[] = [];
118
+ for (const h of hextets) {
119
+ if (!/^[0-9a-f]{1,4}$/.test(h)) return null;
120
+ const n = parseInt(h, 16);
121
+ bytes.push(n >> 8, n & 0xff);
122
+ }
123
+ return bytes;
124
+ }
125
+
126
+ function isBlockedV4([a, b, c]: number[]): boolean {
127
+ return (
128
+ a === 0 || // 0/8 "this network", incl. 0.0.0.0
129
+ a === 10 || // 10/8
130
+ a === 127 || // loopback
131
+ (a === 100 && b! >= 64 && b! <= 127) || // 100.64/10 CGNAT
132
+ (a === 169 && b === 254) || // link-local, incl. cloud metadata 169.254.169.254
133
+ (a === 172 && b! >= 16 && b! <= 31) || // 172.16/12
134
+ (a === 192 && b === 0 && c === 0) || // 192.0.0/24 IETF protocol assignments
135
+ (a === 192 && b === 168) || // 192.168/16
136
+ (a === 198 && (b === 18 || b === 19)) || // 198.18/15 benchmarking
137
+ a! >= 224 // 224/4 multicast, 240/4 reserved, 255.255.255.255
138
+ );
139
+ }
140
+
141
+ function startsWith(bytes: number[], prefix: number[]): boolean {
142
+ return prefix.every((b, i) => bytes[i] === b);
143
+ }
144
+
145
+ function isBlockedV6(bytes: number[]): boolean {
146
+ const first = bytes[0]!;
147
+ if ((first & 0xfe) === 0xfc) return true; // fc00::/7 unique local
148
+ if (first === 0xfe && (bytes[1]! & 0xc0) === 0x80) return true; // fe80::/10 link-local
149
+ if (first === 0xfe && (bytes[1]! & 0xc0) === 0xc0) return true; // fec0::/10 site-local (deprecated)
150
+ if (first === 0xff) return true; // ff00::/8 multicast
151
+ const zero = (n: number) => Array<number>(n).fill(0);
152
+ // ::/96 — :: and ::1 and the deprecated v4-compatible form; checked as the embedded v4
153
+ // (:: -> 0.0.0.0, ::1 -> 0.0.0.1, both in 0/8).
154
+ if (startsWith(bytes, zero(12))) return isBlockedV4(bytes.slice(12));
155
+ // ::ffff:0:0/96 v4-mapped
156
+ if (startsWith(bytes, [...zero(10), 0xff, 0xff])) return isBlockedV4(bytes.slice(12));
157
+ // 64:ff9b::/96 NAT64 well-known prefix
158
+ if (startsWith(bytes, [0x00, 0x64, 0xff, 0x9b, ...zero(8)])) return isBlockedV4(bytes.slice(12));
159
+ // 64:ff9b:1::/48 local-use NAT64 — translates to whatever the local network chooses
160
+ if (startsWith(bytes, [0x00, 0x64, 0xff, 0x9b, 0x00, 0x01])) return true;
161
+ // 2002::/16 6to4 — the v4 is bytes 2..5
162
+ if (bytes[0] === 0x20 && bytes[1] === 0x02) return isBlockedV4(bytes.slice(2, 6));
163
+ return false;
164
+ }
165
+
166
+ /**
167
+ * True when a connection to `addr` must be refused: loopback, private,
168
+ * link-local (cloud metadata), CGNAT, benchmarking, multicast, reserved, the
169
+ * unspecified address, IPv6 loopback/ULA/link-local/multicast, and any IPv6
170
+ * form that embeds a blocked IPv4 (mapped, compatible, NAT64, 6to4).
171
+ * Anything that does not parse as an IP is refused (fail closed).
172
+ */
173
+ export function isBlockedAddress(addr: string): boolean {
174
+ const s = addr.startsWith('[') && addr.endsWith(']') ? addr.slice(1, -1) : addr;
175
+ const family = isIP(s.split('%')[0]!);
176
+ if (family === 4) {
177
+ const v4 = parseV4(s);
178
+ return !v4 || isBlockedV4(v4);
179
+ }
180
+ if (family === 6) {
181
+ const v6 = parseV6(s);
182
+ return !v6 || isBlockedV6(v6);
183
+ }
184
+ return true;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // DNS
189
+ // ---------------------------------------------------------------------------
190
+
191
+ async function dnsLookupAll(hostname: string): Promise<ResolvedAddress[]> {
192
+ const addrs = await dnsLookup(hostname, { all: true, verbatim: true });
193
+ return addrs.map((a) => ({ address: a.address, family: a.family === 6 ? 6 : 4 }));
194
+ }
195
+
196
+ /** Resolves `hostname` and throws BLOCKED_ADDRESS if ANY answer is blocked. */
197
+ async function resolveChecked(
198
+ resolve: (hostname: string) => Promise<ResolvedAddress[]>,
199
+ hostname: string,
200
+ url?: string,
201
+ ): Promise<ResolvedAddress[]> {
202
+ let addrs: ResolvedAddress[];
203
+ try {
204
+ addrs = await resolve(hostname);
205
+ } catch (err) {
206
+ throw new SafeFetchError(`Could not resolve ${hostname}: ${(err as Error)?.message ?? err}`, 'BAD_URL', url);
207
+ }
208
+ if (!addrs.length) throw new SafeFetchError(`Could not resolve ${hostname}`, 'BAD_URL', url);
209
+ const blocked = addrs.find((a) => isBlockedAddress(a.address));
210
+ if (blocked) {
211
+ throw new SafeFetchError(`${hostname} resolves to a blocked address (${blocked.address})`, 'BLOCKED_ADDRESS', url);
212
+ }
213
+ return addrs;
214
+ }
215
+
216
+ /**
217
+ * A `dns.lookup`-shaped function for `net.connect` / undici `connect.lookup`.
218
+ * It resolves with `resolve`, refuses the whole answer if any address is
219
+ * blocked, and hands the socket only the addresses it validated — so the
220
+ * connection goes to an address that was checked, not to a second resolution.
221
+ * Handles both callback forms (`{all:true}` → array; otherwise address+family).
222
+ */
223
+ export function pinnedLookup(resolve: SafeFetchOptions['lookup'] = dnsLookupAll): LookupFunction {
224
+ return (hostname, options, callback) => {
225
+ const wanted = options?.family === 4 || (options?.family as unknown) === 'IPv4' ? 4
226
+ : options?.family === 6 || (options?.family as unknown) === 'IPv6' ? 6 : 0;
227
+ resolveChecked(resolve, hostname).then(
228
+ (addrs) => {
229
+ const list = wanted ? addrs.filter((a) => a.family === wanted) : addrs;
230
+ if (!list.length) {
231
+ const err = Object.assign(new Error(`No IPv${wanted} address for ${hostname}`), { code: 'ENOTFOUND' });
232
+ callback(err, '', 0);
233
+ return;
234
+ }
235
+ if (options?.all) callback(null, list.map((a) => ({ address: a.address, family: a.family })));
236
+ else callback(null, list[0]!.address, list[0]!.family);
237
+ },
238
+ (err: Error) => callback(err as NodeJS.ErrnoException, '', 0),
239
+ );
240
+ };
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // safeFetch
245
+ // ---------------------------------------------------------------------------
246
+
247
+ function parseHopUrl(raw: string | URL, base?: URL): URL {
248
+ let url: URL;
249
+ try {
250
+ url = new URL(raw, base);
251
+ } catch {
252
+ throw new SafeFetchError(`Not a valid url: ${String(raw)}`, 'BAD_URL', String(raw));
253
+ }
254
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
255
+ throw new SafeFetchError(`Only http and https urls are allowed (got ${url.protocol})`, 'BAD_URL', url.href);
256
+ }
257
+ if (url.username || url.password) {
258
+ throw new SafeFetchError('Urls with credentials are not allowed', 'BAD_URL', url.href);
259
+ }
260
+ return url;
261
+ }
262
+
263
+ function bareHost(url: URL): string {
264
+ const h = url.hostname;
265
+ return h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h;
266
+ }
267
+
268
+ /** Rejects as soon as `signal` aborts, so a hung resolver cannot outlive the time cap. */
269
+ function untilAborted<T>(p: Promise<T>, signal: AbortSignal): Promise<T> {
270
+ if (signal.aborted) return Promise.reject(new SafeFetchError('Timed out', 'TIMEOUT'));
271
+ return new Promise<T>((resolve, reject) => {
272
+ const onAbort = () => reject(new SafeFetchError('Timed out', 'TIMEOUT'));
273
+ signal.addEventListener('abort', onAbort, { once: true });
274
+ p.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
275
+ });
276
+ }
277
+
278
+ /** undici wraps connect failures as `TypeError('fetch failed', { cause })`. */
279
+ function findSafeFetchError(err: unknown): SafeFetchError | undefined {
280
+ for (let e: unknown = err, depth = 0; e && depth < 5; e = (e as { cause?: unknown }).cause, depth++) {
281
+ if (e instanceof SafeFetchError) return e;
282
+ }
283
+ return undefined;
284
+ }
285
+
286
+ async function readCapped(res: Response, maxBytes: number, url: string): Promise<Uint8Array> {
287
+ if (!res.body) return new Uint8Array(0);
288
+ const reader = res.body.getReader();
289
+ const chunks: Uint8Array[] = [];
290
+ let total = 0;
291
+ for (;;) {
292
+ const { done, value } = await reader.read();
293
+ if (done) break;
294
+ total += value.byteLength;
295
+ if (total > maxBytes) {
296
+ await reader.cancel().catch(() => undefined);
297
+ throw new SafeFetchError(`Response is larger than ${maxBytes} bytes`, 'TOO_LARGE', url);
298
+ }
299
+ chunks.push(value);
300
+ }
301
+ const body = new Uint8Array(total);
302
+ let offset = 0;
303
+ for (const c of chunks) {
304
+ body.set(c, offset);
305
+ offset += c.byteLength;
306
+ }
307
+ return body;
308
+ }
309
+
310
+ export async function safeFetch(url: string, options: SafeFetchOptions = {}): Promise<SafeFetchResult> {
311
+ const allowTypes = (options.allowTypes ?? SAFE_FETCH_DEFAULTS.allowTypes).map((t) => t.toLowerCase());
312
+ const maxBytes = options.maxBytes ?? SAFE_FETCH_DEFAULTS.maxBytes;
313
+ const maxRedirects = options.maxRedirects ?? SAFE_FETCH_DEFAULTS.maxRedirects;
314
+ const timeoutMs = options.timeoutMs ?? SAFE_FETCH_DEFAULTS.timeoutMs;
315
+ const resolve = options.lookup ?? dnsLookupAll;
316
+
317
+ let current = parseHopUrl(url);
318
+
319
+ const controller = new AbortController();
320
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
321
+ let agent: Agent | undefined;
322
+ let fetchImpl: (input: string, init: RequestInit) => Promise<Response>;
323
+ if (options.fetch) {
324
+ fetchImpl = options.fetch;
325
+ } else {
326
+ // npm undici's own fetch: a npm-undici dispatcher must not be handed to Node's global fetch.
327
+ const pinned = new Agent({ connect: { lookup: pinnedLookup(resolve) } });
328
+ agent = pinned;
329
+ fetchImpl = (input, init) =>
330
+ undiciFetch(input, { ...(init as Parameters<typeof undiciFetch>[1]), dispatcher: pinned }) as unknown as Promise<Response>;
331
+ }
332
+
333
+ try {
334
+ for (let hop = 0; ; hop++) {
335
+ const host = bareHost(current);
336
+ // Pre-flight: an IP literal is checked as-is (never resolved); a name is resolved and every answer checked.
337
+ if (isIP(host)) {
338
+ if (isBlockedAddress(host)) {
339
+ throw new SafeFetchError(`Blocked address ${host}`, 'BLOCKED_ADDRESS', current.href);
340
+ }
341
+ } else {
342
+ await untilAborted(resolveChecked(resolve, host, current.href), controller.signal);
343
+ }
344
+
345
+ const res = await fetchImpl(current.href, { redirect: 'manual', signal: controller.signal });
346
+
347
+ if (res.status >= 300 && res.status < 400) {
348
+ const location = res.headers.get('location');
349
+ await res.body?.cancel().catch(() => undefined);
350
+ if (!location) throw new SafeFetchError(`HTTP ${res.status} without a Location`, 'HTTP_ERROR', current.href);
351
+ if (hop >= maxRedirects) {
352
+ throw new SafeFetchError(`More than ${maxRedirects} redirects`, 'TOO_MANY_REDIRECTS', current.href);
353
+ }
354
+ current = parseHopUrl(location, current);
355
+ continue;
356
+ }
357
+
358
+ if (!res.ok) {
359
+ await res.body?.cancel().catch(() => undefined);
360
+ throw new SafeFetchError(`HTTP ${res.status}`, 'HTTP_ERROR', current.href);
361
+ }
362
+
363
+ const contentType = (res.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();
364
+ if (!contentType || !allowTypes.includes(contentType)) {
365
+ await res.body?.cancel().catch(() => undefined);
366
+ throw new SafeFetchError(`Unsupported content type: ${contentType || '(none)'}`, 'UNSUPPORTED_TYPE', current.href);
367
+ }
368
+
369
+ const declared = Number(res.headers.get('content-length'));
370
+ if (Number.isFinite(declared) && declared > maxBytes) {
371
+ await res.body?.cancel().catch(() => undefined);
372
+ throw new SafeFetchError(`Response is larger than ${maxBytes} bytes`, 'TOO_LARGE', current.href);
373
+ }
374
+
375
+ const body = await readCapped(res, maxBytes, current.href);
376
+ return { finalUrl: current.href, status: res.status, contentType, body };
377
+ }
378
+ } catch (err) {
379
+ const own = findSafeFetchError(err);
380
+ if (own) throw own;
381
+ if (controller.signal.aborted) throw new SafeFetchError(`Timed out after ${timeoutMs} ms`, 'TIMEOUT', current.href);
382
+ throw new SafeFetchError(`Fetch failed: ${(err as Error)?.message ?? err}`, 'HTTP_ERROR', current.href);
383
+ } finally {
384
+ clearTimeout(timer);
385
+ if (agent) void agent.destroy().catch(() => undefined);
386
+ }
387
+ }