Skip to content

Commit 149e547

Browse files
cxbAsDevclaude
andcommitted
fix(web-fetch): use exact-host trust for NO_PROXY, preserving metadata guard
Replace allowPrivateNetwork with exact-host trust via allowedHostnames so NO_PROXY-matched private/local addresses (RFC1918, tailnet, localhost) are reachable directly, but metadata and link-local addresses (169.254.x.x, cloud metadata IPs) remain blocked by the resolved-address guard in resolvePinnedHostnameWithPolicy. - Remove allowPrivateNetwork: true from the NO_PROXY effective policy - Add metadata/link-local regression tests for NO_PROXY-matched hosts - Update web_fetch docs to describe exact-host trust behavior Refs #94541, #93807 Co-Authored-By: Claude <[email protected]>
1 parent 304c68e commit 149e547

3 files changed

Lines changed: 70 additions & 16 deletions

File tree

docs/tools/web-fetch.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,12 @@ pinning. Enable this only when the proxy is operator-controlled and enforces
169169
outbound policy after DNS resolution.
170170

171171
<Note>
172-
If no HTTP(S) proxy env var is configured, or the target host is excluded by
173-
`NO_PROXY`, `web_fetch` falls back to the normal strict path with local DNS
174-
pinning.
172+
If no HTTP(S) proxy env var is configured, `web_fetch` falls back to the
173+
normal strict path with local DNS pinning. When a proxy is configured but
174+
the target host is excluded by `NO_PROXY`, the request gets exact-host
175+
trust — private and local addresses (RFC1918, tailnet, localhost) are
176+
allowed directly without going through the proxy, but metadata and
177+
link-local addresses (169.254.x.x, cloud metadata IPs) remain blocked.
175178
</Note>
176179

177180
## Limits and safety

src/infra/net/fetch-guard.ssrf.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2203,6 +2203,62 @@ describe("fetchWithSsrFGuard hardening", () => {
22032203
expect(lookupFn).toHaveBeenCalledOnce();
22042204
await result.release();
22052205
});
2206+
2207+
it("blocks link-local resolved addresses for NO_PROXY-matched hosts (metadata guard)", async () => {
2208+
clearProxyEnv();
2209+
vi.stubEnv("http_proxy", "http://proxy.corp:8080");
2210+
vi.stubEnv("no_proxy", "");
2211+
vi.stubEnv("NO_PROXY", "");
2212+
vi.stubEnv("NO_PROXY", "*.internal");
2213+
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
2214+
Agent: agentCtor,
2215+
EnvHttpProxyAgent: envHttpProxyAgentCtor,
2216+
ProxyAgent: proxyAgentCtor,
2217+
fetch: vi.fn(async () => okResponse()),
2218+
};
2219+
// DNS rebinding: hostname matches NO_PROXY but resolves to 169.254.x.x
2220+
const lookupFn = vi.fn(async () => [
2221+
{ address: "169.254.169.254", family: 4 },
2222+
]) as unknown as LookupFn;
2223+
const fetchImpl = vi.fn(async () => okResponse());
2224+
2225+
await expect(
2226+
fetchWithSsrFGuard({
2227+
url: "http://api.internal:8080/status",
2228+
fetchImpl,
2229+
lookupFn,
2230+
mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY,
2231+
}),
2232+
).rejects.toThrow("Blocked");
2233+
});
2234+
2235+
it("blocks cloud metadata resolved addresses for NO_PROXY-matched hosts (metadata guard)", async () => {
2236+
clearProxyEnv();
2237+
vi.stubEnv("http_proxy", "http://proxy.corp:8080");
2238+
vi.stubEnv("no_proxy", "");
2239+
vi.stubEnv("NO_PROXY", "");
2240+
vi.stubEnv("NO_PROXY", "*.internal");
2241+
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
2242+
Agent: agentCtor,
2243+
EnvHttpProxyAgent: envHttpProxyAgentCtor,
2244+
ProxyAgent: proxyAgentCtor,
2245+
fetch: vi.fn(async () => okResponse()),
2246+
};
2247+
// DNS rebinding: resolves to cloud metadata IP
2248+
const lookupFn = vi.fn(async () => [
2249+
{ address: "100.100.100.200", family: 4 },
2250+
]) as unknown as LookupFn;
2251+
const fetchImpl = vi.fn(async () => okResponse());
2252+
2253+
await expect(
2254+
fetchWithSsrFGuard({
2255+
url: "http://api.internal:8080/status",
2256+
fetchImpl,
2257+
lookupFn,
2258+
mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY,
2259+
}),
2260+
).rejects.toThrow("Blocked");
2261+
});
22062262
});
22072263

22082264
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {

src/infra/net/fetch-guard.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -518,14 +518,13 @@ async function fetchWithSsrFGuardInternal(
518518
// When trusted-env-proxy mode is active but NO_PROXY excludes this URL,
519519
// the request bypasses the proxy and goes direct through pinned DNS.
520520
//
521-
// Relax SSRF private-network checks for NO_PROXY-matched hosts because:
522-
// 1. useTrustedEnvProxy is an explicit operator opt-in — the user already
523-
// trusted the proxy with ALL traffic, including private-network targets.
524-
// 2. NO_PROXY is an explicit per-host exclusion — the operator chose to
525-
// bypass the proxy for these specific addresses. Direct access does not
526-
// add risk the proxy would not have already accepted.
527-
// 3. SSRF hostname allowlist and DNS pinning still apply; only the
528-
// private-network address block is relaxed, and only for the matched host.
521+
// Use exact-host trust via allowedHostnames so NO_PROXY-matched private
522+
// and local addresses (RFC1918, tailnet, localhost) can be reached
523+
// directly without going through the proxy. This approach intentionally
524+
// does NOT set allowPrivateNetwork because that would skip the
525+
// metadata/link-local resolved-address guard in
526+
// resolvePinnedHostnameWithPolicy — exact-host trust still blocks
527+
// 169.254.x.x and cloud metadata IPs after DNS resolution.
529528
const resolvedProtocol = parsedUrl.protocol === "https:" ? "https" : "http";
530529
const trustedNoProxyBypass =
531530
mode === GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY &&
@@ -535,11 +534,7 @@ async function fetchWithSsrFGuardInternal(
535534
const effectivePolicy = trustedNoProxyBypass
536535
? {
537536
...policyForUrl,
538-
allowPrivateNetwork: true,
539-
allowedHostnames: [
540-
...(policyForUrl?.allowedHostnames ?? []),
541-
parsedUrl.hostname,
542-
],
537+
allowedHostnames: [...(policyForUrl?.allowedHostnames ?? []), parsedUrl.hostname],
543538
}
544539
: policyForUrl;
545540
const canUseMockedFetchWithoutDns =

0 commit comments

Comments
 (0)