UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

97 lines (96 loc) 3.72 kB
import * as dntShim from "../_dnt.shims.js"; import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; export class UrlError extends Error { constructor(message) { super(message); this.name = "UrlError"; } } /** * Validates a URL to prevent SSRF attacks. */ export async function validatePublicUrl(url) { const parsed = new URL(url); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new UrlError(`Unsupported protocol: ${parsed.protocol}`); } let hostname = parsed.hostname; if (hostname.startsWith("[") && hostname.endsWith("]")) { hostname = hostname.substring(1, hostname.length - 2); } if (hostname === "localhost") { throw new UrlError("Localhost is not allowed"); } if ("Deno" in dntShim.dntGlobalThis && !isIP(hostname)) { // If the `net` permission is not granted, we can't resolve the hostname. // However, we can safely assume that it cannot gain access to private // resources. const netPermission = await dntShim.Deno.permissions.query({ name: "net" }); if (netPermission.state !== "granted") return; } // FIXME: This is a temporary workaround for the `Bun` runtime; for unknown // reasons, the Web Crypto API does not work as expected after a DNS lookup. // This workaround purposes to prevent unit tests from hanging up: if ("Bun" in dntShim.dntGlobalThis) { if (hostname === "example.com" || hostname.endsWith(".example.com")) { return; } else if (hostname === "fedify-test.internal") { throw new UrlError("Invalid or private address: fedify-test.internal"); } } // To prevent SSRF via DNS rebinding, we need to resolve all IP addresses // and ensure that they are all public: let addresses; try { addresses = await lookup(hostname, { all: true }); } catch { addresses = []; } for (const { address, family } of addresses) { if (family === 4 && !isValidPublicIPv4Address(address) || family === 6 && !isValidPublicIPv6Address(address) || family < 4 || family === 5 || family > 6) { throw new UrlError(`Invalid or private address: ${address}`); } } } export function isValidPublicIPv4Address(address) { const parts = address.split("."); const first = parseInt(parts[0]); if (first === 0 || first === 10 || first === 127) return false; const second = parseInt(parts[1]); if (first === 169 && second === 254) return false; if (first === 172 && second >= 16 && second <= 31) return false; if (first === 192 && second === 168) return false; return true; } export function isValidPublicIPv6Address(address) { address = expandIPv6Address(address); if (address.at(4) !== ":") return false; const firstWord = parseInt(address.substring(0, 4), 16); return !((firstWord >= 0xfc00 && firstWord <= 0xfdff) || // ULA (firstWord >= 0xfe80 && firstWord <= 0xfebf) || // Link-local firstWord === 0 || firstWord >= 0xff00 // Multicast ); } export function expandIPv6Address(address) { address = address.toLowerCase(); if (address === "::") return "0000:0000:0000:0000:0000:0000:0000:0000"; if (address.startsWith("::")) address = "0000" + address; if (address.endsWith("::")) address = address + "0000"; address = address.replace("::", ":0000".repeat(8 - (address.match(/:/g) || []).length) + ":"); const parts = address.split(":"); return parts.map((part) => part.padStart(4, "0")).join(":"); }