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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* test(apns): exercise malformed relay metadata

Co-authored-by: chenyangjun-xy <chen.yangjun@xydigit.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
chenyangjun-xy
2026-07-21 12:14:25 +08:00
committed by GitHub
parent f83f2ee935
commit 9dc86ed841
2 changed files with 30 additions and 1 deletions
+29
View File
@@ -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,
});
});
});
});
+1 -1
View File
@@ -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.