diff --git a/.env.example b/.env.example index 205d814b..eacadc77 100644 --- a/.env.example +++ b/.env.example @@ -133,6 +133,12 @@ SESSION_EXPIRY_DAYS=30 # Not currently used but reserved for future JWT-based auth # JWT_SECRET=your-secure-random-secret-here +# Allow outbound fetches (federation, media proxy, WebPush, nodeinfo) to +# private / loopback / link-local addresses (default: false). +# SECURITY: only enable for local development or federation testing on +# localhost. Never enable in production (SSRF risk). +# ALLOW_PRIVATE_NETWORK_FETCH=false + # ============================================================================= # Registration & Moderation # ============================================================================= diff --git a/packages/backend/src/lib/instance-icon.ts b/packages/backend/src/lib/instance-icon.ts index 709abbc2..f45edfa6 100644 --- a/packages/backend/src/lib/instance-icon.ts +++ b/packages/backend/src/lib/instance-icon.ts @@ -7,6 +7,7 @@ */ import { logger } from "./logger.js"; +import { safeFetch } from "./safeFetch.js"; /** * Try to fetch instance icon from software-specific APIs, then fall back to favicon @@ -56,7 +57,7 @@ export async function fetchInstanceIconFromApi( normalizedName === "calckey" || normalizedName === "cherrypick" ) { - const response = await fetch(`https://${host}/api/meta`, { + const response = await safeFetch(`https://${host}/api/meta`, { method: "POST", headers: { "Content-Type": "application/json", @@ -85,7 +86,7 @@ export async function fetchInstanceIconFromApi( normalizedName === "glitch" || normalizedName === "kmyblue" ) { - const response = await fetch(`https://${host}/api/v2/instance`, { + const response = await safeFetch(`https://${host}/api/v2/instance`, { headers: { Accept: "application/json", }, @@ -102,7 +103,7 @@ export async function fetchInstanceIconFromApi( } // Fall back to v1 API for older Mastodon versions - const v1Response = await fetch(`https://${host}/api/v1/instance`, { + const v1Response = await safeFetch(`https://${host}/api/v1/instance`, { headers: { Accept: "application/json", }, @@ -119,7 +120,7 @@ export async function fetchInstanceIconFromApi( // Pleroma / Akkoma if (normalizedName === "pleroma" || normalizedName === "akkoma") { - const response = await fetch(`https://${host}/api/v1/instance`, { + const response = await safeFetch(`https://${host}/api/v1/instance`, { headers: { Accept: "application/json", }, @@ -136,7 +137,7 @@ export async function fetchInstanceIconFromApi( // GoToSocial if (normalizedName === "gotosocial") { - const response = await fetch(`https://${host}/api/v1/instance`, { + const response = await safeFetch(`https://${host}/api/v1/instance`, { headers: { Accept: "application/json", }, @@ -155,7 +156,7 @@ export async function fetchInstanceIconFromApi( if (!normalizedName) { // Try Mastodon API first (most common) try { - const response = await fetch(`https://${host}/api/v1/instance`, { + const response = await safeFetch(`https://${host}/api/v1/instance`, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(5000), }); @@ -172,7 +173,7 @@ export async function fetchInstanceIconFromApi( // Try Misskey API try { - const response = await fetch(`https://${host}/api/meta`, { + const response = await safeFetch(`https://${host}/api/meta`, { method: "POST", headers: { "Content-Type": "application/json", @@ -217,7 +218,7 @@ export async function fetchFavicon(host: string): Promise { for (const path of faviconPaths) { try { const url = `https://${host}${path}`; - const response = await fetch(url, { + const response = await safeFetch(url, { method: "HEAD", signal: AbortSignal.timeout(5000), }); diff --git a/packages/backend/src/lib/safeFetch.ts b/packages/backend/src/lib/safeFetch.ts new file mode 100644 index 00000000..78cf2cd6 --- /dev/null +++ b/packages/backend/src/lib/safeFetch.ts @@ -0,0 +1,409 @@ +/** + * SSRF-safe outbound HTTP helpers + * + * Provides utilities to make outbound HTTP requests to attacker-controllable + * URLs (remote ActivityPub objects, inboxes, media, WebPush endpoints, ...) + * without exposing internal network services. + * + * - {@link isPrivateAddress} classifies IPv4/IPv6 literals + * - {@link assertPublicUrl} validates the scheme and resolves the hostname, + * rejecting it if ANY resolved address is non-public + * - {@link safeFetch} wraps `fetch` with manual redirect handling and + * re-validates every redirect hop + * + * Set `ALLOW_PRIVATE_NETWORK_FETCH=true` to disable the private address + * checks (local development / federation testing on localhost only). + * + * Note: DNS is resolved once for validation and again by `fetch` itself, so a + * DNS rebinding attacker with a very short TTL could still race the check. + * This module significantly narrows the attack surface but is not a + * substitute for network-level egress filtering. + * + * @module lib/safeFetch + */ + +import { isIP } from "node:net"; +import { lookup as dnsLookup } from "node:dns/promises"; + +/** + * Error thrown when a URL is rejected by SSRF protection. + */ +export class UnsafeUrlError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsafeUrlError"; + } +} + +/** + * DNS lookup function signature (subset of `dns.promises.lookup` with `all: true`). + */ +export type LookupFn = (hostname: string) => Promise>; + +/** + * Options for {@link assertPublicUrl}. + */ +export interface AssertPublicUrlOptions { + /** Custom DNS resolver (primarily for tests) */ + lookup?: LookupFn; + /** + * Allow private network destinations. Defaults to the + * `ALLOW_PRIVATE_NETWORK_FETCH` environment variable. + */ + allowPrivateNetwork?: boolean; +} + +/** + * Options for {@link safeFetch}. + */ +export interface SafeFetchOptions extends AssertPublicUrlOptions { + /** Maximum number of redirects to follow (default: 5). Use 0 to disable. */ + maxRedirects?: number; + /** Custom fetch implementation (primarily for tests). Defaults to global `fetch`. */ + fetchImpl?: typeof fetch; +} + +/** Default maximum number of redirect hops followed by {@link safeFetch}. */ +export const DEFAULT_MAX_REDIRECTS = 5; + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +/** + * Check whether private network fetches are allowed via environment. + * + * @returns True when `ALLOW_PRIVATE_NETWORK_FETCH=true` + */ +export function isPrivateNetworkFetchAllowed(): boolean { + return process.env.ALLOW_PRIVATE_NETWORK_FETCH === "true"; +} + +/** + * Parse a dotted-quad IPv4 address into four octets. + */ +function parseIPv4(ip: string): [number, number, number, number] | null { + const parts = ip.split("."); + if (parts.length !== 4) return null; + const octets = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN)); + if (octets.some((o) => Number.isNaN(o) || o > 255)) return null; + return octets as [number, number, number, number]; +} + +/** + * Check whether an IPv4 address is non-public. + */ +function isPrivateIPv4(octets: [number, number, number, number]): boolean { + const [a, b, c] = octets; + return ( + a === 0 || // 0.0.0.0/8 "this network" / unspecified + a === 10 || // 10.0.0.0/8 private + (a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 CGNAT + a === 127 || // 127.0.0.0/8 loopback + (a === 169 && b === 254) || // 169.254.0.0/16 link-local (cloud metadata) + (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private + (a === 192 && b === 0 && c === 0) || // 192.0.0.0/24 IETF protocol assignments + (a === 192 && b === 0 && c === 2) || // 192.0.2.0/24 TEST-NET-1 + (a === 192 && b === 88 && c === 99) || // 192.88.99.0/24 6to4 relay anycast + (a === 192 && b === 168) || // 192.168.0.0/16 private + (a === 198 && (b === 18 || b === 19)) || // 198.18.0.0/15 benchmarking + (a === 198 && b === 51 && c === 100) || // 198.51.100.0/24 TEST-NET-2 + (a === 203 && b === 0 && c === 113) || // 203.0.113.0/24 TEST-NET-3 + a >= 224 // 224.0.0.0/4 multicast, 240.0.0.0/4 reserved, broadcast + ); +} + +/** + * Parse an IPv6 address into eight 16-bit groups. + * Supports `::` compression and embedded IPv4 suffixes. + */ +function parseIPv6(ip: string): number[] | null { + let addr = ip; + // Embedded IPv4 (e.g. ::ffff:127.0.0.1) + const lastColon = addr.lastIndexOf(":"); + const tail = addr.slice(lastColon + 1); + if (tail.includes(".")) { + const v4 = parseIPv4(tail); + if (!v4) return null; + const hi = ((v4[0] << 8) | v4[1]).toString(16); + const lo = ((v4[2] << 8) | v4[3]).toString(16); + addr = `${addr.slice(0, lastColon + 1)}${hi}:${lo}`; + } + + const doubleColon = addr.indexOf("::"); + let groups: string[]; + if (doubleColon !== -1) { + if (addr.indexOf("::", doubleColon + 1) !== -1) return null; + const head = addr.slice(0, doubleColon); + const rest = addr.slice(doubleColon + 2); + const headParts = head ? head.split(":") : []; + const restParts = rest ? rest.split(":") : []; + const missing = 8 - headParts.length - restParts.length; + if (missing < 1) return null; + groups = [...headParts, ...Array(missing).fill("0"), ...restParts]; + } else { + groups = addr.split(":"); + } + + if (groups.length !== 8) return null; + const values = groups.map((g) => (/^[0-9a-f]{1,4}$/i.test(g) ? parseInt(g, 16) : NaN)); + if (values.some((v) => Number.isNaN(v))) return null; + return values; +} + +/** + * Extract an embedded IPv4 address from the last 32 bits of an IPv6 address. + */ +function embeddedIPv4(groups: number[], hiIndex = 6): [number, number, number, number] { + const hi = groups[hiIndex] ?? 0; + const lo = groups[hiIndex + 1] ?? 0; + return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff]; +} + +/** + * Check whether an IPv6 address is non-public. + */ +function isPrivateIPv6(g: number[]): boolean { + const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0] = g; + + // ::/96 — unspecified (::), loopback (::1), deprecated IPv4-compatible + if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) return true; + // ::ffff:0:0/96 — IPv4-mapped + if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0xffff) { + return isPrivateIPv4(embeddedIPv4(g)); + } + // ::ffff:0:0:0/96 — IPv4-translated (SIIT) + if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0xffff && g5 === 0) return true; + // 64:ff9b::/96 — NAT64 well-known prefix + if (g0 === 0x64 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) { + return isPrivateIPv4(embeddedIPv4(g)); + } + // 64:ff9b:1::/48 — local-use NAT64 + if (g0 === 0x64 && g1 === 0xff9b && g2 === 1) return true; + // 100::/64 — discard-only + if (g0 === 0x100 && g1 === 0 && g2 === 0 && g3 === 0) return true; + // 2001::/32 — Teredo (may tunnel to arbitrary IPv4) + if (g0 === 0x2001 && g1 === 0) return true; + // 2001:db8::/32 — documentation + if (g0 === 0x2001 && g1 === 0xdb8) return true; + // 2002::/16 — 6to4, check embedded IPv4 + if (g0 === 0x2002) return isPrivateIPv4(embeddedIPv4(g, 1)); + // fc00::/7 — unique local addresses + if ((g0 & 0xfe00) === 0xfc00) return true; + // fe80::/10 — link-local, fec0::/10 — deprecated site-local + if ((g0 & 0xffc0) === 0xfe80 || (g0 & 0xffc0) === 0xfec0) return true; + // ff00::/8 — multicast + if ((g0 & 0xff00) === 0xff00) return true; + + return false; +} + +/** + * Check whether an IP address literal is non-public. + * + * Returns true for loopback, private (RFC 1918 / ULA), link-local, + * unspecified, CGNAT, multicast, reserved, documentation ranges and + * IPv4-mapped / NAT64 / 6to4 IPv6 addresses wrapping such IPv4 addresses. + * Surrounding brackets and IPv6 zone identifiers are ignored. + * + * Unparseable input is treated as private (fail closed). + * + * @param ip - IPv4 or IPv6 address literal + * @returns True if the address must not be contacted + * + * @example + * ```typescript + * isPrivateAddress("127.0.0.1"); // true + * isPrivateAddress("[::ffff:10.0.0.1]"); // true + * isPrivateAddress("8.8.8.8"); // false + * ``` + */ +export function isPrivateAddress(ip: string): boolean { + let addr = ip.trim(); + if (addr.startsWith("[") && addr.endsWith("]")) addr = addr.slice(1, -1); + const zoneIndex = addr.indexOf("%"); + if (zoneIndex !== -1) addr = addr.slice(0, zoneIndex); + + const version = isIP(addr); + if (version === 4) { + const v4 = parseIPv4(addr); + return v4 ? isPrivateIPv4(v4) : true; + } + if (version === 6) { + const v6 = parseIPv6(addr.toLowerCase()); + return v6 ? isPrivateIPv6(v6) : true; + } + return true; +} + +/** + * Default DNS resolver using `dns.promises.lookup` with all addresses. + */ +const defaultLookup: LookupFn = (hostname) => dnsLookup(hostname, { all: true, verbatim: true }); + +/** + * Validate that a URL is safe to fetch from the server. + * + * - Only `http:` and `https:` are allowed + * - IP literal hosts are checked directly (brackets stripped) + * - `localhost` / `*.localhost` are rejected without resolving + * - Hostnames are resolved and rejected if ANY resolved address is private + * + * Private address checks are skipped when `ALLOW_PRIVATE_NETWORK_FETCH=true` + * (or `options.allowPrivateNetwork` is true); the scheme check always applies. + * + * @param input - URL to validate + * @param options - Validation options + * @returns The parsed URL + * @throws {UnsafeUrlError} If the URL is invalid or targets a non-public address + * + * @example + * ```typescript + * await assertPublicUrl("https://mastodon.social/users/alice"); // ok + * await assertPublicUrl("http://169.254.169.254/latest/meta-data"); // throws + * ``` + */ +export async function assertPublicUrl( + input: string | URL, + options: AssertPublicUrlOptions = {}, +): Promise { + let url: URL; + try { + url = new URL(input.toString()); + } catch { + throw new UnsafeUrlError("Invalid URL"); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new UnsafeUrlError("Only HTTP/HTTPS URLs are allowed"); + } + + const allowPrivate = options.allowPrivateNetwork ?? isPrivateNetworkFetchAllowed(); + if (allowPrivate) return url; + + let hostname = url.hostname.toLowerCase(); + if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1); + if (hostname.endsWith(".")) hostname = hostname.slice(0, -1); + + if (!hostname) { + throw new UnsafeUrlError("Missing hostname"); + } + + if (isIP(hostname) !== 0) { + if (isPrivateAddress(hostname)) { + throw new UnsafeUrlError("Private addresses are not allowed"); + } + return url; + } + + if (hostname === "localhost" || hostname.endsWith(".localhost")) { + throw new UnsafeUrlError("Private addresses are not allowed"); + } + + let addresses: Array<{ address: string; family: number }>; + try { + addresses = await (options.lookup ?? defaultLookup)(hostname); + } catch { + throw new UnsafeUrlError("Failed to resolve hostname"); + } + + if (addresses.length === 0) { + throw new UnsafeUrlError("Failed to resolve hostname"); + } + if (addresses.some((a) => isPrivateAddress(a.address))) { + throw new UnsafeUrlError("Private addresses are not allowed"); + } + + return url; +} + +/** + * SSRF-safe `fetch` wrapper. + * + * Validates the initial URL with {@link assertPublicUrl}, performs the request + * with `redirect: "manual"` and re-validates every redirect target before + * following it (up to `maxRedirects` hops). On redirect, the `Host` header is + * dropped and credentials (`Authorization`, `Cookie`) are removed when the + * origin changes. 303 responses (and 301/302 for POST) switch to GET without + * a body, matching browser semantics. + * + * @param input - URL to fetch + * @param init - Fetch init (the `redirect` option is ignored) + * @param options - Redirect and validation options + * @returns The final (non-redirect) response, or the last 3xx response if it has no Location + * @throws {UnsafeUrlError} If any hop targets a non-public address or the redirect limit is exceeded + * + * @example + * ```typescript + * const res = await safeFetch("https://remote.example/image.png", { + * headers: { Accept: "image/*" }, + * }); + * ``` + */ +export async function safeFetch( + input: string | URL, + init: RequestInit = {}, + options: SafeFetchOptions = {}, +): Promise { + const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; + const fetchImpl = options.fetchImpl ?? fetch; + + let current = await assertPublicUrl(input, options); + let method = (init.method ?? "GET").toUpperCase(); + let body = init.body; + const headers = new Headers(init.headers); + + for (let hop = 0; ; hop++) { + const response = await fetchImpl(current.toString(), { + ...init, + method, + body, + headers, + redirect: "manual", + }); + + if (!REDIRECT_STATUSES.has(response.status)) { + return response; + } + + const location = response.headers.get("location"); + if (!location) { + return response; + } + + // Discard the redirect body + try { + await response.body?.cancel(); + } catch { + // ignore + } + + if (hop >= maxRedirects) { + throw new UnsafeUrlError("Too many redirects"); + } + + let next: URL; + try { + next = new URL(location, current); + } catch { + throw new UnsafeUrlError("Invalid redirect location"); + } + + if ( + response.status === 303 || + ((response.status === 301 || response.status === 302) && method === "POST") + ) { + method = "GET"; + body = undefined; + headers.delete("content-type"); + headers.delete("content-length"); + headers.delete("digest"); + } + + headers.delete("host"); + if (next.origin !== current.origin) { + headers.delete("authorization"); + headers.delete("cookie"); + } + + current = await assertPublicUrl(next, options); + } +} diff --git a/packages/backend/src/middleware/securityHeaders.ts b/packages/backend/src/middleware/securityHeaders.ts index 739e53da..70eeca98 100644 --- a/packages/backend/src/middleware/securityHeaders.ts +++ b/packages/backend/src/middleware/securityHeaders.ts @@ -65,7 +65,7 @@ const defaultOptions: Required = { * - X-Frame-Options: DENY (prevents clickjacking) * - X-XSS-Protection: 0 (legacy, modern browsers use CSP) * - Referrer-Policy: strict-origin-when-cross-origin - * - Content-Security-Policy: restrictive policy + * - Content-Security-Policy: restrictive policy (skipped if the route already set one) * - Strict-Transport-Security: enables HSTS (production only) * - Permissions-Policy: disables sensitive features * @@ -101,7 +101,8 @@ export function securityHeaders(options: SecurityHeadersOptions = {}) { c.header("Referrer-Policy", config.referrerPolicy); // Content Security Policy - if (config.contentSecurityPolicy) { + // Do not overwrite a stricter route-specific policy (e.g. the media proxy) + if (config.contentSecurityPolicy && !c.res.headers.has("Content-Security-Policy")) { c.header("Content-Security-Policy", config.contentSecurityPolicy); } diff --git a/packages/backend/src/routes/emojis.ts b/packages/backend/src/routes/emojis.ts index 6b6c85b8..2a36b339 100644 --- a/packages/backend/src/routes/emojis.ts +++ b/packages/backend/src/routes/emojis.ts @@ -13,6 +13,7 @@ import { generateId } from "shared"; import type { ICustomEmojiRepository } from "../interfaces/repositories/ICustomEmojiRepository.js"; import type { IFileStorage } from "../interfaces/IFileStorage.js"; import { logger } from "../lib/logger.js"; +import { safeFetch } from "../lib/safeFetch.js"; import { optionalAuth } from "../middleware/auth.js"; /** Allowed emoji file types */ @@ -737,7 +738,7 @@ app.post("/adopt", async (c: Context) => { let contentType: string; try { - const response = await fetch(remoteUrl, { + const response = await safeFetch(remoteUrl, { headers: { "User-Agent": "Rox/1.0 (ActivityPub)", }, diff --git a/packages/backend/src/routes/proxy.ts b/packages/backend/src/routes/proxy.ts index 95898f85..61197076 100644 --- a/packages/backend/src/routes/proxy.ts +++ b/packages/backend/src/routes/proxy.ts @@ -6,6 +6,15 @@ * - Reduce load on remote servers * - Provide content-type validation for security * + * Security notes: + * - Outbound requests go through {@link safeFetch}, which blocks private / + * loopback / link-local destinations and re-validates every redirect hop + * - Only raster image, audio and video MIME types are allowed. SVG is + * intentionally excluded because it can carry script and would execute on + * the application origin + * - Every proxied response carries a restrictive sandboxed CSP so that even a + * mislabeled payload cannot run script on the application origin + * * @module routes/proxy */ @@ -13,38 +22,56 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { createHash } from "node:crypto"; import { logger } from "../lib/logger.js"; +import { safeFetch, UnsafeUrlError } from "../lib/safeFetch.js"; +import type { ICacheService } from "../interfaces/ICacheService.js"; const proxy = new Hono(); /** - * Allowed MIME types for proxying + * Allowed MIME types for proxying, mapped to the file extension used in the + * `Content-Disposition` filename. + * + * `image/svg+xml` is deliberately NOT allowed (stored XSS on the app origin). */ -const ALLOWED_MIME_TYPES = new Set([ +const ALLOWED_MIME_TYPES = new Map([ // Images - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/avif", - "image/svg+xml", - "image/x-icon", - "image/vnd.microsoft.icon", + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/gif", "gif"], + ["image/webp", "webp"], + ["image/avif", "avif"], + ["image/x-icon", "ico"], + ["image/vnd.microsoft.icon", "ico"], // Audio - "audio/mpeg", - "audio/ogg", - "audio/wav", - "audio/webm", + ["audio/mpeg", "mp3"], + ["audio/ogg", "ogg"], + ["audio/wav", "wav"], + ["audio/webm", "webm"], // Video - "video/mp4", - "video/webm", - "video/ogg", + ["video/mp4", "mp4"], + ["video/webm", "webm"], + ["video/ogg", "ogv"], ]); +/** + * Content Security Policy applied to every proxy response. + * + * `sandbox` prevents script execution and gives the document an opaque + * origin even if it is navigated to directly. + */ +export const PROXY_CSP = + "default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; sandbox"; + /** * Maximum file size for proxying (10MB) */ const MAX_PROXY_SIZE = 10 * 1024 * 1024; +/** + * Upstream request timeout (10 seconds) + */ +const PROXY_TIMEOUT = 10000; + /** * Cache duration headers (1 day for success, 1 hour for errors) */ @@ -59,51 +86,51 @@ function getCacheKey(url: string): string { } /** - * GET /proxy + * Apply security headers shared by every proxy response. * - * Proxy remote media files with caching and validation. - * - * @query {string} url - URL of the remote media to proxy (URL-encoded) - * @returns Proxied media content with appropriate headers - * - * @example - * GET /proxy?url=https%3A%2F%2Fremote.server%2Fimage.jpg + * @param c - Hono context + * @param contentType - Content type of the body (if it is media) */ -proxy.get("/", async (c: Context) => { - const url = c.req.query("url"); - - if (!url) { - return c.json({ error: "url parameter is required" }, 400); +function applyProxySecurityHeaders(c: Context, contentType?: string): void { + c.header("Content-Security-Policy", PROXY_CSP); + c.header("X-Content-Type-Options", "nosniff"); + if (contentType) { + const ext = ALLOWED_MIME_TYPES.get(contentType) ?? "bin"; + c.header("Content-Disposition", `inline; filename="media.${ext}"`); } +} + +/** + * Result of fetching media for the proxy. + */ +type ProxyMediaResult = + | { ok: true; buffer: Buffer; contentType: string; cacheHit: boolean } + | { ok: false; status: 400 | 403 | 413 | 415 | 502 | 504; error: string }; +/** + * Fetch (or load from cache) remote media with validation. + * + * @param url - Remote media URL + * @param cacheService - Optional cache service + * @returns Media buffer and content type, or an error description safe to return to clients + */ +async function fetchProxiedMedia( + url: string, + cacheService: ICacheService | undefined, +): Promise { // Validate URL format let parsedUrl: URL; try { parsedUrl = new URL(url); } catch { - return c.json({ error: "Invalid URL format" }, 400); + return { ok: false, status: 400, error: "Invalid URL format" }; } // Only allow http/https if (!["http:", "https:"].includes(parsedUrl.protocol)) { - return c.json({ error: "Only HTTP/HTTPS URLs are allowed" }, 400); + return { ok: false, status: 400, error: "Only HTTP/HTTPS URLs are allowed" }; } - // Block localhost/private IPs for security - const hostname = parsedUrl.hostname.toLowerCase(); - if ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname.startsWith("192.168.") || - hostname.startsWith("10.") || - hostname.startsWith("172.") || - hostname.endsWith(".local") - ) { - return c.json({ error: "Private addresses are not allowed" }, 403); - } - - const cacheService = c.get("cacheService"); const cacheKey = `proxy:${getCacheKey(url)}`; // Check cache first @@ -113,51 +140,56 @@ proxy.get("/", async (c: Context) => { contentType: string; }>(cacheKey); - if (cached) { - const buffer = Buffer.from(cached.data, "base64"); - c.header("Content-Type", cached.contentType); - c.header("Cache-Control", CACHE_SUCCESS); - c.header("X-Cache", "HIT"); - return c.body(buffer); + // Re-validate the cached content type: entries written before the + // allowlist was tightened (e.g. SVG) must not be served. + if (cached && ALLOWED_MIME_TYPES.has(cached.contentType)) { + return { + ok: true, + buffer: Buffer.from(cached.data, "base64"), + contentType: cached.contentType, + cacheHit: true, + }; } } - // Fetch from remote - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PROXY_TIMEOUT); - const response = await fetch(url, { - signal: controller.signal, - headers: { - "User-Agent": "Rox/1.0 (ActivityPub; +https://github.com/example/rox)", - Accept: "image/*,video/*,audio/*", - }, - }); - - clearTimeout(timeout); + try { + let response: Response; + try { + response = await safeFetch(url, { + signal: controller.signal, + headers: { + "User-Agent": "Rox/1.0 (ActivityPub; +https://github.com/Love-Rox/rox)", + Accept: "image/*,video/*,audio/*", + }, + }); + } catch (error) { + if (error instanceof UnsafeUrlError) { + return { ok: false, status: 403, error: "URL is not allowed" }; + } + throw error; + } if (!response.ok) { - c.header("Cache-Control", CACHE_ERROR); - return c.json( - { error: `Remote server returned ${response.status}` }, - response.status as 400 | 404 | 500, - ); + await response.body?.cancel().catch(() => {}); + return { ok: false, status: 502, error: "Failed to fetch remote media" }; } // Validate content type const rawContentType = response.headers.get("content-type") || ""; - const contentType = (rawContentType.split(";")[0] ?? "").trim(); + const contentType = (rawContentType.split(";")[0] ?? "").trim().toLowerCase(); if (!contentType || !ALLOWED_MIME_TYPES.has(contentType)) { - c.header("Cache-Control", CACHE_ERROR); - return c.json({ error: `Content type not allowed: ${contentType}` }, 415); + await response.body?.cancel().catch(() => {}); + return { ok: false, status: 415, error: "Unsupported media type" }; } // Check content length const contentLength = response.headers.get("content-length"); if (contentLength && parseInt(contentLength, 10) > MAX_PROXY_SIZE) { - c.header("Cache-Control", CACHE_ERROR); - return c.json({ error: "File too large" }, 413); + await response.body?.cancel().catch(() => {}); + return { ok: false, status: 413, error: "File too large" }; } // Read body with size limit @@ -166,7 +198,7 @@ proxy.get("/", async (c: Context) => { const reader = response.body?.getReader(); if (!reader) { - return c.json({ error: "Failed to read response" }, 500); + return { ok: false, status: 502, error: "Failed to fetch remote media" }; } while (true) { @@ -175,15 +207,13 @@ proxy.get("/", async (c: Context) => { totalSize += value.length; if (totalSize > MAX_PROXY_SIZE) { - reader.cancel(); - c.header("Cache-Control", CACHE_ERROR); - return c.json({ error: "File too large" }, 413); + await reader.cancel(); + return { ok: false, status: 413, error: "File too large" }; } chunks.push(value); } - // Combine chunks const buffer = Buffer.concat(chunks); // Cache the result @@ -198,23 +228,52 @@ proxy.get("/", async (c: Context) => { ); } - // Return response - c.header("Content-Type", contentType); - c.header("Cache-Control", CACHE_SUCCESS); - c.header("X-Cache", "MISS"); - c.header("X-Content-Type-Options", "nosniff"); - return c.body(buffer); + return { ok: true, buffer, contentType, cacheHit: false }; } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return { ok: false, status: 504, error: "Request timeout" }; + } logger.error({ err: error, url }, "Proxy fetch error"); + return { ok: false, status: 502, error: "Failed to fetch remote media" }; + } finally { + clearTimeout(timeout); + } +} - if (error instanceof Error && error.name === "AbortError") { +/** + * GET /proxy + * + * Proxy remote media files with caching and validation. + * + * @query {string} url - URL of the remote media to proxy (URL-encoded) + * @returns Proxied media content with appropriate headers + * + * @example + * GET /proxy?url=https%3A%2F%2Fremote.server%2Fimage.jpg + */ +proxy.get("/", async (c: Context) => { + const url = c.req.query("url"); + + applyProxySecurityHeaders(c); + + if (!url) { + return c.json({ error: "url parameter is required" }, 400); + } + + const result = await fetchProxiedMedia(url, c.get("cacheService")); + + if (!result.ok) { + if (result.status !== 400) { c.header("Cache-Control", CACHE_ERROR); - return c.json({ error: "Request timeout" }, 504); } - - c.header("Cache-Control", CACHE_ERROR); - return c.json({ error: "Failed to fetch remote media" }, 502); + return c.json({ error: result.error }, result.status); } + + applyProxySecurityHeaders(c, result.contentType); + c.header("Content-Type", result.contentType); + c.header("Cache-Control", CACHE_SUCCESS); + c.header("X-Cache", result.cacheHit ? "HIT" : "MISS"); + return c.body(new Uint8Array(result.buffer)); }); /** @@ -228,31 +287,21 @@ proxy.get("/", async (c: Context) => { proxy.get("/avatar", async (c: Context) => { const url = c.req.query("url"); - if (!url) { - // Return default avatar placeholder - c.header("Content-Type", "image/svg+xml"); - c.header("Cache-Control", CACHE_SUCCESS); - return c.body(DEFAULT_AVATAR_SVG); - } - - // Delegate to main proxy handler - // In case of error, return default avatar - try { - const proxyUrl = new URL("/proxy", c.req.url); - proxyUrl.searchParams.set("url", url); + applyProxySecurityHeaders(c); - const response = await fetch(proxyUrl.toString()); - if (response.ok) { - const contentType = response.headers.get("content-type") || "image/png"; - c.header("Content-Type", contentType); + if (url) { + const result = await fetchProxiedMedia(url, c.get("cacheService")); + if (result.ok) { + applyProxySecurityHeaders(c, result.contentType); + c.header("Content-Type", result.contentType); c.header("Cache-Control", CACHE_SUCCESS); - return c.body(await response.arrayBuffer()); + return c.body(new Uint8Array(result.buffer)); } - } catch { - // Fall through to default avatar } + // Return default avatar placeholder (static, trusted SVG) c.header("Content-Type", "image/svg+xml"); + c.header("Content-Disposition", 'inline; filename="avatar.svg"'); c.header("Cache-Control", CACHE_SUCCESS); return c.body(DEFAULT_AVATAR_SVG); }); diff --git a/packages/backend/src/services/RemoteInstanceRefreshService.ts b/packages/backend/src/services/RemoteInstanceRefreshService.ts index 45693af7..cde529c4 100644 --- a/packages/backend/src/services/RemoteInstanceRefreshService.ts +++ b/packages/backend/src/services/RemoteInstanceRefreshService.ts @@ -1,5 +1,6 @@ import type { IRemoteInstanceRepository } from "../interfaces/repositories/IRemoteInstanceRepository.js"; import { logger } from "../lib/logger.js"; +import { safeFetch } from "../lib/safeFetch.js"; import { fetchInstanceIcon } from "../lib/instance-icon.js"; import { withMonitor } from "../lib/sentry.js"; @@ -280,7 +281,7 @@ export class RemoteInstanceRefreshService { try { // First, fetch well-known nodeinfo to get the actual nodeinfo URL const wellKnownUrl = `https://${host}/.well-known/nodeinfo`; - const wellKnownResponse = await fetch(wellKnownUrl, { + const wellKnownResponse = await safeFetch(wellKnownUrl, { headers: { Accept: "application/json", }, @@ -305,7 +306,7 @@ export class RemoteInstanceRefreshService { } // Fetch actual nodeinfo - const nodeInfoResponse = await fetch(nodeInfoLink.href, { + const nodeInfoResponse = await safeFetch(nodeInfoLink.href, { headers: { Accept: "application/json", }, diff --git a/packages/backend/src/services/RemoteInstanceService.ts b/packages/backend/src/services/RemoteInstanceService.ts index 1bf44f6c..ebd0fafc 100644 --- a/packages/backend/src/services/RemoteInstanceService.ts +++ b/packages/backend/src/services/RemoteInstanceService.ts @@ -1,6 +1,7 @@ import type { IRemoteInstanceRepository } from "../interfaces/repositories/IRemoteInstanceRepository.js"; import type { RemoteInstance, PublicRemoteInstance } from "shared"; import { logger } from "../lib/logger.js"; +import { safeFetch } from "../lib/safeFetch.js"; import { fetchInstanceIcon } from "../lib/instance-icon.js"; /** @@ -213,7 +214,7 @@ export class RemoteInstanceService { try { // First, fetch well-known nodeinfo to get the actual nodeinfo URL const wellKnownUrl = `https://${host}/.well-known/nodeinfo`; - const wellKnownResponse = await fetch(wellKnownUrl, { + const wellKnownResponse = await safeFetch(wellKnownUrl, { headers: { Accept: "application/json", }, @@ -238,7 +239,7 @@ export class RemoteInstanceService { } // Fetch actual nodeinfo - const nodeInfoResponse = await fetch(nodeInfoLink.href, { + const nodeInfoResponse = await safeFetch(nodeInfoLink.href, { headers: { Accept: "application/json", }, diff --git a/packages/backend/src/services/WebPushService.ts b/packages/backend/src/services/WebPushService.ts index bb18216d..f25b16e5 100644 --- a/packages/backend/src/services/WebPushService.ts +++ b/packages/backend/src/services/WebPushService.ts @@ -9,6 +9,7 @@ import webpush from "web-push"; import { eq, and } from "drizzle-orm"; import { generateId } from "shared"; import { logger } from "../lib/logger.js"; +import { safeFetch } from "../lib/safeFetch.js"; import type { Database } from "../db/index.js"; import type { PushSubscription, NewPushSubscription, NotificationType } from "../db/schema/pg.js"; import type { InstanceSettingsService } from "./InstanceSettingsService.js"; @@ -230,13 +231,18 @@ export class WebPushService { // Convert Buffer to Uint8Array for fetch compatibility const body = requestDetails.body ? new Uint8Array(requestDetails.body) : undefined; - // Web Push always uses POST method - const response = await fetch(requestDetails.endpoint, { - method: "POST", - headers: requestDetails.headers as Record, - body, - signal: controller.signal, - }); + // Web Push always uses POST method. + // Endpoints are user-supplied, so guard against SSRF and never follow redirects. + const response = await safeFetch( + requestDetails.endpoint, + { + method: "POST", + headers: requestDetails.headers as Record, + body, + signal: controller.signal, + }, + { maxRedirects: 0 }, + ); clearTimeout(timeoutId); diff --git a/packages/backend/src/services/ap/ActivityDeliveryService.ts b/packages/backend/src/services/ap/ActivityDeliveryService.ts index 9f0f849b..d725cf71 100644 --- a/packages/backend/src/services/ap/ActivityDeliveryService.ts +++ b/packages/backend/src/services/ap/ActivityDeliveryService.ts @@ -10,6 +10,7 @@ import { signRequest, getSignedHeaders } from "../../utils/crypto.js"; import { logger } from "../../lib/logger.js"; import { recordActivityDelivery } from "../../lib/metrics.js"; +import { safeFetch } from "../../lib/safeFetch.js"; /** * Delivery timeout in milliseconds (30 seconds) @@ -79,7 +80,8 @@ export class ActivityDeliveryService { let response: Response; try { - response = await fetch(inboxUrl, { + // SSRF protection: inbox URLs come from remote actor documents + response = await safeFetch(inboxUrl, { method: "POST", headers: headersObj, body, diff --git a/packages/backend/src/services/ap/RemoteActorService.ts b/packages/backend/src/services/ap/RemoteActorService.ts index 61757207..07bd26a0 100644 --- a/packages/backend/src/services/ap/RemoteActorService.ts +++ b/packages/backend/src/services/ap/RemoteActorService.ts @@ -565,6 +565,7 @@ export class RemoteActorService { headers: { Accept: "application/jrd+json, application/json", }, + allowedContentTypes: ["application/jrd+json", "application/json"], }, ); diff --git a/packages/backend/src/services/ap/RemoteFetchService.ts b/packages/backend/src/services/ap/RemoteFetchService.ts index 0db8c361..306d715d 100644 --- a/packages/backend/src/services/ap/RemoteFetchService.ts +++ b/packages/backend/src/services/ap/RemoteFetchService.ts @@ -14,6 +14,17 @@ import { signRequest, getSignedHeaders } from "../../utils/crypto.js"; import { logger } from "../../lib/logger.js"; +import { safeFetch, UnsafeUrlError } from "../../lib/safeFetch.js"; + +/** + * Content types accepted for ActivityPub object responses by default. + * Some servers respond with plain `application/json`, which is accepted too. + */ +export const ACTIVITYPUB_CONTENT_TYPES = [ + "application/activity+json", + "application/ld+json", + "application/json", +]; /** * Signature configuration for authenticated fetches @@ -39,6 +50,11 @@ export interface RemoteFetchOptions { headers?: Record; /** HTTP Signature configuration for authenticated requests */ signature?: SignatureConfig; + /** + * Accepted response media types (without parameters). + * Defaults to {@link ACTIVITYPUB_CONTENT_TYPES}. + */ + allowedContentTypes?: string[]; } /** @@ -104,6 +120,7 @@ export class RemoteFetchService { initialRetryDelay = RemoteFetchService.DEFAULT_INITIAL_RETRY_DELAY, headers = {}, signature, + allowedContentTypes = ACTIVITYPUB_CONTENT_TYPES, } = options; let lastError: RemoteFetchResult["error"]; @@ -139,7 +156,12 @@ export class RemoteFetchService { }; } - const result = await this.performFetch(url, timeout, requestHeaders); + const result = await this.performFetch( + url, + timeout, + requestHeaders, + allowedContentTypes, + ); if (result.success) { return result; @@ -197,6 +219,7 @@ export class RemoteFetchService { * @param url - URL to fetch * @param timeout - Timeout in milliseconds * @param headers - HTTP headers + * @param allowedContentTypes - Accepted response media types * @returns Fetch result * * @private @@ -205,6 +228,7 @@ export class RemoteFetchService { url: string, timeout: number, headers: Record, + allowedContentTypes: string[], ): Promise> { try { // Create abort controller for timeout @@ -213,10 +237,22 @@ export class RemoteFetchService { let response: Response; try { - response = await fetch(url, { + // SSRF protection: blocks private destinations and re-validates redirects + response = await safeFetch(url, { headers, signal: controller.signal, }); + } catch (error) { + if (error instanceof UnsafeUrlError) { + return { + success: false, + error: { + type: "invalid_response", + message: `URL rejected: ${error.message}`, + }, + }; + } + throw error; } finally { clearTimeout(timeoutId); } @@ -260,6 +296,23 @@ export class RemoteFetchService { }; } + // Validate content type before parsing + const contentType = (response.headers.get("content-type") ?? "") + .split(";")[0]! + .trim() + .toLowerCase(); + if (!allowedContentTypes.includes(contentType)) { + await response.body?.cancel().catch(() => {}); + return { + success: false, + error: { + type: "invalid_response", + message: `Unexpected content type: ${contentType || "(none)"}`, + statusCode: response.status, + }, + }; + } + // Parse JSON response let data: T; try { diff --git a/packages/backend/src/services/ap/RemoteLikesService.ts b/packages/backend/src/services/ap/RemoteLikesService.ts index fb95de29..c82f8c69 100644 --- a/packages/backend/src/services/ap/RemoteLikesService.ts +++ b/packages/backend/src/services/ap/RemoteLikesService.ts @@ -15,6 +15,7 @@ import type { IUserRepository } from "../../interfaces/repositories/IUserReposit import type { ICustomEmojiRepository } from "../../interfaces/repositories/ICustomEmojiRepository.js"; import { generateId } from "shared"; import { logger } from "../../lib/logger.js"; +import { safeFetch } from "../../lib/safeFetch.js"; /** * ActivityPub Collection interface (OrderedCollection or Collection) @@ -358,7 +359,7 @@ export class RemoteLikesService { // Try Misskey API endpoint const apiUrl = `${url.origin}/api/notes/reactions`; - const response = await fetch(apiUrl, { + const response = await safeFetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -430,7 +431,7 @@ export class RemoteLikesService { ): Promise> { try { const apiUrl = `${baseUrl}/api/emojis`; - const response = await fetch(apiUrl, { + const response = await safeFetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/packages/backend/src/tests/unit/proxy.test.ts b/packages/backend/src/tests/unit/proxy.test.ts new file mode 100644 index 00000000..76d15b3d --- /dev/null +++ b/packages/backend/src/tests/unit/proxy.test.ts @@ -0,0 +1,131 @@ +/** + * Unit tests for the media proxy route + * + * Verifies SSRF protection, content-type allowlisting (no SVG) and the + * security headers applied to proxied responses. + * + * @module tests/unit/proxy + */ + +import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test"; +import { Hono } from "hono"; +import proxyRoute, { PROXY_CSP } from "../../routes/proxy.js"; +import { securityHeaders } from "../../middleware/securityHeaders.js"; + +function createApp() { + const app = new Hono(); + app.use("*", securityHeaders()); + app.route("/api/proxy", proxyRoute); + return app; +} + +function proxyPath(url: string, sub = ""): string { + return `/api/proxy${sub}?url=${encodeURIComponent(url)}`; +} + +describe("Media proxy", () => { + let fetchSpy: ReturnType; + const originalEnv = process.env.ALLOW_PRIVATE_NETWORK_FETCH; + + beforeEach(() => { + delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + }); + + afterEach(() => { + fetchSpy?.mockRestore(); + if (originalEnv === undefined) delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + else process.env.ALLOW_PRIVATE_NETWORK_FETCH = originalEnv; + }); + + test("rejects SVG content type and does not echo upstream details", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response('', { + status: 200, + headers: { "Content-Type": "image/svg+xml" }, + })) as any, + ); + + const res = await createApp().request(proxyPath("https://93.184.216.34/evil.svg")); + expect(res.status).toBe(415); + const body = await res.text(); + expect(body).not.toContain("svg"); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(res.headers.get("content-security-policy")).toBe(PROXY_CSP); + }); + + test("serves allowed images with sandboxed CSP, nosniff and Content-Disposition", async () => { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(png, { status: 200, headers: { "Content-Type": "image/png" } })) as any, + ); + + const res = await createApp().request(proxyPath("https://93.184.216.34/ok.png")); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + // Global securityHeaders middleware must not overwrite the proxy CSP + expect(res.headers.get("content-security-policy")).toBe(PROXY_CSP); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + expect(res.headers.get("content-disposition")).toBe('inline; filename="media.png"'); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(png); + }); + + test.each([ + "http://169.254.169.254/latest/meta-data/", + "http://[::1]/", + "http://[::ffff:127.0.0.1]/", + "http://0.0.0.0/", + "http://100.64.0.1/", + "http://localhost/", + ])("blocks private destination %s without fetching", async (url) => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => new Response("secret")) as any, + ); + + const res = await createApp().request(proxyPath(url)); + expect(res.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("blocks redirects to private addresses", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(null, { + status: 302, + headers: { Location: "http://169.254.169.254/" }, + })) as any, + ); + + const res = await createApp().request(proxyPath("https://93.184.216.34/redirect.png")); + expect(res.status).toBe(403); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + test("returns generic 502 without upstream status on upstream errors", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => new Response("teapot", { status: 418 })) as any, + ); + + const res = await createApp().request(proxyPath("https://93.184.216.34/x.png")); + expect(res.status).toBe(502); + expect(await res.text()).not.toContain("418"); + }); + + test("avatar endpoint falls back to default avatar for SVG upstream", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response("", { + status: 200, + headers: { "Content-Type": "image/svg+xml" }, + })) as any, + ); + + const res = await createApp().request(proxyPath("https://93.184.216.34/a.svg", "/avatar")); + expect(res.status).toBe(200); + expect(res.headers.get("content-security-policy")).toBe(PROXY_CSP); + const body = await res.text(); + expect(body).not.toContain("script"); + expect(body).toContain(" [{ address: "93.184.216.34", family: 4 }]; + +describe("isPrivateAddress", () => { + test.each([ + "127.0.0.1", + "127.1.2.3", + "10.0.0.1", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.169.254", + "0.0.0.0", + "100.64.0.1", + "100.127.255.255", + "192.0.2.1", + "198.18.0.1", + "224.0.0.1", + "255.255.255.255", + "::1", + "[::1]", + "::", + "::ffff:127.0.0.1", + "[::ffff:127.0.0.1]", + "::ffff:7f00:1", + "::ffff:169.254.169.254", + "::ffff:10.0.0.1", + "fd00::1", + "fc00::1", + "fe80::1", + "fe80::1%eth0", + "ff02::1", + "2001:db8::1", + "64:ff9b::7f00:1", + "2002:7f00:1::1", + ])("blocks %s", (ip) => { + expect(isPrivateAddress(ip)).toBe(true); + }); + + test.each([ + "8.8.8.8", + "1.1.1.1", + "93.184.216.34", + "172.32.0.1", + "100.63.255.255", + "100.128.0.1", + "2606:4700:4700::1111", + "2001:4860:4860::8888", + "::ffff:8.8.8.8", + "64:ff9b::808:808", + ])("allows %s", (ip) => { + expect(isPrivateAddress(ip)).toBe(false); + }); + + test("treats unparseable input as private", () => { + expect(isPrivateAddress("not-an-ip")).toBe(true); + expect(isPrivateAddress("999.1.1.1")).toBe(true); + }); +}); + +describe("assertPublicUrl", () => { + const originalEnv = process.env.ALLOW_PRIVATE_NETWORK_FETCH; + + beforeEach(() => { + delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + }); + + afterEach(() => { + if (originalEnv === undefined) delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + else process.env.ALLOW_PRIVATE_NETWORK_FETCH = originalEnv; + }); + + test.each([ + "http://127.0.0.1/", + "http://[::1]/", + "http://[::ffff:127.0.0.1]/", + "http://169.254.169.254/latest/meta-data/", + "http://0.0.0.0:3000/", + "http://100.64.0.1/", + "http://[fd00::1]/", + "http://localhost:3000/", + "http://foo.localhost/", + "http://2130706433/", // 127.0.0.1 in decimal, normalized by URL parser + "http://0x7f.0.0.1/", + ])("rejects %s", async (url) => { + await expect(assertPublicUrl(url, { lookup: publicLookup })).rejects.toBeInstanceOf( + UnsafeUrlError, + ); + }); + + test("rejects non-HTTP schemes", async () => { + await expect(assertPublicUrl("file:///etc/passwd")).rejects.toBeInstanceOf(UnsafeUrlError); + await expect(assertPublicUrl("gopher://example.com/")).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + test("rejects invalid URLs", async () => { + await expect(assertPublicUrl("not a url")).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + test("allows public IP literals", async () => { + const url = await assertPublicUrl("https://93.184.216.34/image.png"); + expect(url.hostname).toBe("93.184.216.34"); + await expect(assertPublicUrl("https://[2606:4700:4700::1111]/")).resolves.toBeInstanceOf(URL); + }); + + test("allows hostnames resolving only to public addresses", async () => { + const url = await assertPublicUrl("https://remote.example/users/alice", { + lookup: async () => [ + { address: "93.184.216.34", family: 4 }, + { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 }, + ], + }); + expect(url.hostname).toBe("remote.example"); + }); + + test("rejects hostnames where ANY resolved address is private", async () => { + await expect( + assertPublicUrl("https://evil.example/", { + lookup: async () => [ + { address: "93.184.216.34", family: 4 }, + { address: "10.0.0.5", family: 4 }, + ], + }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + + await expect( + assertPublicUrl("https://evil6.example/", { + lookup: async () => [{ address: "::ffff:127.0.0.1", family: 6 }], + }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + test("rejects when DNS resolution fails", async () => { + await expect( + assertPublicUrl("https://nxdomain.example/", { + lookup: async () => { + throw new Error("ENOTFOUND"); + }, + }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + test("ALLOW_PRIVATE_NETWORK_FETCH=true disables private address checks", async () => { + process.env.ALLOW_PRIVATE_NETWORK_FETCH = "true"; + await expect(assertPublicUrl("http://127.0.0.1:3000/")).resolves.toBeInstanceOf(URL); + // Scheme check still applies + await expect(assertPublicUrl("file:///etc/passwd")).rejects.toBeInstanceOf(UnsafeUrlError); + }); +}); + +describe("safeFetch", () => { + const originalEnv = process.env.ALLOW_PRIVATE_NETWORK_FETCH; + + beforeEach(() => { + delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + }); + + afterEach(() => { + if (originalEnv === undefined) delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + else process.env.ALLOW_PRIVATE_NETWORK_FETCH = originalEnv; + }); + + function redirect(location: string, status = 302): Response { + return new Response(null, { status, headers: { Location: location } }); + } + + test("does not call fetch for private initial URLs", async () => { + const fetchImpl = mock(async () => new Response("ok")); + await expect( + safeFetch("http://169.254.169.254/", {}, { fetchImpl: fetchImpl as any }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test("uses manual redirect mode", async () => { + const fetchImpl = mock(async (_url: string, _init?: RequestInit) => new Response("ok")); + await safeFetch("https://93.184.216.34/", {}, { fetchImpl: fetchImpl as any }); + expect(fetchImpl.mock.calls[0]?.[1]?.redirect).toBe("manual"); + }); + + test("re-validates redirect targets and blocks redirects to private addresses", async () => { + const fetchImpl = mock(async (url: string) => { + if (url.startsWith("https://93.184.216.34/")) { + return redirect("http://169.254.169.254/latest/meta-data/"); + } + return new Response("secret"); + }); + + await expect( + safeFetch("https://93.184.216.34/start", {}, { fetchImpl: fetchImpl as any }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test("blocks redirects to hostnames resolving to private addresses", async () => { + const lookup: LookupFn = async (host) => + host === "internal.example" + ? [{ address: "10.1.2.3", family: 4 }] + : [{ address: "93.184.216.34", family: 4 }]; + const fetchImpl = mock(async () => redirect("https://internal.example/admin")); + + await expect( + safeFetch("https://public.example/", {}, { fetchImpl: fetchImpl as any, lookup }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test("follows redirects to public addresses (relative Location)", async () => { + const fetchImpl = mock(async (url: string) => { + if (url === "https://93.184.216.34/a") return redirect("/b", 301); + return new Response("final", { status: 200 }); + }); + + const res = await safeFetch("https://93.184.216.34/a", {}, { fetchImpl: fetchImpl as any }); + expect(res.status).toBe(200); + expect(await res.text()).toBe("final"); + expect(fetchImpl.mock.calls[1]?.[0]).toBe("https://93.184.216.34/b"); + }); + + test("enforces maximum redirects", async () => { + let n = 0; + const fetchImpl = mock(async () => redirect(`https://93.184.216.34/${++n}`)); + + await expect( + safeFetch("https://93.184.216.34/0", {}, { fetchImpl: fetchImpl as any, maxRedirects: 3 }), + ).rejects.toThrow("Too many redirects"); + expect(fetchImpl).toHaveBeenCalledTimes(4); + }); + + test("maxRedirects: 0 rejects any redirect", async () => { + const fetchImpl = mock(async () => redirect("https://93.184.216.34/next")); + await expect( + safeFetch("https://93.184.216.34/", {}, { fetchImpl: fetchImpl as any, maxRedirects: 0 }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test("303 switches POST to GET and drops credentials across origins", async () => { + const fetchImpl = mock(async (url: string, _init?: RequestInit) => { + if (url === "https://93.184.216.34/post") return redirect("https://8.8.8.8/done", 303); + return new Response("ok"); + }); + + await safeFetch( + "https://93.184.216.34/post", + { + method: "POST", + body: "data", + headers: { Authorization: "Bearer x", "Content-Type": "text/plain", Host: "a" }, + }, + { fetchImpl: fetchImpl as any }, + ); + + const second = fetchImpl.mock.calls[1]?.[1]; + expect(second?.method).toBe("GET"); + expect(second?.body).toBeUndefined(); + const headers = new Headers(second?.headers); + expect(headers.has("authorization")).toBe(false); + expect(headers.has("content-type")).toBe(false); + expect(headers.has("host")).toBe(false); + }); + + test("works against a local server when ALLOW_PRIVATE_NETWORK_FETCH=true", async () => { + process.env.ALLOW_PRIVATE_NETWORK_FETCH = "true"; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + const { pathname } = new URL(req.url); + if (pathname === "/start") return redirect("/end"); + if (pathname === "/file") return redirect("file:///etc/passwd"); + return new Response("reached"); + }, + }); + try { + const base = `http://127.0.0.1:${server.port}`; + const res = await safeFetch(`${base}/start`); + expect(await res.text()).toBe("reached"); + + // Scheme is still re-validated on each hop + await expect(safeFetch(`${base}/file`)).rejects.toBeInstanceOf(UnsafeUrlError); + } finally { + server.stop(true); + } + }); + + test("blocks a local server without the escape hatch", async () => { + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response("should not be reached"), + }); + try { + await expect(safeFetch(`http://127.0.0.1:${server.port}/`)).rejects.toBeInstanceOf( + UnsafeUrlError, + ); + } finally { + server.stop(true); + } + }); +});