mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:00:42 +00:00
fix(infra): harden APNs proxy tunnel
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user