From 9dc86ed84192073ac44dfe86245e2eb0edd812c3 Mon Sep 17 00:00:00 2001 From: chenyangjun-xy Date: Tue, 21 Jul 2026 12:14:25 +0800 Subject: [PATCH] fix: reject malformed UTF-8 in APNs relay response bodies (#111769) * fix: reject malformed UTF-8 in APNs relay response bodies Add { fatal: true } to TextDecoder in push-apns.relay.ts so invalid UTF-8 bytes throw a TypeError before JSON.parse, instead of silently becoming U+FFFD inside the parsed JSON payload. Co-Authored-By: Claude Opus 4.8 (1M context) * test(relay): add invalid UTF-8 relay body regression test Verify that a relay response containing a malformed UTF-8 byte is rejected at decode time and treated as absent, falling back to the HTTP status-derived result instead of leaking corrupted field values. Co-Authored-By: Claude Opus 4.8 (1M context) * test(apns): exercise malformed relay metadata Co-authored-by: chenyangjun-xy --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Peter Steinberger --- src/infra/push-apns.relay.test.ts | 29 +++++++++++++++++++++++++++++ src/infra/push-apns.relay.ts | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/infra/push-apns.relay.test.ts b/src/infra/push-apns.relay.test.ts index e69fe33cfe3c..a3a9025e760f 100644 --- a/src/infra/push-apns.relay.test.ts +++ b/src/infra/push-apns.relay.test.ts @@ -456,5 +456,34 @@ describe("push-apns.relay", () => { expect(result.reason).toBe("RelayResponseTooLarge"); expect(result.status).toBe(202); }); + + it("rejects relay body with malformed UTF-8 bytes instead of parsing corrupted metadata", async () => { + // Regression guard: with { fatal: true } on the TextDecoder, a relay body + // containing invalid UTF-8 sequences must be rejected at decode time and + // treated as absent (status-derived fallback). Corrupted field values must + // never reach the caller. + const encoder = new TextEncoder(); + const prefix = encoder.encode('{"ok":true,"status":200,"apnsId":"test-'); + const suffix = encoder.encode('1234"}'); + const body = new Uint8Array(prefix.length + 1 + suffix.length); + body.set(prefix, 0); + body[prefix.length] = 0xff; // bare invalid byte inside the apns-id string + body.set(suffix, prefix.length + 1); + + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(body, { status: 202, headers: { "content-type": "application/json" } }), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + await expect(sendApnsRelayPush(createRelayPushParams())).resolves.toEqual({ + ok: true, + status: 202, + apnsId: undefined, + reason: undefined, + tokenSuffix: undefined, + }); + }); }); }); diff --git a/src/infra/push-apns.relay.ts b/src/infra/push-apns.relay.ts index 1715e4448ac2..96c9d36580e0 100644 --- a/src/infra/push-apns.relay.ts +++ b/src/infra/push-apns.relay.ts @@ -309,7 +309,7 @@ async function sendApnsRelayRequest(params: { const buffer = await readResponseWithLimit(response, APNS_RELAY_MAX_RESPONSE_BYTES, { onOverflow: ({ size, maxBytes }) => new ApnsRelayResponseTooLargeError(size, maxBytes), }); - json = JSON.parse(new TextDecoder("utf-8").decode(buffer)) as unknown; + json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer)) as unknown; } catch (err) { if (err instanceof ApnsRelayResponseTooLargeError) { // Fail closed: an oversized relay body must never be reported as a delivered push.