~james/core

core/web/src/lib/publicFetch.ts -rw-r--r-- 3.7 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import { dev } from "$app/env";
import type { Fetch } from "#lib/fetch.ts";

/*
 * Fetching where a record or a DID document points. Those hosts are whatever their author
 * wrote, so on the server they could aim our requests at our own network: the cloud
 * metadata address, a compose service, localhost. A target has to be https on a
 * public-looking name, and so does every redirect it answers with. A public name that
 * resolves to a private address still gets through; catching that needs a resolver check
 * this can't make portably across node and workers. Local dev talks to loopback services,
 * so it's let through there.
 */

const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".home.arpa"];
const IPV4 = /^(?:\d{1,3}\.){3}\d{1,3}$/;
const MAX_REDIRECTS = 3;

/** A name on the public internet: not an ip literal, a single-label service name, or an
 *  internal-use suffix. A trailing dot (`localhost.`) is the same name. */
export const isPublicHost = (hostname: string): boolean => {
	const host = hostname.toLowerCase().replace(/\.+$/, "");
	// ipv6 literals keep their colons (and brackets) in hostname
	if (host === "" || host.includes(":") || IPV4.test(host)) return false;
	if (!host.includes(".")) return false;
	return !BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix));
};

/** https on a public name, or anything at all in local dev. */
export const isPublicUrl = (url: URL, allowPrivate = dev): boolean =>
	allowPrivate || (url.protocol === "https:" && isPublicHost(url.hostname));

export class UnsafeTargetError extends Error {
	constructor(url: URL) {
		super(`refusing to fetch ${url.origin}: not a public https host`);
		this.name = "UnsafeTargetError";
	}
}

/**
 * `fetch` to a public https target, following up to MAX_REDIRECTS redirects only while
 * each hop is public too. Rejects with UnsafeTargetError otherwise. Server-side only: a
 * browser hides a manual redirect's location.
 */
export async function fetchPublic(
	fetch: Fetch,
	target: string | URL,
	init: RequestInit = {},
	allowPrivate = dev
): Promise<Response> {
	let url = new URL(target);
	for (let hop = 0; ; hop++) {
		if (!isPublicUrl(url, allowPrivate)) throw new UnsafeTargetError(url);
		const res = await fetch(url, { ...init, redirect: "manual" });
		const location = res.headers.get("location");
		if (res.status < 300 || res.status >= 400 || !location) return res;
		if (hop >= MAX_REDIRECTS) throw new Error(`too many redirects from ${url.origin}`);
		await res.body?.cancel();
		url = new URL(location, url);
	}
}

/** A fetch that only reaches public https targets and follows no redirects: for xrpc
 *  services named in records, which have no business redirecting. */
export const publicOnly =
	(fetch: Fetch, allowPrivate = dev): Fetch =>
	(input, init) => {
		const url = new URL(input instanceof Request ? input.url : input);
		if (!isPublicUrl(url, allowPrivate)) return Promise.reject(new UnsafeTargetError(url));
		return fetch(input, { ...init, redirect: "error" });
	};

/** The body as text, failing once it passes `maxBytes` rather than buffering it whole. */
export async function readTextCapped(res: Response, maxBytes: number): Promise<string> {
	if (Number(res.headers.get("content-length")) > maxBytes) {
		await res.body?.cancel();
		throw new Error(`response over ${maxBytes} bytes`);
	}
	if (!res.body) return "";
	const reader = res.body.getReader();
	const decoder = new TextDecoder();
	let total = 0;
	let text = "";
	for (;;) {
		const { done, value } = await reader.read();
		if (done) break;
		total += value.byteLength;
		if (total > maxBytes) {
			await reader.cancel();
			throw new Error(`response over ${maxBytes} bytes`);
		}
		text += decoder.decode(value, { stream: true });
	}
	return text + decoder.decode();
}