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'); + } + }); });