diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 6078ac2978..c09ee2e25d 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: ab3b85626ac2e4910240dc612b2416c5f8381ad7
-config-catalog.zh.md: 75c19554ecbdf7e66e9451b0c3a9c87dfbde8236
+config-catalog.md: a692330690836c4a8146b8770bda7c1f374d4fda
+config-catalog.zh.md: fd4034bacee21336d529ffbbb33322f8002fc683
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index ab3b85626a..a692330690 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -955,7 +955,7 @@ export interface ProxyConfig {
}
```
-Source: [`packages/net/http-proxy/src/index.ts:50`](../packages/net/http-proxy/src/index.ts)
+Source: [`packages/net/http-proxy/src/index.ts:51`](../packages/net/http-proxy/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 75c19554ec..fd4034bace 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -957,7 +957,7 @@ export interface ProxyConfig {
}
```
-来源:[`packages/net/http-proxy/src/index.ts:50`](../packages/net/http-proxy/src/index.ts)
+来源:[`packages/net/http-proxy/src/index.ts:51`](../packages/net/http-proxy/src/index.ts)
diff --git a/docs/user/guide/network-proxy.i18n.yaml b/docs/user/guide/network-proxy.i18n.yaml
index 1872a1b899..fe244ec86d 100644
--- a/docs/user/guide/network-proxy.i18n.yaml
+++ b/docs/user/guide/network-proxy.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/network-proxy.md
-network-proxy.md: 98722f4562bb81b4b3d015770fb04de495e09e95
-network-proxy.zh.md: 07c8455f37f1022e4c4da0001d97b30e8dafc308
+network-proxy.md: 22db4a583771ac730217a95a9e5662ef6516c7fd
+network-proxy.zh.md: a9479a582075327b35998491543e9d73056db0b5
diff --git a/docs/user/guide/network-proxy.md b/docs/user/guide/network-proxy.md
index 98722f4562..22db4a5837 100644
--- a/docs/user/guide/network-proxy.md
+++ b/docs/user/guide/network-proxy.md
@@ -2,7 +2,7 @@
English | [中文](network-proxy.zh.md)
-DSH routes every outbound request — model calls, web search, page fetches, MCP servers over HTTP, and telemetry — through the proxy named by the standard proxy environment variables. It reads them at launch; nothing else needs configuring.
+DSH routes its outbound requests — model calls, web search, page fetches, and MCP servers over HTTP — through the proxy named by the standard proxy environment variables. It reads them at launch; nothing else needs configuring. A few paths stay direct by design or by runtime limit, listed under "What stays direct" below.
## Export the variables
@@ -59,6 +59,17 @@ Node reads that variable only at process start, so export it before running `dsh
**Tools DSH runs for you follow the same proxy.** Commands in the bash tool, `git`, `gh`, and MCP servers started as child processes all inherit these variables. A child that is itself a Node program honors them only on Node 22.21 or later; an older Node connects directly.
+**A password in the proxy URL reaches those tools too.** `HTTPS_PROXY=http://alice:s3cret@proxy.example:8080` is a normal environment variable, so every command DSH runs — including the ones the model writes — can read it, and a command that prints its environment puts the password in output that is kept. This is how the variable already behaves for everything else in your shell. If that matters, give the proxy a credential-free entry point, or authenticate it some other way than in the URL.
+
+## What stays direct
+
+Not every request DSH makes goes through the proxy:
+
+- **Anything on this machine.** Loopback is always direct: `localhost`, the whole `127.0.0.0/8` range, `::1`, and `0.0.0.0`. A proxy cannot usefully reach a service that only listens locally.
+- **Code the model writes.** The workflow and code-runtime workers never receive the proxy settings, so a script the model authors cannot read a proxy URL that may carry a password. Such a script reaches the network only if it configures that itself.
+- **Telemetry on an older Node.** The OTLP exporter uses Node's own HTTP client, which learned to honor these variables in Node 22.21 and 24.5. On 22.19, 22.20, and 24.0–24.4 telemetry connects directly.
+- **`web_fetch` to a literal private address.** A URL naming an address like `http://10.0.0.5/` is refused rather than handed to the proxy, the same refusal it gets with no proxy configured.
+
## Check that it worked
Ask the agent to fetch a page and watch your proxy application's connection log:
diff --git a/docs/user/guide/network-proxy.zh.md b/docs/user/guide/network-proxy.zh.md
index 07c8455f37..a9479a5820 100644
--- a/docs/user/guide/network-proxy.zh.md
+++ b/docs/user/guide/network-proxy.zh.md
@@ -2,7 +2,7 @@
[English](network-proxy.md) | 中文
-DSH 会把每一个出站请求——模型调用、web 搜索、页面抓取、走 HTTP 的 MCP 服务器与遥测——都经由标准代理环境变量所指定的代理发出。它在启动时读取这些变量,不需要其他配置。
+DSH 会把自身的出站请求——模型调用、web 搜索、页面抓取、走 HTTP 的 MCP 服务器——都经由标准代理环境变量所指定的代理发出。它在启动时读取这些变量,不需要其他配置。有几条路径出于设计或运行时限制保持直连,下文"哪些保持直连"一节列出了它们。
## 导出环境变量
@@ -59,6 +59,17 @@ Node 只在进程启动时读取该变量,所以要在运行 `dsh` 之前导
**DSH 替你运行的工具遵循同一个代理。** bash 工具里的命令、`git`、`gh`,以及作为子进程启动的 MCP 服务器都会继承这些变量。子进程若本身是 Node 程序,则需 Node 22.21 或更高版本才会遵循;更旧的 Node 会直连。
+**代理 URL 里的密码同样会到达这些工具。** `HTTPS_PROXY=http://alice:s3cret@proxy.example:8080` 就是一个普通环境变量,因此 DSH 运行的每一条命令——包括模型编写的那些——都能读到它,而打印环境的命令会把密码写进被保留的输出。这与该变量在你 shell 里对其他一切程序的行为一致。若这一点重要,请为代理提供一个无需凭据的入口,或改用 URL 之外的方式认证。
+
+## 哪些保持直连
+
+并非 DSH 发出的每个请求都会走代理:
+
+- **本机上的一切。** loopback 始终直连:`localhost`、整个 `127.0.0.0/8` 段、`::1` 与 `0.0.0.0`。代理无法有意义地访问一个只在本地监听的服务。
+- **模型编写的代码。** workflow 与 code-runtime worker 从不接收代理配置,因此模型编写的脚本读不到可能携带密码的代理 URL。这类脚本只有自行配置才能联网。
+- **较旧 Node 上的遥测。** OTLP 导出器使用 Node 自带的 HTTP 客户端,而它从 Node 22.21 与 24.5 起才遵循这些变量。在 22.19、22.20 与 24.0–24.4 上遥测直连。
+- **`web_fetch` 访问字面量私网地址。** 形如 `http://10.0.0.5/` 的 URL 会被拒绝而非交给代理,与未配置代理时得到的拒绝相同。
+
## 验证是否生效
让 agent 抓取一个页面,同时观察代理软件的连接日志:
diff --git a/packages/net/http-proxy/README.i18n.yaml b/packages/net/http-proxy/README.i18n.yaml
index 45cfd664fc..644b4d8cba 100644
--- a/packages/net/http-proxy/README.i18n.yaml
+++ b/packages/net/http-proxy/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/net/http-proxy/README.md
-README.md: 8d28134ba076b0184987995b44924327c26f8096
-README.zh.md: 0711bb39d5077996c29992fe6a89913eaf6ec31e
+README.md: d07ca2fceced2adfa4d788462f8e04894d87c99a
+README.zh.md: 74d256a9d9f2b7d00ea01b5bf50a9b84279511fd
diff --git a/packages/net/http-proxy/README.md b/packages/net/http-proxy/README.md
index 8d28134ba0..d07ca2fcec 100644
--- a/packages/net/http-proxy/README.md
+++ b/packages/net/http-proxy/README.md
@@ -46,7 +46,7 @@ That gate cannot see inside an SDK, so every outbound call site in the repositor
`http_proxy`, `https_proxy`, `no_proxy`, and `all_proxy`, lowercase first and uppercase as the fallback, with a blank value treated as unset. `ALL_PROXY` backs both schemes, and HTTPS falls back to the HTTP proxy last — neither Node nor undici derives the first of these on its own. Values come from the launcher's snapshot, so a proxy declared in a project or `$DSH_HOME` `.env` layer works too; real environment variables still outrank both.
-Loopback is always bypassed. The harness's own Web UI, Connection transport, and every local test server would otherwise route through the proxy and loop.
+Loopback is always bypassed — `localhost`, the whole `127.0.0.0/8` range, `::1`, `0.0.0.0`, and the IPv4-mapped spellings of those. The harness's own Web UI, Connection transport, and every local test server would otherwise route through the proxy and loop. The published bypass list names only the four literal entries an environment reader can match; `proxyForUrl` recognises the range itself, because a list entry cannot express one.
### Failures
diff --git a/packages/net/http-proxy/README.zh.md b/packages/net/http-proxy/README.zh.md
index 0711bb39d5..74d256a9d9 100644
--- a/packages/net/http-proxy/README.zh.md
+++ b/packages/net/http-proxy/README.zh.md
@@ -46,7 +46,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代
`http_proxy`、`https_proxy`、`no_proxy` 与 `all_proxy`,小写优先、大写兜底,空值视为未设置。`ALL_PROXY` 为两种协议兜底,HTTPS 最后回退到 HTTP 代理——其中第一条 Node 与 undici 都不会自行推导。取值来自启动器的快照,因此写在项目或 `$DSH_HOME` 的 `.env` 层中的代理同样生效;真实环境变量仍然高于两者。
-loopback 始终被绕过。否则 Harness 自己的 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。
+loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、`0.0.0.0`,以及它们的 IPv4 映射写法。否则 Harness 自己的 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。发布出去的绕过列表只包含读取环境的消费者能匹配的四个字面量条目;`proxyForUrl` 自行识别整个网段,因为列表条目无法表达一个范围。
### 失败处理
diff --git a/packages/net/http-proxy/src/index.ts b/packages/net/http-proxy/src/index.ts
index 1520a88506..a7d58102f5 100644
--- a/packages/net/http-proxy/src/index.ts
+++ b/packages/net/http-proxy/src/index.ts
@@ -22,6 +22,7 @@ import { describeProxyPolicy, resolveProxyPolicy, type ProxyConfig } from './pol
export {
bypassesProxy,
+ isLoopbackHost,
describeProxyPolicy,
proxyForUrl,
resolveProxyPolicy,
diff --git a/packages/net/http-proxy/src/policy.ts b/packages/net/http-proxy/src/policy.ts
index ad87da5bd1..178f7d85f8 100644
--- a/packages/net/http-proxy/src/policy.ts
+++ b/packages/net/http-proxy/src/policy.ts
@@ -233,6 +233,35 @@ function splitHostPort(entry: string): { host: string; port?: string } {
return { host: entry }
}
+/** One IPv4 octet, so a loopback match cannot accept `127.999.1.1`. */
+const OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'
+
+/** The whole `127.0.0.0/8` block, not just its first address. */
+const LOOPBACK_IPV4 = new RegExp(`^127\\.${OCTET}\\.${OCTET}\\.${OCTET}$`)
+
+/**
+ * Whether a host names this machine.
+ *
+ * A proxy cannot meaningfully reach one: it would resolve the address in its own network, and a
+ * proxy running on this machine would reach a service that only listens on loopback. The bypass
+ * list carries {@link LOOPBACK_NO_PROXY} for the consumers that read an environment rather than a
+ * policy, but those are four literal entries — matching them alone leaves `127.0.0.2`, the whole
+ * rest of `127.0.0.0/8`, and the IPv4-mapped spelling routed through the proxy.
+ *
+ * @param hostname - a URL's hostname, bracketed or not.
+ * @returns true when the host is loopback or the unspecified address.
+ */
+export function isLoopbackHost(hostname: string): boolean {
+ const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
+ if (host === 'localhost' || host.endsWith('.localhost')) return true
+ if (host === '::1' || host === '::' || host === '0.0.0.0') return true
+ // An IPv4-mapped IPv6 address may keep its dotted tail or, once a URL has normalized it, carry
+ // the same four bytes as two hex groups: `::ffff:127.0.0.1` and `::ffff:7f00:1` are one address.
+ const mappedHigh = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/.exec(host)?.[1]
+ if (mappedHigh !== undefined) return Number.parseInt(mappedHigh, 16) >>> 8 === 127
+ return LOOPBACK_IPV4.test(host.startsWith('::ffff:') ? host.slice('::ffff:'.length) : host)
+}
+
/**
* Decide whether a bypass list exempts one URL. Entries match an exact host, a `.suffix` or
* `*.suffix` domain, an optional `:port`, or `*` for everything. CIDR notation is not matched —
@@ -325,6 +354,7 @@ export function resolveProxyPolicy(
export function proxyForUrl(policy: ProxyPolicy, url: URL): string | undefined {
const proxy = url.protocol === 'https:' ? policy.httpsProxy : url.protocol === 'http:' ? policy.httpProxy : undefined
if (proxy === undefined) return undefined
+ if (isLoopbackHost(url.hostname)) return undefined
return bypassesProxy(policy.noProxy, url) ? undefined : proxy
}
diff --git a/packages/net/http-proxy/tests/install.spec.ts b/packages/net/http-proxy/tests/install.spec.ts
index 431cb3af79..3f81e29b2f 100644
--- a/packages/net/http-proxy/tests/install.spec.ts
+++ b/packages/net/http-proxy/tests/install.spec.ts
@@ -21,6 +21,13 @@ let origin: Server
let proxyUrl: string
let originUrl: string
+/**
+ * The target for every assertion about a tunnelled hop. It is deliberately not loopback: no policy
+ * routes this machine through a proxy, so a loopback target could only ever prove a direct hop. The
+ * host never resolves — the client connects to the proxy, which answers the absolute-form request.
+ */
+const proxyTarget = 'http://origin.test/probe'
+
function listen(server: Server): Promise {
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => { resolve(server.address() as AddressInfo) })
@@ -67,8 +74,8 @@ describe('installGlobalProxy', () => {
it('routes the built-in global fetch through the proxy', async () => {
const dispose = await installGlobalProxy(proxyAll())
try {
- await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY')
- expect(proxied).toEqual([`GET ${originUrl}`])
+ await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
+ expect(proxied).toEqual([`GET ${proxyTarget}`])
} finally {
await dispose()
}
@@ -148,8 +155,8 @@ describe('installGlobalProxy', () => {
await expect(fetch('https://refused-scheme.invalid/', { signal: AbortSignal.timeout(1500) })).rejects.toThrow()
expect(proxied).toEqual([])
// The same policy still tunnels http, so the empty expectation above is not vacuous.
- await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY')
- expect(proxied).toEqual([`GET ${originUrl}`])
+ await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
+ expect(proxied).toEqual([`GET ${proxyTarget}`])
} finally {
await dispose()
}
@@ -159,10 +166,10 @@ describe('installGlobalProxy', () => {
describe('createDispatcher', () => {
it('tunnels through the proxy when the policy covers the URL', async () => {
const dispose = await installGlobalProxy(proxyAll())
- const dispatcher = await createDispatcher(new URL(originUrl))
+ const dispatcher = await createDispatcher(new URL(proxyTarget))
try {
const undici = await import('undici')
- const response = await undici.fetch(originUrl, { dispatcher })
+ const response = await undici.fetch(proxyTarget, { dispatcher })
await expect(response.text()).resolves.toBe('VIA-PROXY')
} finally {
await dispatcher.close()
@@ -202,10 +209,10 @@ describe('createDispatcher', () => {
// the plugin here is what a hot reload does mid-request; reading the active policy again would
// hand back a direct agent and connect to an origin nothing validated.
await dispose()
- const dispatcher = await createDispatcher(new URL(originUrl), {}, branched)
+ const dispatcher = await createDispatcher(new URL(proxyTarget), {}, branched)
try {
const undici = await import('undici')
- await expect((await undici.fetch(originUrl, { dispatcher })).text()).resolves.toBe('VIA-PROXY')
+ await expect((await undici.fetch(proxyTarget, { dispatcher })).text()).resolves.toBe('VIA-PROXY')
} finally {
await dispatcher.close()
}
@@ -343,18 +350,19 @@ describe('installGlobalProxy over an existing installation', () => {
it('stops proxying when a direct policy is installed over a proxied one', async () => {
const outer = await installGlobalProxy(proxyAll())
try {
- await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY')
+ await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
const off = await installGlobalProxy(DIRECT_POLICY)
try {
// `mode: 'off'` must actually stop proxying, not merely report a direct policy while the
- // launcher's agent keeps tunnelling.
+ // launcher's agent keeps tunnelling. A direct hop needs a host that answers, so this one
+ // reaches the real origin rather than the name only the proxy can resolve.
await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT')
expect(currentProxyPolicy()).toBe(DIRECT_POLICY)
} finally {
await off()
}
// Disposing the direct policy restores the proxy the launcher installed.
- await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY')
+ await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
} finally {
await outer()
}
diff --git a/packages/net/http-proxy/tests/policy.spec.ts b/packages/net/http-proxy/tests/policy.spec.ts
index 6db288e7a1..abce8ae29f 100644
--- a/packages/net/http-proxy/tests/policy.spec.ts
+++ b/packages/net/http-proxy/tests/policy.spec.ts
@@ -3,6 +3,7 @@ import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environ
import {
bypassesProxy,
describeProxyPolicy,
+ isLoopbackHost,
proxyForUrl,
resolveProxyPolicy,
DIRECT_POLICY,
@@ -18,6 +19,41 @@ function env(values: Record): ReturnType {
+ const proxied = { httpProxy: PROXY, httpsProxy: PROXY, noProxy: '', source: 'env' } as const
+
+ // The published bypass list carries four literal entries for the consumers that read an
+ // environment. Matching only those left the rest of `127.0.0.0/8` — including the resolver stub
+ // at `127.0.0.53` — routed through a proxy that could then reach it on the caller's behalf.
+ it.each([
+ '127.0.0.1', '127.0.0.2', '127.0.0.53', '127.255.255.254',
+ 'localhost', 'app.localhost', '[::1]', '[::ffff:127.0.0.1]', '0.0.0.0',
+ ])('never routes %s through a proxy', (host) => {
+ expect(proxyForUrl(proxied, new URL(`http://${host}:8080/`))).toBeUndefined()
+ })
+
+ it.each(['128.0.0.1', '10.0.0.5', '[::ffff:10.0.0.1]', 'notlocalhost', 'example.com'])(
+ 'still routes %s, which is not this machine',
+ (host) => {
+ expect(proxyForUrl(proxied, new URL(`http://${host}:8080/`))).toBe(PROXY)
+ },
+ )
+
+ it('rejects an out-of-range octet rather than reading it as loopback', () => {
+ expect(isLoopbackHost('127.999.1.1')).toBe(false)
+ expect(isLoopbackHost('1270.0.0.1')).toBe(false)
+ })
+
+ it('reads an IPv4-mapped address in either spelling', () => {
+ // A URL normalizes the dotted tail into hex groups, but a caller reading a bypass list or a
+ // configuration value has the dotted form in hand, and both name the same address.
+ expect(isLoopbackHost('::ffff:127.0.0.1')).toBe(true)
+ expect(isLoopbackHost('::ffff:7f00:1')).toBe(true)
+ expect(isLoopbackHost('::ffff:10.0.0.1')).toBe(false)
+ expect(isLoopbackHost('::ffff:a00:1')).toBe(false)
+ })
+})
+
describe('resolveProxyPolicy', () => {
it('resolves nothing when the environment carries no proxy', () => {
const { policy, diagnostics } = resolveProxyPolicy(env({}))
diff --git a/packages/web/web-fetch-http/src/network.ts b/packages/web/web-fetch-http/src/network.ts
index 3c83063acf..a6c6b34953 100644
--- a/packages/web/web-fetch-http/src/network.ts
+++ b/packages/web/web-fetch-http/src/network.ts
@@ -158,6 +158,21 @@ function embeddedIpv4Address(bytes: readonly number[], prefixLength: Nat64Prefix
return ipv4.join('.')
}
+/**
+ * Whether a hostname is an IP literal that {@link resolvePublicAddresses} would refuse.
+ *
+ * A proxied hop skips those checks because the proxy resolves the origin, but a literal needs no
+ * resolution: the address is already stated, and handing it to a proxy running on this machine
+ * would reach exactly the loopback or private service the checks exist to keep out of reach.
+ *
+ * @param hostname - a URL's hostname, bracketed or not.
+ * @returns true when the host is a literal address no request may be sent to.
+ */
+export function isNonPublicIpLiteral(hostname: string): boolean {
+ const unbracketed = stripIpv6Brackets(hostname)
+ return isIP(unbracketed) !== 0 && !isPublicIpAddress(unbracketed)
+}
+
/**
* Fetch through an Undici agent whose lookup callback returns only the already
* validated address set. The URL hostname remains intact for HTTP Host and TLS SNI.
diff --git a/packages/web/web-fetch-http/src/provider.ts b/packages/web/web-fetch-http/src/provider.ts
index 75c89d6e40..865977713f 100644
--- a/packages/web/web-fetch-http/src/provider.ts
+++ b/packages/web/web-fetch-http/src/provider.ts
@@ -11,7 +11,7 @@ import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult }
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import type { Response } from 'undici'
import { currentProxyPolicy, proxyForUrl, DIRECT_POLICY } from '@deepseek-ai/dsh-http-proxy'
-import { publicHttpNetwork } from './network.ts'
+import { isNonPublicIpLiteral, publicHttpNetwork } from './network.ts'
import type { PublicAddress } from './network.ts'
import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts'
@@ -128,8 +128,12 @@ export class HttpFetchProvider implements WebFetchProvider {
// One snapshot decides both the branch and the dispatcher. Reading the active policy again
// inside the transport would let a mount or disposal land between the two reads and return a
// direct, unpinned agent for a URL this branch cleared as proxied.
+ //
+ // An IP literal the address checks would refuse never takes it. The proxy would resolve
+ // nothing — the address is already stated — so the shortcut would spend the checks for
+ // nothing and let a proxy on this machine reach the very service they keep out of reach.
const policy = currentProxyPolicy() ?? DIRECT_POLICY
- if (proxyForUrl(policy, url) !== undefined) {
+ if (proxyForUrl(policy, url) !== undefined && !isNonPublicIpLiteral(url.hostname)) {
return await publicHttpNetwork.requestProxied(url, headers, signal, policy)
}
const addresses = await this.resolveAddresses(url.hostname, signal)
diff --git a/packages/web/web-fetch-http/tests/proxy.spec.ts b/packages/web/web-fetch-http/tests/proxy.spec.ts
index 23fd372c55..105d046e6e 100644
--- a/packages/web/web-fetch-http/tests/proxy.spec.ts
+++ b/packages/web/web-fetch-http/tests/proxy.spec.ts
@@ -20,6 +20,13 @@ let proxy: Server
let origin: Server
let proxyUrl: string
let originUrl: string
+
+/**
+ * The target for every assertion about a tunnelled hop. Loopback cannot serve: no policy routes
+ * this machine through a proxy. The host never resolves — the proxy answers the absolute-form
+ * request — which is also what makes the skipped resolver observable.
+ */
+const proxyTarget = 'http://origin.test/page'
let disposeProxy: (() => Promise) | undefined
function listen(server: Server): Promise {
@@ -65,10 +72,10 @@ describe('fetching through a proxy', () => {
const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
disposeProxy = await installGlobalProxy(policy())
- const result = await new HttpFetchProvider(limits).fetch({ url: originUrl })
+ const result = await new HttpFetchProvider(limits).fetch({ url: proxyTarget })
expect(result.body.content).toBe('via-proxy')
- expect(proxied).toEqual([originUrl])
+ expect(proxied).toEqual([proxyTarget])
// Through a proxy the origin's DNS happens proxy-side, so the resolver that rejects non-public
// destinations is not consulted at all.
expect(resolve).not.toHaveBeenCalled()
@@ -96,6 +103,23 @@ describe('fetching through a proxy', () => {
expect(resolve).toHaveBeenCalledOnce()
})
+ it.each(['10.0.0.5', '169.254.169.254', '127.0.0.2', '[::ffff:127.0.0.1]'])(
+ 'refuses %s instead of letting the proxy reach it for us',
+ async (host) => {
+ const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
+ disposeProxy = await installGlobalProxy(policy())
+
+ // The proxied path exists because a proxy resolves the origin; a literal needs no resolution,
+ // so taking it would spend the address checks for nothing and hand a proxy on this machine
+ // the private or loopback destination those checks exist to refuse. The hop therefore takes
+ // the validated path instead, where the existing refusal already covers it.
+ await expect(new HttpFetchProvider(limits).fetch({ url: `http://${host}:8080/` }))
+ .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
+ expect(proxied).toEqual([])
+ expect(resolve).toHaveBeenCalledOnce()
+ },
+ )
+
it('still refuses a cross-origin redirect on the proxied path', async () => {
proxy.removeAllListeners('request')
proxy.on('request', (request, response) => {
@@ -105,7 +129,7 @@ describe('fetching through a proxy', () => {
})
disposeProxy = await installGlobalProxy(policy())
- await expect(new HttpFetchProvider(limits).fetch({ url: originUrl }))
+ await expect(new HttpFetchProvider(limits).fetch({ url: proxyTarget }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
})