From 7a02ac5928f8a713cea0bf72344f72871329b237 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 4 May 2026 18:22:34 +1000 Subject: [PATCH] fix: tighten apns-id validation in proxy reachability check --- src/infra/net/proxy/proxy-validation.test.ts | 28 +++++++++++++++++++- src/infra/net/proxy/proxy-validation.ts | 14 +++++++++- src/infra/push-apns-http2.test.ts | 6 ++++- src/infra/push-apns-http2.ts | 10 ++++++- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/infra/net/proxy/proxy-validation.test.ts b/src/infra/net/proxy/proxy-validation.test.ts index 6732e91db25..52ec04c0a5a 100644 --- a/src/infra/net/proxy/proxy-validation.test.ts +++ b/src/infra/net/proxy/proxy-validation.test.ts @@ -423,7 +423,9 @@ describe("proxy validation", () => { it("adds an APNs reachability check when requested", async () => { const fetchCheck = vi.fn().mockResolvedValue({ ok: true, status: 200 }); - const apnsCheck = vi.fn().mockResolvedValue({ status: 403 }); + const apnsCheck = vi + .fn() + .mockResolvedValue({ status: 403, apnsId: "00000000-0000-0000-0000-000000000000" }); const result = await runProxyValidation({ config: { @@ -465,6 +467,30 @@ describe("proxy validation", () => { }); }); + it("fails APNs reachability when response has no apns-id (proxy intercept)", async () => { + const result = await runProxyValidation({ + config: { + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }, + env: {}, + allowedUrls: [], + deniedUrls: [], + apnsReachability: true, + apnsCheck: vi.fn().mockResolvedValue({ status: 200 }), + }); + + expect(result.ok).toBe(false); + expect(result.checks).toEqual([ + { + kind: "apns", + url: "https://api.sandbox.push.apple.com", + ok: false, + error: expect.stringContaining("apns-id"), + }, + ]); + }); + it("fails APNs reachability when the proxy blocks CONNECT", async () => { const result = await runProxyValidation({ config: { diff --git a/src/infra/net/proxy/proxy-validation.ts b/src/infra/net/proxy/proxy-validation.ts index 87d96e16e4f..39f43c0754c 100644 --- a/src/infra/net/proxy/proxy-validation.ts +++ b/src/infra/net/proxy/proxy-validation.ts @@ -60,6 +60,8 @@ export type ProxyValidationApnsCheckParams = { export type ProxyValidationApnsCheckResult = { status: number; + /** Present when the response originated from a real APNs server (Apple always returns this UUID). */ + apnsId?: string; }; export type ProxyValidationApnsCheck = ( @@ -201,7 +203,7 @@ async function defaultProxyValidationApnsCheck({ timeoutMs, }: ProxyValidationApnsCheckParams): Promise { const result = await probeApnsHttp2ReachabilityViaProxy({ proxyUrl, authority, timeoutMs }); - return { status: result.status }; + return { status: result.status, apnsId: result.responseHeaders?.["apns-id"] }; } function normalizeTimeoutMs(value: number | undefined): number { @@ -420,6 +422,16 @@ async function runApnsReachabilityCheck(params: { authority: params.authority, timeoutMs: params.timeoutMs, }); + if (!result.apnsId) { + return { + kind: "apns", + url: params.authority, + ok: false, + error: + "APNs reachability check failed: response did not include an apns-id header. " + + "The proxy may be intercepting the connection instead of tunneling it.", + }; + } return { kind: "apns", url: params.authority, diff --git a/src/infra/push-apns-http2.test.ts b/src/infra/push-apns-http2.test.ts index bd204e754b6..614b5e9eb80 100644 --- a/src/infra/push-apns-http2.test.ts +++ b/src/infra/push-apns-http2.test.ts @@ -185,7 +185,11 @@ describe("connectApnsHttp2Session", () => { timeoutMs: 10_000, }); - expect(result).toEqual({ status: 403, body: '{"reason":"InvalidProviderToken"}' }); + expect(result).toEqual({ + status: 403, + body: '{"reason":"InvalidProviderToken"}', + responseHeaders: {}, + }); const tunnelCall = tunnelSpy.mock.calls.at(-1)?.[0]; const proxyUrl = tunnelCall?.proxyUrl; expect(proxyUrl).toBeInstanceOf(URL); diff --git a/src/infra/push-apns-http2.ts b/src/infra/push-apns-http2.ts index c0c1b3d286c..773aadba019 100644 --- a/src/infra/push-apns-http2.ts +++ b/src/infra/push-apns-http2.ts @@ -30,6 +30,8 @@ export type ProbeApnsHttp2ReachabilityViaProxyParams = { export type ProbeApnsHttp2ReachabilityViaProxyResult = { status: number; body: string; + /** Raw response headers from APNs. Includes apns-id when the connection was truly tunneled to Apple. */ + responseHeaders: Record; }; function assertApnsAuthority(authority: string): ApnsAuthority { @@ -96,6 +98,7 @@ export async function probeApnsHttp2ReachabilityViaProxy( let settled = false; let body = ""; let status: number | undefined; + let responseHeaders: Record = {}; const timeout = setTimeout(() => { fail( new Error(`APNs reachability probe timed out after ${Math.trunc(params.timeoutMs)}ms`), @@ -132,6 +135,11 @@ export async function probeApnsHttp2ReachabilityViaProxy( request.on("response", (headers) => { const rawStatus = headers[":status"]; status = typeof rawStatus === "number" ? rawStatus : Number(rawStatus); + responseHeaders = Object.fromEntries( + Object.entries(headers) + .filter(([k]) => !k.startsWith(":")) + .map(([k, v]) => [k, String(v)]), + ); }); request.on("data", (chunk) => { body += String(chunk); @@ -147,7 +155,7 @@ export async function probeApnsHttp2ReachabilityViaProxy( reject(new Error("APNs reachability probe ended without an HTTP/2 status")); return; } - resolve({ status, body }); + resolve({ status, body, responseHeaders }); }); request.end(JSON.stringify({ aps: { alert: "OpenClaw APNs proxy validation" } })); });