diff --git a/src/canonicalize.ts b/src/canonicalize.ts index b45e4a7..b7da5f9 100644 --- a/src/canonicalize.ts +++ b/src/canonicalize.ts @@ -43,23 +43,31 @@ const SIGNED_FIELDS = [ /** * Recursively sort object keys for deterministic JSON. + * Uses Object.create(null) for the result object to avoid + * prototype-pollution style assignments being silently + * discarded (or hijacked) by Object.prototype setters. */ function sortKeys(obj: unknown): unknown { if (obj === null || typeof obj !== 'object') { return obj; } - + if (Array.isArray(obj)) { return obj.map(sortKeys); } - - const sorted: Record = {}; + + const sorted = Object.create(null) as Record; const keys = Object.keys(obj as Record).sort(); - + for (const key of keys) { - sorted[key] = sortKeys((obj as Record)[key]); + Object.defineProperty(sorted, key, { + value: sortKeys((obj as Record)[key]), + writable: true, + enumerable: true, + configurable: true, + }); } - + return sorted; } @@ -77,15 +85,24 @@ function serializeValue(value: unknown): unknown { * Extract and canonicalize receipt fields for signing. */ export function canonicalizeReceipt(receipt: Record): string { - const canonical: Record = {}; - + // Use a null-prototype object so that a malicious receipt cannot + // route `__proto__` (or any other inherited key) through + // Object.prototype setters and silently drop its signed value from + // the canonical bytes. + const canonical = Object.create(null) as Record; + for (const field of SIGNED_FIELDS) { const value = receipt[field]; if (value !== undefined && value !== null) { - canonical[field] = serializeValue(value); + Object.defineProperty(canonical, field, { + value: serializeValue(value), + writable: true, + enumerable: true, + configurable: true, + }); } } - + // Sort keys and stringify deterministically const sorted = sortKeys(canonical); return JSON.stringify(sorted); diff --git a/tests/verify.test.ts b/tests/verify.test.ts index 0fd26db..1341616 100644 --- a/tests/verify.test.ts +++ b/tests/verify.test.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { verifyReceipt } from '../src/verify.js'; +import { canonicalizeReceipt, canonicalizeReceiptBytes } from '../src/canonicalize.js'; async function loadJson(name: string): Promise { const path = resolve(process.cwd(), 'tests/fixtures', name); @@ -40,6 +41,35 @@ describe('verifyReceipt', () => { } }); + it('canonicalize preserves nested __proto__ keys inside signed fields', () => { + // Forge a requestJson whose value contains a nested __proto__ own + // key. After the receipt is signed, mutate that value and confirm + // the canonical signing bytes change. If the canonicalizer routed + // __proto__ through Object.prototype.__proto__ the value change + // would be silently dropped and the bytes would not change. + const inner: Record = {}; + Object.defineProperty(inner, '__proto__', { + value: { evil: 'first' }, + writable: true, + enumerable: true, + configurable: true, + }); + const base = { + id: 'rcpt_proto_001', + requestJson: inner, + } as unknown as Record; + const originalBytes = canonicalizeReceiptBytes(base); + + // Mutate the nested __proto__ value. With the old sortKeys + // implementation, this would either silently no-op or rewrite the + // prototype chain instead of changing the own key. With the fix + // the canonical bytes must change. + (base.requestJson as unknown as { __proto__: { evil: string } }).__proto__.evil = 'second'; + const mutatedBytes = canonicalizeReceiptBytes(base); + + expect(mutatedBytes.equals(originalBytes)).toBe(false); + }); + it('returns signature invalid for wrong key id pin', async () => { const receipt = await loadJson('wrong-key.json'); const result = await verifyReceipt(receipt, { keyFile, keyId: 'pp-test-2026-q2', noNetwork: true });