fix: harden OpenResponses URL input fetching

This commit is contained in:
Peter Steinberger
2026-02-13 01:38:15 +01:00
parent 4199f9889f
commit 99f28031e5
13 changed files with 431 additions and 11 deletions

View File

@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { fetchWithSsrFGuard } from "./fetch-guard.js";
function redirectResponse(location: string): Response {
return new Response(null, {
status: 302,
headers: { location },
});
}
describe("fetchWithSsrFGuard hardening", () => {
it("blocks private IP literal URLs before fetch", async () => {
const fetchImpl = vi.fn();
await expect(
fetchWithSsrFGuard({
url: "http://127.0.0.1:8080/internal",
fetchImpl,
}),
).rejects.toThrow(/private|internal|blocked/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("blocks redirect chains that hop to private hosts", async () => {
const lookupFn = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]);
const fetchImpl = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1:6379/"));
await expect(
fetchWithSsrFGuard({
url: "https://public.example/start",
fetchImpl,
lookupFn,
}),
).rejects.toThrow(/private|internal|blocked/i);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("enforces hostname allowlist policies", async () => {
const fetchImpl = vi.fn();
await expect(
fetchWithSsrFGuard({
url: "https://evil.example.org/file.txt",
fetchImpl,
policy: { hostnameAllowlist: ["cdn.example.com", "*.assets.example.com"] },
}),
).rejects.toThrow(/allowlist/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("allows wildcard allowlisted hosts", async () => {
const lookupFn = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]);
const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 }));
const result = await fetchWithSsrFGuard({
url: "https://img.assets.example.com/pic.png",
fetchImpl,
lookupFn,
policy: { hostnameAllowlist: ["*.assets.example.com"] },
});
expect(result.response.status).toBe(200);
expect(fetchImpl).toHaveBeenCalledTimes(1);
await result.release();
});
});

View File

@@ -1,10 +1,11 @@
import type { Dispatcher } from "undici";
import { logWarn } from "../../logger.js";
import {
closeDispatcher,
createPinnedDispatcher,
resolvePinnedHostname,
resolvePinnedHostnameWithPolicy,
type LookupFn,
SsrFBlockedError,
type SsrFPolicy,
} from "./ssrf.js";
@@ -20,6 +21,7 @@ export type GuardedFetchOptions = {
policy?: SsrFPolicy;
lookupFn?: LookupFn;
pinDns?: boolean;
auditContext?: string;
};
export type GuardedFetchResult = {
@@ -113,15 +115,10 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
let dispatcher: Dispatcher | null = null;
try {
const usePolicy = Boolean(
params.policy?.allowPrivateNetwork || params.policy?.allowedHostnames?.length,
);
const pinned = usePolicy
? await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
lookupFn: params.lookupFn,
policy: params.policy,
})
: await resolvePinnedHostname(parsedUrl.hostname, params.lookupFn);
const pinned = await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
lookupFn: params.lookupFn,
policy: params.policy,
});
if (params.pinDns !== false) {
dispatcher = createPinnedDispatcher(pinned);
}
@@ -164,6 +161,12 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
release: async () => release(dispatcher),
};
} catch (err) {
if (err instanceof SsrFBlockedError) {
const context = params.auditContext ?? "url-fetch";
logWarn(
`security: blocked URL fetch (${context}) target=${parsedUrl.origin}${parsedUrl.pathname} reason=${err.message}`,
);
}
await release(dispatcher);
throw err;
}

View File

@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { createPinnedLookup, resolvePinnedHostname } from "./ssrf.js";
import {
createPinnedLookup,
resolvePinnedHostname,
resolvePinnedHostnameWithPolicy,
} from "./ssrf.js";
describe("ssrf pinning", () => {
it("pins resolved addresses for the target hostname", async () => {
@@ -68,4 +72,34 @@ describe("ssrf pinning", () => {
expect(fallback).toHaveBeenCalledTimes(1);
expect(result.address).toBe("1.2.3.4");
});
it("enforces hostname allowlist when configured", async () => {
const lookup = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]);
await expect(
resolvePinnedHostnameWithPolicy("api.example.com", {
lookupFn: lookup,
policy: { hostnameAllowlist: ["cdn.example.com", "*.trusted.example"] },
}),
).rejects.toThrow(/allowlist/i);
expect(lookup).not.toHaveBeenCalled();
});
it("supports wildcard hostname allowlist patterns", async () => {
const lookup = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]);
await expect(
resolvePinnedHostnameWithPolicy("assets.example.com", {
lookupFn: lookup,
policy: { hostnameAllowlist: ["*.example.com"] },
}),
).resolves.toMatchObject({ hostname: "assets.example.com" });
await expect(
resolvePinnedHostnameWithPolicy("example.com", {
lookupFn: lookup,
policy: { hostnameAllowlist: ["*.example.com"] },
}),
).rejects.toThrow(/allowlist/i);
});
});

View File

@@ -20,6 +20,7 @@ export type LookupFn = typeof dnsLookup;
export type SsrFPolicy = {
allowPrivateNetwork?: boolean;
allowedHostnames?: string[];
hostnameAllowlist?: string[];
};
const PRIVATE_IPV6_PREFIXES = ["fe80:", "fec0:", "fc", "fd"];
@@ -40,6 +41,37 @@ function normalizeHostnameSet(values?: string[]): Set<string> {
return new Set(values.map((value) => normalizeHostname(value)).filter(Boolean));
}
function normalizeHostnameAllowlist(values?: string[]): string[] {
if (!values || values.length === 0) {
return [];
}
return Array.from(
new Set(
values
.map((value) => normalizeHostname(value))
.filter((value) => value !== "*" && value !== "*." && value.length > 0),
),
);
}
function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
if (pattern.startsWith("*.")) {
const suffix = pattern.slice(2);
if (!suffix || hostname === suffix) {
return false;
}
return hostname.endsWith(`.${suffix}`);
}
return hostname === pattern;
}
function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolean {
if (allowlist.length === 0) {
return true;
}
return allowlist.some((pattern) => isHostnameAllowedByPattern(hostname, pattern));
}
function parseIpv4(address: string): number[] | null {
const parts = address.split(".");
if (parts.length !== 4) {
@@ -229,8 +261,13 @@ export async function resolvePinnedHostnameWithPolicy(
const allowPrivateNetwork = Boolean(params.policy?.allowPrivateNetwork);
const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames);
const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
const isExplicitAllowed = allowedHostnames.has(normalized);
if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);
}
if (!allowPrivateNetwork && !isExplicitAllowed) {
if (isBlockedHostname(normalized)) {
throw new SsrFBlockedError(`Blocked hostname: ${hostname}`);