From 9d641c2a4909f62c2f5001b15eae2f0151437ff4 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 18 May 2026 10:23:37 +1000 Subject: [PATCH] fix: pin managed fetch dispatcher --- CHANGELOG.md | 2 ++ README.md | 3 ++- docs/README.md | 3 ++- docs/security.md | 4 ++-- docs/surfaces.md | 2 +- src/runtime.ts | 28 ++++++++++++++++++++-------- test/e2e.test.ts | 27 ++++++++++++++++++++++++++- test/package.test.ts | 18 ++++++++++++++++++ 8 files changed, 73 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95edcdf..12743ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.3.3 - Unreleased +- Fixed managed `globalThis.fetch` so later Undici global dispatcher replacement cannot bypass the active Proxyline dispatcher. + ## 0.3.2 - 2026-05-17 - Fixed managed Undici proxy dispatchers so HTTPS proxy endpoints addressed by IP do not send invalid IP-literal SNI. diff --git a/README.md b/README.md index 86d44ff..f2efddc 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,8 @@ It uses Proxyline's built-in HTTP/HTTPS Node agent, and `proxyTls` applies only - `http.request` / `http.get`: covered by global method patching and global agent replacement. - `https.request` / `https.get`: covered by global method patching and global agent replacement. -- `fetch` / undici global dispatcher: covered by the `globalThis.fetch` patch and `setGlobalDispatcher`. +- `globalThis.fetch`: covered by the fetch patch, including explicit dispatcher options and later Undici global dispatcher replacement in managed mode. +- Undici global dispatcher: installed for Undici APIs that read the current process dispatcher. - WebSocket clients accepting a Node `agent`: covered with `proxy.createWebSocketAgent()`. - Caller-built `http.Agent` / `https.Agent`: overridden in managed and active ambient mode, with TLS options preserved. - Explicit HTTP CONNECT sockets: covered with `openProxyConnectTunnel()`. diff --git a/docs/README.md b/docs/README.md index 68a0366..610f6c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,7 +25,8 @@ Process-global proxy routing for Node.js. Proxyline patches the network surfaces - `http.request` / `http.get`: covered by global method patching and global agent replacement. - `https.request` / `https.get`: covered by global method patching and global agent replacement. -- `fetch` / undici global dispatcher: covered by the `globalThis.fetch` patch and `setGlobalDispatcher`. +- `globalThis.fetch`: covered by the fetch patch, including explicit dispatcher options and later Undici global dispatcher replacement in managed mode. +- Undici global dispatcher: installed for Undici APIs that read the current process dispatcher. - WebSocket clients accepting a Node `agent`: covered with `proxy.createWebSocketAgent()`. - WebSocket clients without an `agent` option: partially covered when the upgrade path reuses patched `http.request`. - Explicit HTTP CONNECT sockets: covered with `openProxyConnectTunnel()`. diff --git a/docs/security.md b/docs/security.md index 0f2a29e..1aca171 100644 --- a/docs/security.md +++ b/docs/security.md @@ -30,7 +30,7 @@ Anything that does not flow through the patched APIs: - Direct `net.connect` or `tls.connect` calls. Code that opens raw sockets is not seen by Proxyline. - Native modules with private transport stacks (e.g. some database drivers, gRPC C bindings). -- Libraries that import and call `undici.fetch` directly with their own explicit `Dispatcher`. Proxyline's managed `globalThis.fetch` strips explicit dispatchers, but it cannot rewrite every imported undici function reference. +- Libraries that import and call `undici.fetch` directly with their own explicit `Dispatcher`. Proxyline's managed `globalThis.fetch` pins its own dispatcher even when callers pass an explicit dispatcher or later replace the Undici global dispatcher, but it cannot rewrite every imported undici function reference. - DNS resolution itself. Proxyline tells the proxy a hostname; DNS-based exfiltration via the local resolver is out of scope. - Sockets opened **before** `installProxyline` ran. Existing keepalive connections continue to use whatever transport they were created with. - Module references captured before `installProxyline` ran. Anything that stored `http.request` in a local variable at import time keeps the un-patched reference. @@ -69,7 +69,7 @@ This is deliberate: two competing proxy patches would race on `http.request` and | Threat | Mitigated | Notes | | --- | --- | --- | | Library passes a direct `http.Agent` per request | yes (managed) | Replaced before the request runs | -| Library passes a direct `Dispatcher` to managed `globalThis.fetch` | yes | Explicit dispatchers are stripped | +| Library passes a direct `Dispatcher` to managed `globalThis.fetch` | yes | Proxyline pins its dispatcher | | Trusted local endpoint needs direct routing | yes, with `bypassPolicy` | Keep the callback narrow and auditable | | Library calls imported `undici.fetch` with a direct `Dispatcher` | no | Imported function references are outside the global fetch patch | | Library uses `net.connect` directly | no | Out of scope | diff --git a/docs/surfaces.md b/docs/surfaces.md index 5e758cc..b83baa5 100644 --- a/docs/surfaces.md +++ b/docs/surfaces.md @@ -50,7 +50,7 @@ import { fetch } from "undici"; await fetch("https://api.example.com/health"); // routed through Proxyline ``` -In managed mode, Proxyline's patched `globalThis.fetch` ignores explicit `dispatcher` options so a per-call undici `Agent` cannot bypass the managed proxy. In ambient mode, and for callers using imported `undici.fetch` directly, an explicit undici `Agent` or `Dispatcher` still wins. Use `proxy.createUndiciDispatcher()` to get a dispatcher pre-wired to the same policy. +In managed mode, Proxyline's patched `globalThis.fetch` passes Proxyline's own dispatcher explicitly, so a per-call undici `Agent` or a later `setGlobalDispatcher()` call cannot bypass the managed proxy through that global fetch path. In ambient mode, and for callers using imported `undici.fetch` directly, an explicit undici `Agent` or `Dispatcher` still wins. Use `proxy.createUndiciDispatcher()` to get a dispatcher pre-wired to the same policy. Proxyline also replaces `globalThis.Request`, `Response`, `Headers`, and `FormData` with versions from its undici dependency so `globalThis.fetch` receives compatible objects on Node versions where the built-in fetch no longer shares the package dispatcher. Requests created before Proxyline installs are normalized through the standard public `Request` fields. Install Proxyline first if you need non-standard Request internals, such as a dispatcher embedded in a pre-install native `Request`, to be preserved. diff --git a/src/runtime.ts b/src/runtime.ts index c602117..7f80168 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -216,28 +216,40 @@ async function normalizeFetchInput( }); } -function stripFetchDispatcher( +function withManagedFetchDispatcher( init: Parameters[1], + dispatcher: Dispatcher, ): Parameters[1] { - if (typeof init !== "object" || init === null) { - return init; + if ( + init !== undefined && + init !== null && + typeof init !== "object" && + typeof init !== "function" + ) { + throw new TypeError( + `Request constructor: Expected ${String(init)} to be one of: Null, Undefined, Object.`, + ); } - const sanitized = Object.create(init); + const sanitized = init === undefined || init === null ? {} : Object.create(init); Reflect.defineProperty(sanitized, "dispatcher", { configurable: true, enumerable: true, - value: undefined, + value: dispatcher, writable: true, }); return sanitized; } const proxylineFetch: typeof globalThis.fetch = async (input, init) => { - const managedMode = activeRuntime?.mode === "managed"; + const managedDispatcher = activeRuntime?.mode === "managed" + ? activeRuntime.installedDispatcher + : undefined; const normalizedInput = await normalizeFetchInput(input, init, { - preserveDispatcher: !managedMode, + preserveDispatcher: managedDispatcher === undefined, }); - const normalizedInit = managedMode ? stripFetchDispatcher(init) : init; + const normalizedInit = managedDispatcher === undefined + ? init + : withManagedFetchDispatcher(init, managedDispatcher); const response: unknown = await Reflect.apply( undiciFetch, undefined, diff --git a/test/e2e.test.ts b/test/e2e.test.ts index ba6e588..8032063 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -7,7 +7,7 @@ import { Duplex } from "node:stream"; import tls from "node:tls"; import { URL } from "node:url"; import test from "node:test"; -import { Dispatcher, fetch } from "undici"; +import { Agent as UndiciAgent, Dispatcher, fetch, setGlobalDispatcher } from "undici"; import WebSocket from "ws"; import { createWebSocketServer } from "./support/ws-server.js"; import { @@ -1552,6 +1552,31 @@ test("managed mode routes undici fetch through the lab proxy", async () => { } }); +test("managed global fetch keeps proxy routing after global dispatcher replacement", async () => { + const lab = await startProxyLab(); + const proxy = installGlobalProxy({ mode: "managed", proxyUrl: lab.proxyUrl }); + const directDispatcher = new UndiciAgent(); + try { + setGlobalDispatcher(directDispatcher); + + const response = await globalThis.fetch(`${lab.targetUrl}/denied`); + + assert.equal(response.status, 403); + assert.match(await response.text(), /blocked by proxy lab/); + assert.ok( + lab.events.some( + (event) => + (event.type === "deny" && event.url.endsWith("/denied")) || + (event.type === "deny_connect" && event.path === "/denied"), + ), + ); + } finally { + await directDispatcher.close(); + proxy.stop(); + await lab.close(); + } +}); + test("managed mode bypass policy sends matching node and undici traffic direct", async () => { const lab = await startProxyLab(); const targetHost = new URL(lab.targetUrl).host; diff --git a/test/package.test.ts b/test/package.test.ts index cd912a3..ecbbb5e 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -197,6 +197,24 @@ test("package entrypoint managed fetch ignores explicit dispatcher overrides", a } }); +test("package entrypoint managed fetch rejects invalid primitive init values", async () => { + const lab = await startProxyLab(); + const proxy = installGlobalProxy({ mode: "managed", proxyUrl: lab.proxyUrl }); + try { + await assert.rejects( + async () => { + await Reflect.apply(globalThis.fetch, globalThis, [`${lab.targetUrl}/allowed`, 1]); + }, + (error: unknown) => error instanceof TypeError && + error.message.includes("Request constructor"), + ); + assert.equal(lab.events.length, 0); + } finally { + proxy.stop(); + await lab.close(); + } +}); + test("package entrypoint preserves standard options on preinstall Requests", async () => { const lab = await startProxyLab(); const requestUnknown: unknown = Reflect.construct(globalThis.Request, [