fix: guard remote media fetches with SSRF checks

This commit is contained in:
Peter Steinberger
2026-02-02 04:04:27 -08:00
parent d842b28a15
commit 81c68f582d
11 changed files with 422 additions and 241 deletions

View File

@@ -15,7 +15,12 @@ export class SsrFBlockedError extends Error {
}
}
type LookupFn = typeof dnsLookup;
export type LookupFn = typeof dnsLookup;
export type SsrFPolicy = {
allowPrivateNetwork?: boolean;
allowedHostnames?: string[];
};
const PRIVATE_IPV6_PREFIXES = ["fe80:", "fec0:", "fc", "fd"];
const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]);
@@ -28,6 +33,13 @@ function normalizeHostname(hostname: string): string {
return normalized;
}
function normalizeHostnameSet(values?: string[]): Set<string> {
if (!values || values.length === 0) {
return new Set<string>();
}
return new Set(values.map((value) => normalizeHostname(value)).filter(Boolean));
}
function parseIpv4(address: string): number[] | null {
const parts = address.split(".");
if (parts.length !== 4) {
@@ -206,31 +218,40 @@ export type PinnedHostname = {
lookup: typeof dnsLookupCb;
};
export async function resolvePinnedHostname(
export async function resolvePinnedHostnameWithPolicy(
hostname: string,
lookupFn: LookupFn = dnsLookup,
params: { lookupFn?: LookupFn; policy?: SsrFPolicy } = {},
): Promise<PinnedHostname> {
const normalized = normalizeHostname(hostname);
if (!normalized) {
throw new Error("Invalid hostname");
}
if (isBlockedHostname(normalized)) {
throw new SsrFBlockedError(`Blocked hostname: ${hostname}`);
}
if (isPrivateIpAddress(normalized)) {
throw new SsrFBlockedError("Blocked: private/internal IP address");
const allowPrivateNetwork = Boolean(params.policy?.allowPrivateNetwork);
const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames);
const isExplicitAllowed = allowedHostnames.has(normalized);
if (!allowPrivateNetwork && !isExplicitAllowed) {
if (isBlockedHostname(normalized)) {
throw new SsrFBlockedError(`Blocked hostname: ${hostname}`);
}
if (isPrivateIpAddress(normalized)) {
throw new SsrFBlockedError("Blocked: private/internal IP address");
}
}
const lookupFn = params.lookupFn ?? dnsLookup;
const results = await lookupFn(normalized, { all: true });
if (results.length === 0) {
throw new Error(`Unable to resolve hostname: ${hostname}`);
}
for (const entry of results) {
if (isPrivateIpAddress(entry.address)) {
throw new SsrFBlockedError("Blocked: resolves to private/internal IP address");
if (!allowPrivateNetwork && !isExplicitAllowed) {
for (const entry of results) {
if (isPrivateIpAddress(entry.address)) {
throw new SsrFBlockedError("Blocked: resolves to private/internal IP address");
}
}
}
@@ -246,6 +267,13 @@ export async function resolvePinnedHostname(
};
}
export async function resolvePinnedHostname(
hostname: string,
lookupFn: LookupFn = dnsLookup,
): Promise<PinnedHostname> {
return await resolvePinnedHostnameWithPolicy(hostname, { lookupFn });
}
export function createPinnedDispatcher(pinned: PinnedHostname): Dispatcher {
return new Agent({
connect: {