Browse Source

fix(net): keep this machine off the proxy, and refuse a literal the checks reject

Review found `127.0.0.2` routed through the proxy. The bypass list carries four
literal loopback entries because that is all a consumer reading an environment
can match, and `proxyForUrl` matched only those — leaving the rest of
`127.0.0.0/8`, `0.0.0.0`, and the IPv4-mapped spellings routed through a proxy
that could then reach them. Loopback is now recognised structurally, which a
list entry cannot express; the published entries stay for the environment
readers.

The same review case exposed a wider one. `web_fetch` skips its address checks
on a proxied hop, because the proxy resolves the origin — but a literal needs no
resolution, so the skip bought nothing and let a proxy on this machine reach
every private range those checks refuse, `169.254.169.254` included. A literal
the checks would refuse now takes the validated path, where the existing
refusal already covers it.

Tests that proved a tunnelled hop used a loopback origin, which no policy can
route through a proxy any more. They name a host only the proxy can answer for
instead — closer to what a proxied request actually looks like.

The user guide promised the proxy carried every outbound request including
telemetry. It carries neither on an older Node, nor anything a model-authored
script sends, so the promise is narrowed and the exceptions listed. A password
in a proxy URL reaching every tool DSH runs is documented there too: it is how
the variable already behaves, and worth knowing before putting one in.
Yichen Jiang 1 week ago
parent
commit
6de470e61b

+ 2 - 2
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

+ 1 - 1
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)
 
 <a id="deepseek-aidsh-invariants"></a>
 

+ 1 - 1
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)
 
 <a id="deepseek-aidsh-invariants"></a>
 

+ 2 - 2
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

+ 12 - 1
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:

+ 12 - 1
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 抓取一个页面,同时观察代理软件的连接日志:

+ 2 - 2
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

+ 1 - 1
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
 

+ 1 - 1
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` 自行识别整个网段,因为列表条目无法表达一个范围。
 
 ### 失败处理
 

+ 1 - 0
packages/net/http-proxy/src/index.ts

@@ -22,6 +22,7 @@ import { describeProxyPolicy, resolveProxyPolicy, type ProxyConfig } from './pol
 
 export {
   bypassesProxy,
+  isLoopbackHost,
   describeProxyPolicy,
   proxyForUrl,
   resolveProxyPolicy,

+ 30 - 0
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
 }
 

+ 19 - 11
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<AddressInfo> {
   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()
     }

+ 36 - 0
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<string, string>): ReturnType<typeof createLaunchEnvi
 /** Windows folds environment names, so a case distinction cannot be expressed there at all. */
 const FOLDS_ENV_CASE = process.platform === 'win32'
 
+describe('loopback routing', () => {
+  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({}))

+ 15 - 0
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.

+ 6 - 2
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)

+ 27 - 3
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<void>) | undefined
 
 function listen(server: Server): Promise<AddressInfo> {
@@ -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' }))
   })