fix(proxy): force CONNECT tunnel for HTTP proxied requests (undici 8.7) + production deps bump (#6620)
Some checks failed
CI / Change Classification (push) Has been cancelled
Publish Fork Image to GHCR / Build and Push Fork Image (push) Has been cancelled
OpenSSF Scorecard / Scorecard analysis (push) Has been cancelled
semgrep / semgrep (push) Has been cancelled
CI / E2E Tests (4/9) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Quality Gates (Extended) (push) Has been cancelled
CI / Docs Sync (Strict) (push) Has been cancelled
CI / Docs Lint (prose — advisory) (push) Has been cancelled
CI / i18n UI Coverage (push) Has been cancelled
CI / i18n Validation (all languages) (push) Has been cancelled
CI / PR Test Policy (push) Has been cancelled
Publish to Docker Hub / Resolve Docker release metadata (push) Has been cancelled
CI / Quality Ratchet (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Package Artifact (push) Has been cancelled
CI / Electron Package Smoke (push) Has been cancelled
CI / Unit Tests (1/8) (push) Has been cancelled
CI / Unit Tests (2/8) (push) Has been cancelled
CI / Unit Tests (3/8) (push) Has been cancelled
CI / Unit Tests (4/8) (push) Has been cancelled
CI / Unit Tests (5/8) (push) Has been cancelled
CI / Unit Tests (6/8) (push) Has been cancelled
CI / Unit Tests (7/8) (push) Has been cancelled
CI / Unit Tests (8/8) (push) Has been cancelled
CI / Vitest (MCP / autoCombo / UI components) (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / SonarQube (push) Has been cancelled
CI / PR Coverage Comment (push) Has been cancelled
CI / E2E Tests (1/9) (push) Has been cancelled
CI / E2E Tests (2/9) (push) Has been cancelled
CI / E2E Tests (3/9) (push) Has been cancelled
CI / E2E Tests (5/9) (push) Has been cancelled
CI / E2E Tests (6/9) (push) Has been cancelled
CI / E2E Tests (7/9) (push) Has been cancelled
CI / E2E Tests (8/9) (push) Has been cancelled
CI / E2E Tests (9/9) (push) Has been cancelled
CI / Integration Tests (1/2) (push) Has been cancelled
CI / Integration Tests (2/2) (push) Has been cancelled
CI / Security Tests (push) Has been cancelled
CI / CI Dashboard (push) Has been cancelled
Publish to Docker Hub / Build Docker (linux/amd64) (push) Has been cancelled
Publish to Docker Hub / Build Docker (linux/arm64) (push) Has been cancelled
Publish to Docker Hub / Publish multi-arch manifests (push) Has been cancelled

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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 621 additions and 815 deletions

View file

@ -472,6 +472,12 @@ export function createProxyDispatcher(proxyUrl: string): Dispatcher {
// valid; the cast suppresses the spurious missing-`port` error.
dispatcher = new ProxyAgent({
uri: cleanUri,
// undici 8.6+ forwards plain-HTTP requests through the proxy as an origin
// request (GET http://host/…) instead of a CONNECT tunnel; upstream proxies
// that only speak CONNECT then reject it (501). OmniRoute tunnels ALL proxied
// traffic (HTTP + HTTPS) via CONNECT, so force tunneling. Unknown option on
// undici <8.6 → silently ignored (that version already tunneled by default).
proxyTunnel: true,
...proxyDispatcherOptions,
...(family !== null
? { proxyTls: { family, autoSelectFamily: false } as ProxyAgent.Options["proxyTls"] }

1355
package-lock.json generated

File diff suppressed because it is too large Load diff

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();
}
});
});