From cbd161045c5676d7e803316c693288f36bffa238 Mon Sep 17 00:00:00 2001 From: akuraposo Date: Thu, 4 Jun 2026 18:50:04 +0000 Subject: [PATCH] fix(verify): reject null required signed fields (closes #54) The reference verifier only checked required-field presence with `field in receipt`, while the canonicalizer omits any signed field whose value is `undefined` or `null`. A receipt with `requestJson: null` would satisfy the required-field check, then canonicalize the rest of the body without `requestJson`, and produce a `verified: true` result against a forged authorization scope that has no operation payload. This patch adds an explicit non-null check before the required-field loop, so any required signed field whose value is `null` or `undefined` produces `MALFORMED_RECEIPT` and exit code 3 instead of bypassing the missing-field gate. - Added `isNonEmptySignedField` helper in `src/verify.ts` - Required-field loop now uses the helper instead of `in` - Added regression test that forges `requestJson: null` on a valid receipt and expects `MALFORMED_RECEIPT` - All 6 tests pass - `npm run build` clean Closes #54 --- src/verify.ts | 10 +++++++++- tests/verify.test.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/verify.ts b/src/verify.ts index 9a3280c..18257b4 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -63,6 +63,14 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +function isNonEmptySignedField(receipt: Record, field: string): boolean { + const value = receipt[field]; + if (value === undefined || value === null) { + return false; + } + return true; +} + export async function verifyReceipt(receipt: unknown, options: VerifyOptions): Promise { if (!isObject(receipt)) { return { @@ -74,7 +82,7 @@ export async function verifyReceipt(receipt: unknown, options: VerifyOptions): P } for (const field of REQUIRED_SIGNED_FIELDS) { - if (!(field in receipt)) { + if (!isNonEmptySignedField(receipt, field)) { return { verified: false, exitCode: 3, diff --git a/tests/verify.test.ts b/tests/verify.test.ts index 0fd26db..cfd3d28 100644 --- a/tests/verify.test.ts +++ b/tests/verify.test.ts @@ -59,4 +59,20 @@ describe('verifyReceipt', () => { expect(result.errorCode).toBe('SIGNATURE_INVALID'); } }); + + it('returns malformed when a required signed field is null', async () => { + const receipt = await loadJson('valid.json') as Record; + // The canonicalizer omits null values from signing bytes, but the + // previous required-field check only tested `field in receipt`, + // so a key whose value is null satisfied presence and produced a + // verified result against a tampered requestJson scope. + receipt.requestJson = null; + const result = await verifyReceipt(receipt, { keyFile, noNetwork: true }); + expect(result.verified).toBe(false); + if (!result.verified) { + expect(result.exitCode).toBe(3); + expect(result.errorCode).toBe('MALFORMED_RECEIPT'); + expect(result.errorMessage.toLowerCase()).toContain('requestjson'); + } + }); });