Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
4 changes: 2 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/surfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
28 changes: 20 additions & 8 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,28 +216,40 @@ async function normalizeFetchInput(
});
}

function stripFetchDispatcher(
function withManagedFetchDispatcher(
init: Parameters<typeof globalThis.fetch>[1],
dispatcher: Dispatcher,
): Parameters<typeof globalThis.fetch>[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,
Expand Down
27 changes: 26 additions & 1 deletion test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions test/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, [
Expand Down
Loading