fix(infra): harden APNs proxy tunnel

This commit is contained in:
jesse-merhi
2026-05-04 02:30:40 +10:00
committed by clawsweeper
parent b3e1342009
commit 6252e7b270
3 changed files with 164 additions and 14 deletions

View File

@@ -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);

View File

@@ -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<net.Socket> {
export async function openHttpConnectTunnel(
params: HttpConnectTunnelParams,
): Promise<tls.TLSSocket> {
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<net.Socket>((resolve, reject) => {
return await new Promise<tls.TLSSocket>((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"));
}

View File

@@ -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,
});