import { dev } from "$app/env"; /* * 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: typeof globalThis.fetch, target: string | URL, init: RequestInit = {}, allowPrivate = dev ): Promise { 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: typeof globalThis.fetch, allowPrivate = dev): typeof globalThis.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 { 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(); }