fix: tighten apns-id validation in proxy reachability check

This commit is contained in:
jesse-merhi
2026-05-04 18:22:34 +10:00
committed by clawsweeper
parent 22fc256c3c
commit 7a02ac5928
4 changed files with 54 additions and 4 deletions

View File

@@ -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: {

View File

@@ -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<ProxyValidationApnsCheckResult> {
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,

View File

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

View File

@@ -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<string, string>;
};
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<string, string> = {};
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" } }));
});