From 543bbcba9dfad80e65ebbff96259cad39938f091 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 4 May 2026 02:30:38 +1000 Subject: [PATCH] fix: proxy direct APNs HTTP2 sessions --- src/infra/net/http-connect-tunnel.test.ts | 106 ++++++++++++++++++ src/infra/net/http-connect-tunnel.ts | 128 ++++++++++++++++++++++ src/infra/push-apns-http2.test.ts | 87 +++++++++++++++ src/infra/push-apns-http2.ts | 58 ++++++++++ src/infra/push-apns.ts | 7 +- 5 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 src/infra/net/http-connect-tunnel.test.ts create mode 100644 src/infra/net/http-connect-tunnel.ts create mode 100644 src/infra/push-apns-http2.test.ts create mode 100644 src/infra/push-apns-http2.ts diff --git a/src/infra/net/http-connect-tunnel.test.ts b/src/infra/net/http-connect-tunnel.test.ts new file mode 100644 index 00000000000..372d01ded8b --- /dev/null +++ b/src/infra/net/http-connect-tunnel.test.ts @@ -0,0 +1,106 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; + +class FakeSocket extends EventEmitter { + public readonly writes: string[] = []; + public readonly unshifted: Buffer[] = []; + public destroyed = false; + + constructor(private readonly response: string) { + super(); + } + + write(data: string): void { + this.writes.push(data); + queueMicrotask(() => this.emit("data", Buffer.from(this.response, "latin1"))); + } + + destroy(): void { + this.destroyed = true; + } + + unshift(data: Buffer): void { + this.unshifted.push(data); + } +} + +const { connectSpy, nextSocket } = vi.hoisted(() => { + let nextSocket: FakeSocket | undefined; + return { + connectSpy: vi.fn(() => { + if (!nextSocket) { + throw new Error("nextSocket not set"); + } + const socket = nextSocket; + queueMicrotask(() => socket.emit("connect")); + return socket; + }), + nextSocket: (socket: FakeSocket) => { + nextSocket = socket; + }, + }; +}); + +vi.mock("node:net", () => ({ + connect: connectSpy, +})); + +describe("openHttpConnectTunnel", () => { + it("opens an HTTP CONNECT tunnel through the configured proxy", async () => { + const socket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n"); + nextSocket(socket); + + const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js"); + + const result = await openHttpConnectTunnel({ + proxyUrl: "http://proxy.example:8080", + targetHost: "api.push.apple.com", + targetPort: 443, + }); + + expect(result).toBe(socket); + expect(connectSpy).toHaveBeenCalledWith({ host: "proxy.example", port: 8080 }); + expect(socket.writes[0]).toBe( + [ + "CONNECT api.push.apple.com:443 HTTP/1.1", + "Host: api.push.apple.com:443", + "Proxy-Connection: Keep-Alive", + "", + "", + ].join("\r\n"), + ); + }); + + it("sends basic proxy authorization for proxy URLs with credentials", async () => { + const socket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n"); + nextSocket(socket); + + const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js"); + + await openHttpConnectTunnel({ + proxyUrl: "http://user:pass@proxy.example:8080", + targetHost: "api.push.apple.com", + targetPort: 443, + }); + + expect(socket.writes[0]).toContain( + `Proxy-Authorization: Basic ${Buffer.from("user:pass").toString("base64")}`, + ); + }); + + it("destroys the socket and redacts credentials when CONNECT fails", async () => { + const socket = new FakeSocket("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"); + nextSocket(socket); + + const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js"); + + await expect( + openHttpConnectTunnel({ + proxyUrl: "http://user:secret@proxy.example:8080", + targetHost: "api.push.apple.com", + targetPort: 443, + }), + ).rejects.toThrow("Proxy CONNECT failed via http://proxy.example:8080: HTTP/1.1 407"); + expect(socket.destroyed).toBe(true); + }); +}); diff --git a/src/infra/net/http-connect-tunnel.ts b/src/infra/net/http-connect-tunnel.ts new file mode 100644 index 00000000000..a9134512bf6 --- /dev/null +++ b/src/infra/net/http-connect-tunnel.ts @@ -0,0 +1,128 @@ +import { once } from "node:events"; +import * as net from "node:net"; + +export type HttpConnectTunnelParams = { + proxyUrl: string; + targetHost: string; + targetPort: number; + timeoutMs?: number; +}; + +function redactProxyUrl(proxyUrl: string): string { + try { + const parsed = new URL(proxyUrl); + parsed.username = ""; + parsed.password = ""; + return parsed.toString().replace(/\/$/, ""); + } catch { + return ""; + } +} + +function resolveProxyPort(proxy: URL): number { + if (proxy.port) { + return Number(proxy.port); + } + return proxy.protocol === "https:" ? 443 : 80; +} + +function resolveProxyAuthorization(proxy: URL): string | undefined { + if (!proxy.username && !proxy.password) { + return undefined; + } + const username = decodeURIComponent(proxy.username); + const password = decodeURIComponent(proxy.password); + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; +} + +function readConnectResponse(socket: net.Socket): Promise { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + + const cleanup = () => { + socket.off("data", onData); + socket.off("end", onEnd); + socket.off("error", onError); + socket.off("close", onClose); + }; + const fail = (err: Error) => { + cleanup(); + reject(err); + }; + const onData = (chunk: Buffer | string) => { + const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "latin1"); + buffer = Buffer.concat([buffer, nextChunk]); + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) { + return; + } + cleanup(); + const bodyOffset = headerEnd + 4; + if (buffer.length > bodyOffset) { + socket.unshift(buffer.subarray(bodyOffset)); + } + resolve(buffer.subarray(0, bodyOffset).toString("latin1")); + }; + const onEnd = () => fail(new Error("Proxy closed before CONNECT response")); + const onError = (err: Error) => fail(err); + const onClose = () => fail(new Error("Proxy closed before CONNECT response")); + + socket.on("data", onData); + socket.once("end", onEnd); + socket.once("error", onError); + socket.once("close", onClose); + }); +} + +export async function openHttpConnectTunnel(params: HttpConnectTunnelParams): Promise { + const proxy = new URL(params.proxyUrl); + if (proxy.protocol !== "http:") { + throw new Error(`Unsupported proxy protocol for APNs HTTP/2 CONNECT tunnel: ${proxy.protocol}`); + } + const socket = net.connect({ host: proxy.hostname, port: resolveProxyPort(proxy) }); + let timeout: NodeJS.Timeout | undefined; + const clear = () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + }; + try { + if (params.timeoutMs && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0) { + timeout = setTimeout(() => { + socket.destroy( + new Error(`Proxy CONNECT timed out after ${Math.trunc(params.timeoutMs ?? 0)}ms`), + ); + }, Math.trunc(params.timeoutMs)); + timeout.unref?.(); + } + + await once(socket, "connect"); + const target = `${params.targetHost}:${params.targetPort}`; + const headers = [ + `CONNECT ${target} HTTP/1.1`, + `Host: ${target}`, + "Proxy-Connection: Keep-Alive", + ]; + const authorization = resolveProxyAuthorization(proxy); + if (authorization) { + headers.push(`Proxy-Authorization: ${authorization}`); + } + socket.write([...headers, "", ""].join("\r\n")); + + const response = await readConnectResponse(socket); + const statusLine = response.split("\r\n", 1)[0] ?? ""; + if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) { + socket.destroy(); + throw new Error(`Proxy CONNECT failed via ${redactProxyUrl(params.proxyUrl)}: ${statusLine}`); + } + clear(); + return socket; + } catch (err) { + clear(); + if (!socket.destroyed) { + socket.destroy(); + } + throw err; + } +} diff --git a/src/infra/push-apns-http2.test.ts b/src/infra/push-apns-http2.test.ts new file mode 100644 index 00000000000..c97ec4918b9 --- /dev/null +++ b/src/infra/push-apns-http2.test.ts @@ -0,0 +1,87 @@ +import type http2 from "node:http2"; +import { describe, expect, it, vi } from "vitest"; + +const { connectSpy, tlsConnectSpy, tunnelSpy, fakeSession, fakeTlsSocket } = vi.hoisted(() => { + const fakeSession = { close: vi.fn(), destroy: vi.fn() }; + const fakeTlsSocket = { encrypted: true }; + return { + fakeSession, + fakeTlsSocket, + connectSpy: vi.fn(() => fakeSession), + tlsConnectSpy: vi.fn(() => fakeTlsSocket), + tunnelSpy: vi.fn(async () => ({ tunneled: true })), + }; +}); + +vi.mock("node:http2", () => ({ + default: { connect: connectSpy }, + connect: connectSpy, +})); + +vi.mock("node:tls", () => ({ + default: { connect: tlsConnectSpy }, + connect: tlsConnectSpy, +})); + +vi.mock("./net/http-connect-tunnel.js", () => ({ + openHttpConnectTunnel: tunnelSpy, +})); + +describe("connectApnsHttp2Session", () => { + it("uses direct http2.connect when no HTTPS proxy is configured", async () => { + const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); + + const session = await connectApnsHttp2Session({ + authority: "https://api.sandbox.push.apple.com", + timeoutMs: 10_000, + env: {}, + }); + + expect(session).toBe(fakeSession); + expect(tunnelSpy).not.toHaveBeenCalled(); + expect(connectSpy).toHaveBeenCalledWith("https://api.sandbox.push.apple.com"); + }); + + it("uses an HTTP CONNECT tunnel and disables direct fallback when HTTPS proxy is configured", async () => { + const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); + + const session = await connectApnsHttp2Session({ + authority: "https://api.push.apple.com", + timeoutMs: 10_000, + env: { HTTPS_PROXY: "http://proxy.example:8080" }, + }); + + expect(session).toBe(fakeSession); + expect(tunnelSpy).toHaveBeenCalledWith({ + proxyUrl: "http://proxy.example:8080", + targetHost: "api.push.apple.com", + targetPort: 443, + timeoutMs: 10_000, + }); + expect(tlsConnectSpy).toHaveBeenCalledWith({ + socket: { tunneled: true }, + servername: "api.push.apple.com", + ALPNProtocols: ["h2"], + }); + expect(connectSpy).toHaveBeenCalledWith("https://api.push.apple.com", { + createConnection: expect.any(Function), + }); + const connectCall = connectSpy.mock.calls.at(-1) as + | [string, http2.ClientSessionOptions] + | undefined; + const createConnection = connectCall?.[1].createConnection; + expect(createConnection?.(new URL("https://api.push.apple.com"), {})).toBe(fakeTlsSocket); + }); + + it("rejects non-APNs authorities", async () => { + const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); + + await expect( + connectApnsHttp2Session({ + authority: "https://example.com", + timeoutMs: 10_000, + env: { HTTPS_PROXY: "http://proxy.example:8080" }, + }), + ).rejects.toThrow("Unsupported APNs authority"); + }); +}); diff --git a/src/infra/push-apns-http2.ts b/src/infra/push-apns-http2.ts new file mode 100644 index 00000000000..671d26dd9f5 --- /dev/null +++ b/src/infra/push-apns-http2.ts @@ -0,0 +1,58 @@ +import http2 from "node:http2"; +import tls from "node:tls"; +import { openHttpConnectTunnel } from "./net/http-connect-tunnel.js"; +import { resolveEnvHttpProxyUrl } from "./net/proxy-env.js"; + +const APNS_AUTHORITIES = new Set([ + "https://api.push.apple.com", + "https://api.sandbox.push.apple.com", +]); + +type ApnsAuthority = "https://api.push.apple.com" | "https://api.sandbox.push.apple.com"; + +export type ConnectApnsHttp2SessionParams = { + authority: string; + timeoutMs: number; + env?: NodeJS.ProcessEnv; +}; + +function assertApnsAuthority(authority: string): ApnsAuthority { + let parsed: URL; + try { + parsed = new URL(authority); + } catch { + throw new Error(`Unsupported APNs authority: ${authority}`); + } + const normalized = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`; + if (!APNS_AUTHORITIES.has(normalized)) { + throw new Error(`Unsupported APNs authority: ${authority}`); + } + return normalized as ApnsAuthority; +} + +export async function connectApnsHttp2Session( + params: ConnectApnsHttp2SessionParams, +): Promise { + const authority = assertApnsAuthority(params.authority); + const proxyUrl = resolveEnvHttpProxyUrl("https", params.env); + if (!proxyUrl) { + return http2.connect(authority); + } + + const apnsHost = new URL(authority).hostname; + const tunnel = await openHttpConnectTunnel({ + proxyUrl, + targetHost: apnsHost, + targetPort: 443, + timeoutMs: params.timeoutMs, + }); + const tlsSocket = tls.connect({ + socket: tunnel, + servername: apnsHost, + ALPNProtocols: ["h2"], + }); + + return http2.connect(authority, { + createConnection: () => tlsSocket, + }); +} diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index 5c4508ae859..32086865b0b 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -10,6 +10,7 @@ import { import type { DeviceIdentity } from "./device-identity.js"; import { formatErrorMessage } from "./errors.js"; import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js"; +import { connectApnsHttp2Session } from "./push-apns-http2.js"; import { type ApnsRelayConfig, type ApnsRelayPushResponse, @@ -658,8 +659,12 @@ async function sendApnsRequest(params: { const body = JSON.stringify(params.payload); const requestPath = `/3/device/${params.token}`; + const client = await connectApnsHttp2Session({ + authority, + timeoutMs: params.timeoutMs, + }); + return await new Promise((resolve, reject) => { - const client = http2.connect(authority); let settled = false; const fail = (err: unknown) => { if (settled) {