sipher/src/lib/federation/url-guard.ts
Nixyan 7049a40870 feat: enhance federation functionality by reworking the workers.
- Introduced single Redis connection for managing federation delivery jobs, improving reliability and performance.
- Updated environment configuration to include Redis connection details and allowed hostnames for CORS.
- Refactored existing code to streamline federation processes and improve error handling.
- Enhanced database schema to track acknowledgment status for follow requests.

This update aims to strengthen the federation's communication capabilities and ensure better handling of server interactions.

#3 #4
2026-04-08 10:35:59 -03:00

146 lines
4.3 KiB
TypeScript

import createDebug from "debug";
import { z } from "zod";
const debug = createDebug("app:federation:url-guard");
const BLOCKED_HOSTNAMES = new Set([
"localhost",
"0.0.0.0",
"[::1]",
"[::0]",
"metadata.google.internal",
"metadata.goog",
"169.254.169.254",
]);
/** Normalize allowlist tokens so `host:port` and full URLs map to URL.hostname. */
function allowlistHostname(entry: string): string | null {
const t = entry.trim();
if (!t) return null;
try {
if (t.includes("://")) return new URL(t).hostname;
return new URL(`http://${t}`).hostname;
} catch {
return t;
}
}
const DEV_ALLOWED_HOSTNAMES = new Set([
"localhost",
"127.0.0.1",
]);
if (typeof process.env.DEV_ALLOWED_HOSTNAMES === "string" && process.env.DEV_ALLOWED_HOSTNAMES.trim() !== "") {
for (const h of process.env.DEV_ALLOWED_HOSTNAMES.split(",")) {
const hostname = allowlistHostname(h);
if (hostname) DEV_ALLOWED_HOSTNAMES.add(hostname);
}
}
debug("DEV_ALLOWED_HOSTNAMES: %s", [...DEV_ALLOWED_HOSTNAMES].join(", "));
const ipv4Octet = z.number().int().min(0).max(255);
const ipv4OctetsSchema = z
.ipv4()
.transform((s) => s.split(".").map((octet) => Number.parseInt(octet, 10)))
.pipe(z.tuple([ipv4Octet, ipv4Octet, ipv4Octet, ipv4Octet]));
function isPrivateIPv4(hostname: string): boolean {
const parsed = ipv4OctetsSchema.safeParse(hostname);
if (!parsed.success) return false;
const [a, b] = parsed.data;
if (a === 127) return true; // 127.0.0.0/8
if (a === 10) return true; // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local / AWS metadata)
if (a === 0) return true; // 0.0.0.0/8
return false;
}
const ipv6HostNormalized = z
.string()
.transform((h) => h.replace(/^\[|\]$/g, "").toLowerCase())
.pipe(z.ipv6());
const ipv6Hextet16 = z
.string()
.regex(/^[0-9a-f]{1,4}$/)
.transform((s) => Number.parseInt(s, 16))
.pipe(z.number().int().min(0).max(0xffff));
/** First 16-bit group, or null if address starts with `::` (no leading hextet) / not colon-shaped. */
function ipv6LeadingHextet(bare: string): string | null {
if (bare.startsWith("::")) return null;
const colon = bare.indexOf(":");
if (colon === -1) return null;
return bare.slice(0, colon);
}
function isPrivateIPv6(hostname: string): boolean {
const host = ipv6HostNormalized.safeParse(hostname);
if (!host.success) return false;
const bare = host.data;
if (bare === "::1" || bare === "::0" || bare === "::") return true;
const first = ipv6LeadingHextet(bare);
if (first === null) return false;
const hextet = ipv6Hextet16.safeParse(first);
if (!hextet.success) return false;
const n = hextet.data;
return (n >= 0xfc00 && n <= 0xfdff) || (n >= 0xfe80 && n <= 0xfebf); // ULA fc00::/7, link-local fe80::/10
}
/**
* Throws if the URL points to a private/internal address or uses a
* non-HTTP(S) protocol. Hosts listed in `DEV_ALLOWED_HOSTNAMES` are always allowed
* (use bare host or `host:port` / full URL — port is stripped to match `URL.hostname`).
*/
export function assertSafeUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new UrlGuardError(`Invalid URL: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new UrlGuardError(`Blocked protocol: ${parsed.protocol}`);
}
const hostname = parsed.hostname;
// Explicit dev allowlist wins (host-only match; list entries may use host:port — see allowlistHostname).
if (DEV_ALLOWED_HOSTNAMES.has(hostname)) {
return;
}
if (BLOCKED_HOSTNAMES.has(hostname)) {
debug("blocked hostname: %s", hostname);
throw new UrlGuardError(`Blocked internal address: ${hostname}`);
}
if (isPrivateIPv4(hostname)) {
debug("blocked private IPv4: %s", hostname);
throw new UrlGuardError(`Blocked internal address: ${hostname}`);
}
if (hostname.startsWith("[") || isPrivateIPv6(hostname)) {
if (isPrivateIPv6(hostname)) {
debug("blocked private IPv6: %s", hostname);
throw new UrlGuardError(`Blocked internal address: ${hostname}`);
}
}
}
export class UrlGuardError extends Error {
constructor(message: string) {
super(message);
this.name = "UrlGuardError";
}
}