diff --git a/src/infra/net/http-connect-tunnel.test.ts b/src/infra/net/http-connect-tunnel.test.ts index 9e2dfc4189a..048d9912d77 100644 --- a/src/infra/net/http-connect-tunnel.test.ts +++ b/src/infra/net/http-connect-tunnel.test.ts @@ -6,9 +6,16 @@ class FakeSocket extends EventEmitter { public readonly unshifted: Buffer[] = []; public destroyed = false; public writable = true; + public readonly alpnProtocol: string | false; + public readonly emitSecureConnectOnConnect: boolean; - constructor(private readonly response?: string) { + constructor( + private readonly response?: string, + options: { alpnProtocol?: string | false; emitSecureConnectOnConnect?: boolean } = {}, + ) { super(); + this.alpnProtocol = options.alpnProtocol ?? "h2"; + this.emitSecureConnectOnConnect = options.emitSecureConnectOnConnect ?? true; } write(data: string): void { @@ -64,7 +71,11 @@ const { if (!nextTargetTlsSocket) { throw new Error("nextTargetTlsSocket not set"); } - return nextTargetTlsSocket; + const socket = nextTargetTlsSocket; + if (socket.emitSecureConnectOnConnect) { + queueMicrotask(() => socket.emit("secureConnect")); + } + return socket; } if (!nextProxyTlsSocket) { throw new Error("nextProxyTlsSocket not set"); @@ -173,6 +184,82 @@ describe("openHttpConnectTunnel", () => { expect(proxySocket.destroyed).toBe(true); }); + it("rejects malformed proxy credentials through the normal cleanup path", async () => { + const proxySocket = new FakeSocket(); + setNextNetSocket(proxySocket); + const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js"); + + await expect( + openHttpConnectTunnel({ + proxyUrl: "http://%E0%A4%A@proxy.example:8080", + targetHost: "api.push.apple.com", + targetPort: 443, + }), + ).rejects.toThrow("Proxy CONNECT failed via http://proxy.example:8080: URI malformed"); + expect(proxySocket.destroyed).toBe(true); + }); + + it("caps unterminated CONNECT response headers", async () => { + const proxySocket = new FakeSocket(`HTTP/1.1 200 ${"a".repeat(17 * 1024)}`); + setNextNetSocket(proxySocket); + const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js"); + + await expect( + openHttpConnectTunnel({ + proxyUrl: "http://proxy.example:8080", + targetHost: "api.push.apple.com", + targetPort: 443, + }), + ).rejects.toThrow( + "Proxy CONNECT failed via http://proxy.example:8080: Proxy CONNECT response headers exceeded 16384 bytes", + ); + expect(proxySocket.destroyed).toBe(true); + }); + + it("waits for APNs TLS secureConnect before resolving", async () => { + const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n"); + const targetTlsSocket = new FakeSocket(undefined, { emitSecureConnectOnConnect: false }); + setNextNetSocket(proxySocket); + setNextTargetTlsSocket(targetTlsSocket); + const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js"); + + let resolved = false; + const tunnel = openHttpConnectTunnel({ + proxyUrl: "http://proxy.example:8080", + targetHost: "api.push.apple.com", + targetPort: 443, + }).then((socket) => { + resolved = true; + return socket; + }); + + await new Promise((resolve) => setImmediate(resolve)); + expect(resolved).toBe(false); + + targetTlsSocket.emit("secureConnect"); + + await expect(tunnel).resolves.toBe(targetTlsSocket); + }); + + it("rejects APNs TLS tunnels that do not negotiate h2", async () => { + const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n"); + const targetTlsSocket = new FakeSocket(undefined, { alpnProtocol: "http/1.1" }); + setNextNetSocket(proxySocket); + setNextTargetTlsSocket(targetTlsSocket); + const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js"); + + await expect( + openHttpConnectTunnel({ + proxyUrl: "http://proxy.example:8080", + targetHost: "api.push.apple.com", + targetPort: 443, + }), + ).rejects.toThrow( + "Proxy CONNECT failed via http://proxy.example:8080: APNs TLS tunnel negotiated http/1.1 instead of h2", + ); + expect(targetTlsSocket.destroyed).toBe(true); + }); + it("rejects and destroys the proxy socket when CONNECT times out", async () => { const proxySocket = new FakeSocket(); setNextNetSocket(proxySocket); diff --git a/src/infra/net/http-connect-tunnel.ts b/src/infra/net/http-connect-tunnel.ts index 82bf80725c5..e2e3f229fcb 100644 --- a/src/infra/net/http-connect-tunnel.ts +++ b/src/infra/net/http-connect-tunnel.ts @@ -8,6 +8,8 @@ export type HttpConnectTunnelParams = { timeoutMs?: number; }; +const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024; + function redactProxyUrl(proxyUrl: string): string { try { const parsed = new URL(proxyUrl); @@ -55,13 +57,15 @@ function writeConnectRequest(socket: net.Socket, proxy: URL, target: string): vo socket.write([...headers, "", ""].join("\r\n")); } -export async function openHttpConnectTunnel(params: HttpConnectTunnelParams): Promise { +export async function openHttpConnectTunnel( + params: HttpConnectTunnelParams, +): Promise { const proxy = new URL(params.proxyUrl); if (proxy.protocol !== "http:" && proxy.protocol !== "https:") { throw new Error(`Unsupported proxy protocol for APNs HTTP/2 CONNECT tunnel: ${proxy.protocol}`); } - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { let proxySocket: net.Socket | tls.TLSSocket | undefined; let targetTlsSocket: tls.TLSSocket | undefined; let timeout: NodeJS.Timeout | undefined; @@ -84,6 +88,12 @@ export async function openHttpConnectTunnel(params: HttpConnectTunnelParams): Pr proxySocket?.off("secureConnect", onConnected); }; + const cleanupTargetTlsListeners = () => { + targetTlsSocket?.off("secureConnect", onTargetSecureConnect); + targetTlsSocket?.off("error", onTargetTlsError); + targetTlsSocket?.off("close", onTargetTlsClose); + }; + const fail = (err: unknown) => { if (settled) { return; @@ -91,6 +101,7 @@ export async function openHttpConnectTunnel(params: HttpConnectTunnelParams): Pr settled = true; clearTimer(); cleanupProxyListeners(); + cleanupTargetTlsListeners(); targetTlsSocket?.destroy(); proxySocket?.destroy(); reject(formatTunnelFailure(params.proxyUrl, err)); @@ -104,6 +115,7 @@ export async function openHttpConnectTunnel(params: HttpConnectTunnelParams): Pr settled = true; clearTimer(); cleanupProxyListeners(); + cleanupTargetTlsListeners(); resolve(socket); }; @@ -113,18 +125,41 @@ export async function openHttpConnectTunnel(params: HttpConnectTunnelParams): Pr return; } const target = `${params.targetHost}:${params.targetPort}`; - writeConnectRequest(proxySocket, proxy, target); + try { + writeConnectRequest(proxySocket, proxy, target); + } catch (err) { + fail(err); + } } function onData(chunk: Buffer | string): void { const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "latin1"); responseBuffer = Buffer.concat([responseBuffer, nextChunk]); const headerEnd = responseBuffer.indexOf("\r\n\r\n"); - if (headerEnd === -1 || !proxySocket) { + if (headerEnd === -1) { + if (responseBuffer.length > MAX_CONNECT_RESPONSE_HEADER_BYTES) { + fail( + new Error( + `Proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`, + ), + ); + } + return; + } + if (!proxySocket) { + fail(new Error("Proxy socket missing after CONNECT response")); + return; + } + const bodyOffset = headerEnd + 4; + if (bodyOffset > MAX_CONNECT_RESPONSE_HEADER_BYTES) { + fail( + new Error( + `Proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`, + ), + ); return; } - const bodyOffset = headerEnd + 4; if (responseBuffer.length > bodyOffset) { proxySocket.unshift(responseBuffer.subarray(bodyOffset)); } @@ -136,14 +171,41 @@ export async function openHttpConnectTunnel(params: HttpConnectTunnelParams): Pr } cleanupProxyListeners(); - targetTlsSocket = tls.connect({ - socket: proxySocket, - servername: params.targetHost, - ALPNProtocols: ["h2"], - }); + try { + targetTlsSocket = tls.connect({ + socket: proxySocket, + servername: params.targetHost, + ALPNProtocols: ["h2"], + }); + targetTlsSocket.once("secureConnect", onTargetSecureConnect); + targetTlsSocket.once("error", onTargetTlsError); + targetTlsSocket.once("close", onTargetTlsClose); + } catch (err) { + fail(err); + } + } + + function onTargetSecureConnect(): void { + if (!targetTlsSocket) { + fail(new Error("APNs TLS socket missing after secureConnect")); + return; + } + if (targetTlsSocket.alpnProtocol !== "h2") { + const negotiated = targetTlsSocket.alpnProtocol || "no ALPN protocol"; + fail(new Error(`APNs TLS tunnel negotiated ${negotiated} instead of h2`)); + return; + } succeed(targetTlsSocket); } + function onTargetTlsError(err: Error): void { + fail(err); + } + + function onTargetTlsClose(): void { + fail(new Error("APNs TLS tunnel closed before secureConnect")); + } + function onEnd(): void { fail(new Error("Proxy closed before CONNECT response")); } diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index b8e6c0137e3..12e90ff4bbe 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -15,7 +15,8 @@ const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1", }).privateKey.export({ format: "pem", type: "pkcs8" }); -const testApnsServerKey = `-----BEGIN PRIVATE KEY----- +const testApnsServerKey = `-----BEGIN PRIVATE KEY-----`; // pragma: allowlist secret +const testApnsServerKeyPem = `${testApnsServerKey} MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC1l/DDGxT//Ma2 1EC7ON4lb+9IOrHHd437rv5DBhMt7ZXpzmfZuXyJWd/RI3ljiCcJeXwTYdzLsyaR aMRUnbzOoaI5/9LRdwmo007Y/US1ZxSjXW3L+vl3+QtiAUt6GDBZo49jB/LSCgu3 @@ -170,7 +171,7 @@ async function startFakeApnsServer(): Promise<{ }> { const requests: CapturedApnsRequest[] = []; const server = http2.createSecureServer({ - key: testApnsServerKey, + key: testApnsServerKeyPem, cert: testApnsServerCert, allowHTTP1: false, });