@bygghammar/cms-platform 0.95.0 → 0.96.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.
@@ -0,0 +1,565 @@
1
+ /**
2
+ * The one way server code fetches a URL that staff or configuration chose.
3
+ *
4
+ * Media imports, form webhooks and popup webhooks all fetch addresses a person
5
+ * typed in. Each of those is a server-side request forgery primitive unless
6
+ * the request is held to the public internet, so every such request goes
7
+ * through `safeRequest`, which:
8
+ *
9
+ * - accepts only the schemes its policy names (webhooks: https only), and no
10
+ * credentials, local or single-label host names, or privileged ports;
11
+ * - resolves the host name and refuses it unless *every* address it resolves
12
+ * to is public (`outbound-address`): a name that also answers 127.0.0.1 is
13
+ * refused, not tried;
14
+ * - connects to the address it validated — the connection is pinned, so a DNS
15
+ * answer that changes between the check and the connection (rebinding)
16
+ * cannot redirect it, while TLS is still verified against the host name;
17
+ * - follows redirects itself, validating and pinning every hop, up to a cap.
18
+ * A webhook never follows one to another origin (it carries a signed body
19
+ * and its signature); and when a hop changes origin or turns into a GET,
20
+ * only plain request headers go with it, never entity or signature ones;
21
+ * - runs under one deadline, and reads at most `maxResponseBytes`, streaming,
22
+ * whatever `Content-Length` claims. The deadline holds even against a
23
+ * resolver or transport that ignores cancellation (Node's `dns.lookup`
24
+ * cannot be cancelled at all);
25
+ * - reports failures by category with an address-free message, never a query
26
+ * string, a resolved address or a response body.
27
+ *
28
+ * DNS resolution and pinned connections need Node (`node:dns`, `node:http`,
29
+ * `node:https`), which is what the self-hosted server runs. Where those are
30
+ * missing the request fails closed (`dns`), rather than going out unchecked.
31
+ *
32
+ * Server-only. Never import from client code.
33
+ */
34
+ import { addressFamily, isPublicAddress, type AddressFamily } from "./outbound-address";
35
+ import { isBlockedHostname } from "./webhook-url-guard";
36
+
37
+ export { redactUrl } from "../util/redact-url";
38
+
39
+ export type OutboundPolicy = {
40
+ readonly schemes: ReadonlyArray<"https:" | "http:">;
41
+ readonly maxRedirects: number;
42
+ /** One deadline for the whole request: every hop, DNS, connect and body. */
43
+ readonly timeoutMs: number;
44
+ readonly maxResponseBytes: number;
45
+ /** Whether a redirect may lead to another origin (scheme, host or port). */
46
+ readonly crossOriginRedirects: boolean;
47
+ };
48
+
49
+ /** Configured webhooks: https only, a short deadline, a small reply. */
50
+ export const WEBHOOK_POLICY: OutboundPolicy = Object.freeze({
51
+ schemes: ["https:"] as const,
52
+ maxRedirects: 3,
53
+ timeoutMs: 10_000,
54
+ maxResponseBytes: 64 * 1024,
55
+ // A webhook goes to the receiver an administrator named, and to no one else:
56
+ // the signed body and its signature must not follow a redirect elsewhere.
57
+ crossOriginRedirects: false,
58
+ });
59
+
60
+ /** Staff media imports: the web as it is (http too), a longer deadline, a media-sized cap. */
61
+ export const MEDIA_IMPORT_POLICY: OutboundPolicy = Object.freeze({
62
+ schemes: ["https:", "http:"] as const,
63
+ maxRedirects: 5,
64
+ timeoutMs: 30_000,
65
+ maxResponseBytes: 50 * 1024 * 1024,
66
+ // A download carries nothing secret, and files live on CDNs.
67
+ crossOriginRedirects: true,
68
+ });
69
+
70
+ export type OutboundFailure =
71
+ | "blocked-url"
72
+ | "blocked-address"
73
+ | "dns"
74
+ | "timeout"
75
+ | "network"
76
+ | "too-large"
77
+ | "too-many-redirects"
78
+ | "cross-origin-redirect";
79
+
80
+ const MESSAGES: Record<OutboundFailure, string> = {
81
+ "blocked-url": "That address is not allowed: it must be a public http(s) address.",
82
+ "blocked-address": "That address leads inside this server's own network.",
83
+ dns: "That address could not be looked up.",
84
+ timeout: "The address did not answer in time.",
85
+ network: "The address could not be reached.",
86
+ "too-large": "The reply was too large.",
87
+ "too-many-redirects": "The address redirected too many times.",
88
+ "cross-origin-redirect": "The address redirected to another site.",
89
+ };
90
+
91
+ export type ResolvedAddress = { readonly address: string; readonly family: AddressFamily };
92
+
93
+ /** Looks a host name up. Injected in tests; Node's resolver otherwise. */
94
+ export type Resolver = (hostname: string, signal: AbortSignal) => Promise<readonly ResolvedAddress[]>;
95
+
96
+ export type TransportRequest = {
97
+ readonly url: URL;
98
+ /** The validated address to connect to. */
99
+ readonly address: ResolvedAddress;
100
+ readonly method: string;
101
+ readonly headers: Readonly<Record<string, string>>;
102
+ /** Sent as given: a signed body must reach the receiver byte for byte. */
103
+ readonly body?: string | Uint8Array;
104
+ readonly signal: AbortSignal;
105
+ };
106
+
107
+ export type TransportResponse = {
108
+ readonly status: number;
109
+ readonly headers: Headers;
110
+ readonly body: AsyncIterable<Uint8Array> | null;
111
+ /** Stops reading and releases the connection. */
112
+ close(): void;
113
+ };
114
+
115
+ /** Makes one request to one pinned address. Injected in tests; Node's http(s) otherwise. */
116
+ export type Transport = (request: TransportRequest) => Promise<TransportResponse>;
117
+
118
+ export type SafeRequestInput = {
119
+ readonly url: string;
120
+ readonly method?: "GET" | "POST";
121
+ readonly headers?: Readonly<Record<string, string>>;
122
+ readonly body?: string | Uint8Array;
123
+ readonly policy: OutboundPolicy;
124
+ readonly resolver?: Resolver;
125
+ readonly transport?: Transport;
126
+ readonly signal?: AbortSignal;
127
+ };
128
+
129
+ export type SafeResponse = {
130
+ readonly ok: true;
131
+ readonly status: number;
132
+ readonly headers: Headers;
133
+ readonly body: Uint8Array;
134
+ /** The address the final hop was made to. */
135
+ readonly url: string;
136
+ };
137
+
138
+ export type SafeFailure = {
139
+ readonly ok: false;
140
+ readonly failure: OutboundFailure;
141
+ /** Safe to show and to log: no address, no query string, no body. */
142
+ readonly message: string;
143
+ };
144
+
145
+ const MAX_URL_LENGTH = 2048;
146
+
147
+ const fail = (failure: OutboundFailure): SafeFailure => ({
148
+ ok: false,
149
+ failure,
150
+ message: MESSAGES[failure],
151
+ });
152
+
153
+ /** The host as a bare address when it is an IP literal, else null. */
154
+ function literalAddress(url: URL): ResolvedAddress | null {
155
+ const host = url.hostname.replace(/^\[|\]$/g, "");
156
+ const family = addressFamily(host);
157
+ return family === null ? null : { address: host, family };
158
+ }
159
+
160
+ /**
161
+ * Whether a URL may be requested at all, before any lookup.
162
+ *
163
+ * The WHATWG URL parser has already turned decimal, hex, octal and shorthand
164
+ * IPv4 forms (`2130706433`, `0x7f.1`) into dotted quads, so the address checks
165
+ * see the address the connection would use.
166
+ */
167
+ export function checkOutboundUrl(
168
+ raw: string | URL,
169
+ policy: Pick<OutboundPolicy, "schemes">,
170
+ ): { ok: true; url: URL } | SafeFailure {
171
+ let url: URL;
172
+ try {
173
+ const text = typeof raw === "string" ? raw.trim() : raw.href;
174
+ if (text.length === 0 || text.length > MAX_URL_LENGTH) return fail("blocked-url");
175
+ url = new URL(text);
176
+ } catch {
177
+ return fail("blocked-url");
178
+ }
179
+ if (!(policy.schemes as readonly string[]).includes(url.protocol)) return fail("blocked-url");
180
+ if (url.username !== "" || url.password !== "") return fail("blocked-url");
181
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
182
+ if (host === "") return fail("blocked-url");
183
+ const literal = literalAddress(url);
184
+ if (literal) {
185
+ if (!isPublicAddress(literal.address)) return fail("blocked-address");
186
+ } else {
187
+ // A name with no dot is a name on the local network (a container, a host
188
+ // in /etc/hosts), never a public site.
189
+ if (!host.includes(".") || isBlockedHostname(host)) return fail("blocked-url");
190
+ }
191
+ // Privileged ports other than the web's own belong to other protocols.
192
+ if (url.port !== "") {
193
+ const port = Number(url.port);
194
+ if (port < 1024 && port !== 80 && port !== 443) return fail("blocked-url");
195
+ }
196
+ return { ok: true, url };
197
+ }
198
+
199
+ /** Every address the host resolves to, or why the request may not be made. */
200
+ export async function resolvePublicAddresses(
201
+ url: URL,
202
+ resolver: Resolver,
203
+ signal: AbortSignal,
204
+ ): Promise<{ ok: true; addresses: readonly ResolvedAddress[] } | SafeFailure> {
205
+ const literal = literalAddress(url);
206
+ if (literal) {
207
+ return isPublicAddress(literal.address) ? { ok: true, addresses: [literal] } : fail("blocked-address");
208
+ }
209
+ let addresses: readonly ResolvedAddress[];
210
+ try {
211
+ addresses = await untilAborted(
212
+ Promise.resolve().then(() => resolver(url.hostname, signal)),
213
+ signal,
214
+ );
215
+ } catch {
216
+ return signal.aborted ? fail("timeout") : fail("dns");
217
+ }
218
+ if (addresses.length === 0) return fail("dns");
219
+ // All or nothing: one private answer among public ones is the rebinding setup.
220
+ if (!addresses.every((entry) => isPublicAddress(entry.address))) return fail("blocked-address");
221
+ return { ok: true, addresses };
222
+ }
223
+
224
+ async function readCapped(
225
+ body: AsyncIterable<Uint8Array> | null,
226
+ limit: number,
227
+ ): Promise<Uint8Array | null> {
228
+ if (body === null) return new Uint8Array(0);
229
+ const chunks: Uint8Array[] = [];
230
+ let total = 0;
231
+ for await (const chunk of body) {
232
+ total += chunk.byteLength;
233
+ if (total > limit) return null;
234
+ chunks.push(chunk);
235
+ }
236
+ const out = new Uint8Array(total);
237
+ let at = 0;
238
+ for (const chunk of chunks) {
239
+ out.set(chunk, at);
240
+ at += chunk.byteLength;
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * `work`, or a rejection the moment `signal` aborts — whichever comes first.
247
+ *
248
+ * For work that does not listen to the signal (Node's `dns.lookup`, an
249
+ * injected transport, a stalled body): the deadline holds regardless. What the
250
+ * work does after losing is absorbed — a late rejection is not an unhandled
251
+ * one, and a late value is handed to `onLate` (to close a late connection).
252
+ */
253
+ function untilAborted<T>(
254
+ work: Promise<T>,
255
+ signal: AbortSignal,
256
+ onLate?: (value: T) => void,
257
+ ): Promise<T> {
258
+ return new Promise<T>((resolve, reject) => {
259
+ let settled = false;
260
+ const onAbort = () => {
261
+ if (settled) return;
262
+ settled = true;
263
+ reject(abortError());
264
+ };
265
+ work.then(
266
+ (value) => {
267
+ if (settled) {
268
+ onLate?.(value);
269
+ return;
270
+ }
271
+ settled = true;
272
+ signal.removeEventListener("abort", onAbort);
273
+ resolve(value);
274
+ },
275
+ (error: unknown) => {
276
+ if (settled) return;
277
+ settled = true;
278
+ signal.removeEventListener("abort", onAbort);
279
+ reject(error);
280
+ },
281
+ );
282
+ if (signal.aborted) onAbort();
283
+ else signal.addEventListener("abort", onAbort, { once: true });
284
+ });
285
+ }
286
+
287
+ function abortError(): Error {
288
+ const error = new Error("The operation was aborted.");
289
+ error.name = "AbortError";
290
+ return error;
291
+ }
292
+
293
+ /**
294
+ * The request headers that may go with a hop to another origin, or with a
295
+ * request a redirect turned into a GET. Nothing that describes a body
296
+ * (`content-*`) or vouches for one (signatures, timestamps, event ids) does.
297
+ */
298
+ const PLAIN_HEADERS = new Set(["user-agent", "accept", "accept-language"]);
299
+
300
+ function plainHeadersOnly(headers: Readonly<Record<string, string>>): Record<string, string> {
301
+ return Object.fromEntries(
302
+ Object.entries(headers).filter(([name]) => PLAIN_HEADERS.has(name.toLowerCase())),
303
+ );
304
+ }
305
+
306
+ /** What the next hop of a redirect is: method and body per the Fetch standard. */
307
+ function redirectedRequest(
308
+ status: number,
309
+ method: string,
310
+ body: string | Uint8Array | undefined,
311
+ ): { method: string; body: string | Uint8Array | undefined } {
312
+ if (status === 307 || status === 308) return { method, body };
313
+ if (status === 303 || ((status === 301 || status === 302) && method === "POST")) {
314
+ return { method: method === "HEAD" ? "HEAD" : "GET", body: undefined };
315
+ }
316
+ return { method, body };
317
+ }
318
+
319
+ /** Make one request under the policy. Never throws. */
320
+ export async function safeRequest(input: SafeRequestInput): Promise<SafeResponse | SafeFailure> {
321
+ const controller = new AbortController();
322
+ const timer = setTimeout(() => controller.abort(), input.policy.timeoutMs);
323
+ const onAbort = () => controller.abort();
324
+ input.signal?.addEventListener("abort", onAbort, { once: true });
325
+ const signal = controller.signal;
326
+
327
+ let method: string = input.method ?? "GET";
328
+ let body = input.body;
329
+ let headers: Readonly<Record<string, string>> = input.headers ?? {};
330
+ let target: string | URL = input.url;
331
+
332
+ try {
333
+ const resolver = input.resolver ?? (await nodeResolver());
334
+ const transport = input.transport ?? (await nodeTransport());
335
+ if (!resolver || !transport) return fail("dns");
336
+
337
+ for (let hop = 0; hop <= input.policy.maxRedirects; hop += 1) {
338
+ const checked = checkOutboundUrl(target, input.policy);
339
+ if (!checked.ok) return checked;
340
+ const url = checked.url;
341
+ const resolved = await resolvePublicAddresses(url, resolver, signal);
342
+ if (!resolved.ok) return resolved;
343
+ if (signal.aborted) return fail("timeout");
344
+
345
+ let response: TransportResponse;
346
+ try {
347
+ response = await untilAborted(
348
+ Promise.resolve().then(() =>
349
+ transport({ url, address: resolved.addresses[0]!, method, headers, body, signal }),
350
+ ),
351
+ signal,
352
+ (late) => late.close(),
353
+ );
354
+ } catch (caught) {
355
+ return signal.aborted || isAbortError(caught) ? fail("timeout") : fail("network");
356
+ }
357
+
358
+ if (response.status >= 300 && response.status < 400 && response.status !== 304) {
359
+ const location = response.headers.get("location");
360
+ response.close();
361
+ if (location === null) {
362
+ return { ok: true, status: response.status, headers: response.headers, body: new Uint8Array(0), url: url.href };
363
+ }
364
+ let next: URL;
365
+ try {
366
+ next = new URL(location, url);
367
+ } catch {
368
+ return fail("blocked-url");
369
+ }
370
+ // Refused before the next hop is even looked up.
371
+ const crossOrigin = next.origin !== url.origin;
372
+ if (crossOrigin && !input.policy.crossOriginRedirects) return fail("cross-origin-redirect");
373
+ const redirected = redirectedRequest(response.status, method, body);
374
+ if (crossOrigin || redirected.method !== method) headers = plainHeadersOnly(headers);
375
+ ({ method, body } = redirected);
376
+ target = next;
377
+ continue;
378
+ }
379
+
380
+ const declared = Number(response.headers.get("content-length"));
381
+ if (Number.isFinite(declared) && declared > input.policy.maxResponseBytes) {
382
+ response.close();
383
+ return fail("too-large");
384
+ }
385
+ let bytes: Uint8Array | null;
386
+ try {
387
+ bytes = await untilAborted(readCapped(response.body, input.policy.maxResponseBytes), signal);
388
+ } catch {
389
+ response.close();
390
+ return signal.aborted ? fail("timeout") : fail("network");
391
+ }
392
+ if (bytes === null) {
393
+ response.close();
394
+ return fail("too-large");
395
+ }
396
+ return { ok: true, status: response.status, headers: response.headers, body: bytes, url: url.href };
397
+ }
398
+ return fail("too-many-redirects");
399
+ } catch {
400
+ return signal.aborted ? fail("timeout") : fail("network");
401
+ } finally {
402
+ clearTimeout(timer);
403
+ input.signal?.removeEventListener("abort", onAbort);
404
+ }
405
+ }
406
+
407
+ const isAbortError = (caught: unknown) =>
408
+ caught instanceof Error && (caught.name === "AbortError" || caught.name === "TimeoutError");
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // Injected fetch (test seam)
412
+ // ---------------------------------------------------------------------------
413
+
414
+ /**
415
+ * A transport over an injected `fetch`, for tests that stand in for the
416
+ * network with one. Every hop is still checked and followed by `safeRequest`
417
+ * (the fetch is asked never to follow a redirect itself), but the connection
418
+ * is the fetch's own: it is not pinned. Production code passes no fetch.
419
+ */
420
+ export function fetchTransport(fetchImpl: typeof fetch): Transport {
421
+ return async (request) => {
422
+ const response = await fetchImpl(request.url.href, {
423
+ method: request.method,
424
+ headers: { ...request.headers },
425
+ body: request.body as BodyInit | undefined,
426
+ redirect: "manual",
427
+ signal: request.signal,
428
+ });
429
+ const stream = response.body;
430
+ return {
431
+ status: response.status,
432
+ headers: response.headers,
433
+ body: stream ? streamChunks(stream) : null,
434
+ close: () => void stream?.cancel().catch(() => undefined),
435
+ };
436
+ };
437
+ }
438
+
439
+ /** With an injected fetch there is no real lookup: names stand for a public host. */
440
+ export const injectedFetchResolver: Resolver = async () => [{ address: "192.88.98.1", family: 4 }];
441
+
442
+ async function* streamChunks(stream: ReadableStream<Uint8Array>): AsyncIterable<Uint8Array> {
443
+ const reader = stream.getReader();
444
+ try {
445
+ for (;;) {
446
+ const { done, value } = await reader.read();
447
+ if (done) return;
448
+ if (value) yield value;
449
+ }
450
+ } finally {
451
+ await reader.cancel().catch(() => undefined);
452
+ }
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Node implementations
457
+ // ---------------------------------------------------------------------------
458
+
459
+ /** Node's resolver, or null where there is none (fail closed). */
460
+ async function nodeResolver(): Promise<Resolver | null> {
461
+ try {
462
+ const dns = await import("node:dns");
463
+ return async (hostname) => {
464
+ const answers = await dns.promises.lookup(hostname, { all: true, verbatim: true });
465
+ return answers.map((a) => ({ address: a.address, family: a.family === 6 ? 6 : 4 }));
466
+ };
467
+ } catch {
468
+ return null;
469
+ }
470
+ }
471
+
472
+ /** Node's http(s) client, connecting only to the pinned address. Null where there is none. */
473
+ async function nodeTransport(): Promise<Transport | null> {
474
+ let http: typeof import("node:http");
475
+ let https: typeof import("node:https");
476
+ try {
477
+ http = await import("node:http");
478
+ https = await import("node:https");
479
+ } catch {
480
+ return null;
481
+ }
482
+ return (request) =>
483
+ new Promise<TransportResponse>((resolve, reject) => {
484
+ const { url, address } = request;
485
+ const secure = url.protocol === "https:";
486
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
487
+ const client = secure ? https : http;
488
+ const req = client.request(
489
+ {
490
+ protocol: url.protocol,
491
+ hostname,
492
+ port: url.port || (secure ? 443 : 80),
493
+ path: `${url.pathname}${url.search}`,
494
+ method: request.method,
495
+ headers: { ...request.headers, host: url.host },
496
+ // Pinned: whatever the name resolves to now, connect to the address
497
+ // that was checked. TLS still verifies the certificate for `hostname`.
498
+ lookup: (_name, options, callback) => {
499
+ if ((options as { all?: boolean }).all) {
500
+ (callback as (e: null, a: Array<{ address: string; family: number }>) => void)(null, [
501
+ { address: address.address, family: address.family },
502
+ ]);
503
+ } else {
504
+ (callback as (e: null, a: string, f: number) => void)(null, address.address, address.family);
505
+ }
506
+ },
507
+ agent: false,
508
+ signal: request.signal,
509
+ },
510
+ (res) => {
511
+ const headers = new Headers();
512
+ for (const [name, value] of Object.entries(res.headers)) {
513
+ if (value === undefined) continue;
514
+ for (const v of Array.isArray(value) ? value : [value]) headers.append(name, v);
515
+ }
516
+ resolve({
517
+ status: res.statusCode ?? 0,
518
+ headers,
519
+ body: res as AsyncIterable<Uint8Array>,
520
+ close: () => res.destroy(),
521
+ });
522
+ },
523
+ );
524
+ req.on("error", reject);
525
+ if (request.body !== undefined) req.end(Buffer.from(request.body));
526
+ else req.end();
527
+ });
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Media
532
+ // ---------------------------------------------------------------------------
533
+
534
+ export type MediaKind = { readonly mime: string; readonly ext: string };
535
+
536
+ /**
537
+ * What a downloaded file actually is, by its first bytes — never by what the
538
+ * server said or what the address ends in. SVG and HTML are not media here:
539
+ * served from the media bucket they would run script.
540
+ */
541
+ export function sniffMedia(bytes: Uint8Array): MediaKind | null {
542
+ const at = (offset: number, ...values: number[]) =>
543
+ values.every((value, i) => bytes[offset + i] === value);
544
+ const ascii = (offset: number, text: string) =>
545
+ at(offset, ...Array.from(text, (c) => c.charCodeAt(0)));
546
+ if (at(0, 0xff, 0xd8, 0xff)) return { mime: "image/jpeg", ext: ".jpg" };
547
+ if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return { mime: "image/png", ext: ".png" };
548
+ if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return { mime: "image/gif", ext: ".gif" };
549
+ if (ascii(0, "RIFF") && ascii(8, "WEBP")) return { mime: "image/webp", ext: ".webp" };
550
+ if (ascii(0, "RIFF") && ascii(8, "WAVE")) return { mime: "audio/wav", ext: ".wav" };
551
+ if (ascii(4, "ftyp")) {
552
+ const brand = String.fromCharCode(...bytes.slice(8, 12));
553
+ if (brand === "avif" || brand === "avis") return { mime: "image/avif", ext: ".avif" };
554
+ if (brand === "qt ") return { mime: "video/quicktime", ext: ".mov" };
555
+ if (brand.startsWith("M4V")) return { mime: "video/x-m4v", ext: ".m4v" };
556
+ return { mime: "video/mp4", ext: ".mp4" };
557
+ }
558
+ if (at(0, 0x1a, 0x45, 0xdf, 0xa3)) return { mime: "video/webm", ext: ".webm" };
559
+ if (ascii(0, "OggS")) return { mime: "audio/ogg", ext: ".ogg" };
560
+ if (ascii(0, "ID3") || at(0, 0xff, 0xfb) || at(0, 0xff, 0xf3) || at(0, 0xff, 0xf2)) {
561
+ return { mime: "audio/mpeg", ext: ".mp3" };
562
+ }
563
+ if (ascii(0, "%PDF-")) return { mime: "application/pdf", ext: ".pdf" };
564
+ return null;
565
+ }