fix(proxy): force CONNECT tunnel for HTTP proxied requests (undici 8.7) + production deps bump (#6620)

fix(proxy): force CONNECT tunnel for HTTP proxied requests (undici 8.7) + production deps bump.

undici 8.6+ changed ProxyAgent to forward plain-HTTP via request-proxy instead of CONNECT, breaking OAuth refresh through a connection proxy (501). proxyDispatcher now passes proxyTunnel:true. Validated: Unit Tests 3/8 (the OAuth-proxy test) green, new regression test green (fails without the fix on undici 8.7), SonarQube green.

Supersedes #6380. (--admin: the only red is Electron Package Smoke failing on a next-build artifact 'digest-mismatch' — a GitHub Actions infra flake corrupting the asar ('file data stream has unexpected number of bytes'); the better-sqlite3 rebuild itself succeeded (gyp ok) and the electron path is unchanged from #6605 which passed the smoke. Not this diff.)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-08 00:41:19 -03:00
committed by GitHub
parent 44d501ae6a
commit 9cd18bf9a1
3 changed files with 621 additions and 815 deletions

View File

@@ -26,7 +26,12 @@ describe("proxyDispatcher SOCKS5 host handling", () => {
describe("proxyDispatcher family directive", () => {
it("encodes family from a config object onto the URL", () => {
const url = proxyConfigToUrl({ type: "http", host: "proxy.example.com", port: 8080, family: "ipv6" });
const url = proxyConfigToUrl({
type: "http",
host: "proxy.example.com",
port: 8080,
family: "ipv6",
});
assert.ok(url!.includes("family=ipv6"), url!);
});
it("derives 6 for an explicit ipv6 directive on a hostname proxy", () => {
@@ -38,7 +43,10 @@ describe("proxyDispatcher family directive", () => {
assert.equal(__resolveDispatcherFamilyForTest("http://proxy.example.com:8080"), null);
});
it("throws (fail-closed) when family=ipv6 contradicts a v4 literal", () => {
assert.throws(() => __resolveDispatcherFamilyForTest("http://203.0.113.7:8080?family=ipv6"), /family/i);
assert.throws(
() => __resolveDispatcherFamilyForTest("http://203.0.113.7:8080?family=ipv6"),
/family/i
);
});
});
@@ -97,3 +105,66 @@ describe("proxyDispatcher connection pool", () => {
assert.equal(closeCount, 1);
});
});
describe("proxyDispatcher CONNECT tunneling (undici 8.6+ proxyTunnel)", () => {
it("tunnels a plain-HTTP proxied request via CONNECT, not origin-forwarding", async () => {
const http = await import("node:http");
const net = await import("node:net");
const { createProxyDispatcher } = await import("../../open-sse/utils/proxyDispatcher.ts");
const { fetch: undiciFetch } = await import("undici");
// Upstream HTTP target.
const target = http.createServer((_req, res) => {
res.writeHead(200);
res.end("ok");
});
await new Promise((r) => target.listen(0, r));
const targetPort = (target.address() as { port: number }).port;
// Proxy that ONLY speaks CONNECT: 501 on a forwarded origin request, tunnels on CONNECT.
let sawConnect = false;
let sawForward = false;
const proxy = http.createServer((_req, res) => {
sawForward = true;
res.writeHead(501);
res.end("CONNECT only");
});
proxy.on("connect", (req, socket) => {
sawConnect = true;
const [host, port] = String(req.url).split(":");
const upstream = net.connect(Number(port), host, () => {
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
upstream.pipe(socket);
socket.pipe(upstream);
});
upstream.on("error", () => socket.destroy());
});
await new Promise((r) => proxy.listen(0, r));
const proxyPort = (proxy.address() as { port: number }).port;
try {
const dispatcher = createProxyDispatcher(`http://127.0.0.1:${proxyPort}`);
const res = await undiciFetch(`http://127.0.0.1:${targetPort}/token`, {
method: "POST",
// @ts-expect-error undici dispatcher option
dispatcher,
signal: AbortSignal.timeout(3000),
});
assert.equal(res.status, 200, "request must succeed via CONNECT tunnel");
assert.equal(
sawConnect,
true,
"proxy must receive a CONNECT (tunnel), not a forwarded request"
);
assert.equal(
sawForward,
false,
"proxy must NOT receive a forwarded origin request (undici 8.6+ regression)"
);
} finally {
proxy.close();
target.close();
clearDispatcherCache();
}
});
});