fix: preserve dns pinning for strict web SSRF fetches

This commit is contained in:
Peter Steinberger
2026-03-02 15:54:18 +00:00
parent a3d2021eea
commit 345abf0b20
9 changed files with 83 additions and 9 deletions

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { EnvHttpProxyAgent } from "undici";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchWithSsrFGuard } from "./fetch-guard.js";
function redirectResponse(location: string): Response {
@@ -14,6 +15,9 @@ function okResponse(body = "ok"): Response {
describe("fetchWithSsrFGuard hardening", () => {
type LookupFn = NonNullable<Parameters<typeof fetchWithSsrFGuard>[0]["lookupFn"]>;
afterEach(() => {
vi.unstubAllEnvs();
});
it("blocks private and legacy loopback literals before fetch", async () => {
const blockedUrls = [
@@ -159,4 +163,50 @@ describe("fetchWithSsrFGuard hardening", () => {
expect(headers.get("authorization")).toBe("Bearer secret");
await result.release();
});
it("ignores env proxy by default to preserve DNS-pinned destination binding", async () => {
vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890");
const lookupFn = vi.fn(async () => [
{ address: "93.184.216.34", family: 4 },
]) as unknown as LookupFn;
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const requestInit = init as RequestInit & { dispatcher?: unknown };
expect(requestInit.dispatcher).toBeDefined();
expect(requestInit.dispatcher).not.toBeInstanceOf(EnvHttpProxyAgent);
return okResponse();
});
const result = await fetchWithSsrFGuard({
url: "https://public.example/resource",
fetchImpl,
lookupFn,
proxy: "env",
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
await result.release();
});
it("uses env proxy only when dangerous proxy bypass is explicitly enabled", async () => {
vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890");
const lookupFn = vi.fn(async () => [
{ address: "93.184.216.34", family: 4 },
]) as unknown as LookupFn;
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const requestInit = init as RequestInit & { dispatcher?: unknown };
expect(requestInit.dispatcher).toBeInstanceOf(EnvHttpProxyAgent);
return okResponse();
});
const result = await fetchWithSsrFGuard({
url: "https://public.example/resource",
fetchImpl,
lookupFn,
proxy: "env",
dangerouslyAllowEnvProxyWithoutPinnedDns: true,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
await result.release();
});
});

View File

@@ -23,6 +23,11 @@ export type GuardedFetchOptions = {
lookupFn?: LookupFn;
pinDns?: boolean;
proxy?: "env";
/**
* Env proxies can break destination binding between SSRF pre-check and connect-time target.
* Keep this off for untrusted URLs; enable only for trusted/operator-controlled endpoints.
*/
dangerouslyAllowEnvProxyWithoutPinnedDns?: boolean;
auditContext?: string;
};
@@ -157,7 +162,8 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
lookupFn: params.lookupFn,
policy: params.policy,
});
if (params.proxy === "env" && hasEnvProxyConfigured()) {
const hasEnvProxy = params.proxy === "env" && hasEnvProxyConfigured();
if (hasEnvProxy && params.dangerouslyAllowEnvProxyWithoutPinnedDns === true) {
dispatcher = new EnvHttpProxyAgent();
} else if (params.pinDns !== false) {
dispatcher = createPinnedDispatcher(pinned);