refactor(net): unify proxy env checks and guarded fetch modes

This commit is contained in:
Peter Steinberger
2026-03-02 16:24:20 +00:00
parent a229ae6c3e
commit c973b053a5
12 changed files with 129 additions and 117 deletions

View File

@@ -1,6 +1,6 @@
import { EnvHttpProxyAgent } from "undici";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchWithSsrFGuard } from "./fetch-guard.js";
import { fetchWithSsrFGuard, GUARDED_FETCH_MODE } from "./fetch-guard.js";
function redirectResponse(location: string): Response {
return new Response(null, {
@@ -180,7 +180,7 @@ describe("fetchWithSsrFGuard hardening", () => {
url: "https://public.example/resource",
fetchImpl,
lookupFn,
proxy: "env",
mode: GUARDED_FETCH_MODE.STRICT,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
@@ -202,8 +202,7 @@ describe("fetchWithSsrFGuard hardening", () => {
url: "https://public.example/resource",
fetchImpl,
lookupFn,
proxy: "env",
dangerouslyAllowEnvProxyWithoutPinnedDns: true,
mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);

View File

@@ -1,6 +1,7 @@
import { EnvHttpProxyAgent, type Dispatcher } from "undici";
import { logWarn } from "../../logger.js";
import { bindAbortRelay } from "../../utils/fetch-timeout.js";
import { hasProxyEnvConfigured } from "./proxy-env.js";
import {
closeDispatcher,
createPinnedDispatcher,
@@ -12,6 +13,13 @@ import {
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export const GUARDED_FETCH_MODE = {
STRICT: "strict",
TRUSTED_ENV_PROXY: "trusted_env_proxy",
} as const;
export type GuardedFetchMode = (typeof GUARDED_FETCH_MODE)[keyof typeof GUARDED_FETCH_MODE];
export type GuardedFetchOptions = {
url: string;
fetchImpl?: FetchLike;
@@ -21,11 +29,12 @@ export type GuardedFetchOptions = {
signal?: AbortSignal;
policy?: SsrFPolicy;
lookupFn?: LookupFn;
mode?: GuardedFetchMode;
pinDns?: boolean;
/** @deprecated use `mode: "trusted_env_proxy"` for trusted/operator-controlled URLs. */
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.
* @deprecated use `mode: "trusted_env_proxy"` instead.
*/
dangerouslyAllowEnvProxyWithoutPinnedDns?: boolean;
auditContext?: string;
@@ -37,15 +46,12 @@ export type GuardedFetchResult = {
release: () => Promise<void>;
};
type GuardedFetchPresetOptions = Omit<
GuardedFetchOptions,
"mode" | "proxy" | "dangerouslyAllowEnvProxyWithoutPinnedDns"
>;
const DEFAULT_MAX_REDIRECTS = 3;
const ENV_PROXY_KEYS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
] as const;
const CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS = [
"authorization",
"proxy-authorization",
@@ -53,14 +59,24 @@ const CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS = [
"cookie2",
];
function hasEnvProxyConfigured(): boolean {
for (const key of ENV_PROXY_KEYS) {
const value = process.env[key];
if (typeof value === "string" && value.trim()) {
return true;
}
export function withStrictGuardedFetchMode(params: GuardedFetchPresetOptions): GuardedFetchOptions {
return { ...params, mode: GUARDED_FETCH_MODE.STRICT };
}
export function withTrustedEnvProxyGuardedFetchMode(
params: GuardedFetchPresetOptions,
): GuardedFetchOptions {
return { ...params, mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY };
}
function resolveGuardedFetchMode(params: GuardedFetchOptions): GuardedFetchMode {
if (params.mode) {
return params.mode;
}
return false;
if (params.proxy === "env" && params.dangerouslyAllowEnvProxyWithoutPinnedDns === true) {
return GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY;
}
return GUARDED_FETCH_MODE.STRICT;
}
function isRedirectStatus(status: number): boolean {
@@ -122,6 +138,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
typeof params.maxRedirects === "number" && Number.isFinite(params.maxRedirects)
? Math.max(0, Math.floor(params.maxRedirects))
: DEFAULT_MAX_REDIRECTS;
const mode = resolveGuardedFetchMode(params);
const { signal, cleanup } = buildAbortSignal({
timeoutMs: params.timeoutMs,
@@ -162,8 +179,9 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
lookupFn: params.lookupFn,
policy: params.policy,
});
const hasEnvProxy = params.proxy === "env" && hasEnvProxyConfigured();
if (hasEnvProxy && params.dangerouslyAllowEnvProxyWithoutPinnedDns === true) {
const canUseTrustedEnvProxy =
mode === GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY && hasProxyEnvConfigured();
if (canUseTrustedEnvProxy) {
dispatcher = new EnvHttpProxyAgent();
} else if (params.pinDns !== false) {
dispatcher = createPinnedDispatcher(pinned);

View File

@@ -0,0 +1,18 @@
export const PROXY_ENV_KEYS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
] as const;
export function hasProxyEnvConfigured(env: NodeJS.ProcessEnv = process.env): boolean {
for (const key of PROXY_ENV_KEYS) {
const value = env[key];
if (typeof value === "string" && value.trim().length > 0) {
return true;
}
}
return false;
}

View File

@@ -63,7 +63,7 @@ function normalizeHostnameAllowlist(values?: string[]): string[] {
);
}
function resolveAllowPrivateNetwork(policy?: SsrFPolicy): boolean {
export function isPrivateNetworkAllowedByPolicy(policy?: SsrFPolicy): boolean {
return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
}
@@ -282,7 +282,7 @@ export async function resolvePinnedHostnameWithPolicy(
throw new Error("Invalid hostname");
}
const allowPrivateNetwork = resolveAllowPrivateNetwork(params.policy);
const allowPrivateNetwork = isPrivateNetworkAllowedByPolicy(params.policy);
const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames);
const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
const isExplicitAllowed = allowedHostnames.has(normalized);