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();
}