mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-12 23:12:54 +00:00
refactor(net): unify proxy env checks and guarded fetch modes
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
18
src/infra/net/proxy-env.ts
Normal file
18
src/infra/net/proxy-env.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user