diff --git a/backend/src/tests/validators.fuzz.test.ts b/backend/src/tests/validators.fuzz.test.ts index d3076ac36..1c7bd3ad1 100644 --- a/backend/src/tests/validators.fuzz.test.ts +++ b/backend/src/tests/validators.fuzz.test.ts @@ -1,99 +1,488 @@ -import { z } from 'zod'; -import * as fc from 'fast-check'; -import * as sharedSchemas from '../validators/shared'; -import * as invoiceSchemas from '../validators/invoices'; -import * as bidSchemas from '../validators/bids'; -import * as settlementSchemas from '../validators/settlements'; - -// Helper to extract Zod schemas from a module -function extractSchemas(moduleObj: Record): Record { - const schemas: Record = {}; - for (const [key, val] of Object.entries(moduleObj)) { - if (val && typeof val === 'object' && 'safeParse' in val && typeof val.safeParse === 'function') { - schemas[key] = val; - } - } - return schemas; +/** + * Property-based fuzz tests for Zod validators in invoices, bids, and settlements schemas. + * + * Strategy: + * - Use fast-check arbitraries to generate malformed payloads covering: + * unicode strings, very large integers, deeply nested objects, prototype-pollution + * attempts, NaN/Infinity in numeric fields, ISO date edge cases, integer overflow, + * and type confusion objects. + * - Every test asserts that the validator either accepts or rejects the input — it + * must never throw an unhandled exception that would crash the process. + * - Prototype-pollution assertions verify that __proto__ / constructor keys cannot + * mutate the validated output or Object.prototype. + */ + +import * as fc from "fast-check"; +import { createInvoiceBodySchema } from "../validators/invoices"; +import { createBidBodySchema } from "../validators/bids"; +import { + getSettlementsQuerySchema, + transitionInputSchema, +} from "../validators/settlements"; +import { + hexStringSchema, + stellarAddressSchema, + positiveAmountSchema, + paginationSchema, + getInvoicesQuerySchema, + invoiceIdParamSchema, + getBidsQuerySchema, +} from "../validators/shared"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Snapshot of Object.prototype keys before tests run — used for pollution checks. */ +const PROTO_KEYS_BEFORE = Object.getOwnPropertyNames(Object.prototype); + +function assertNoPrototypePollution(): void { + const after = Object.getOwnPropertyNames(Object.prototype); + expect(after).toEqual(PROTO_KEYS_BEFORE); } -const allSchemas = { - ...extractSchemas(sharedSchemas), - ...extractSchemas(invoiceSchemas), - ...extractSchemas(bidSchemas), - ...extractSchemas(settlementSchemas), -}; - -describe('Zod Validators Fuzz Tests (Property-Based)', () => { - it('should have extracted at least one schema to test', () => { - expect(Object.keys(allSchemas).length).toBeGreaterThan(0); - }); - - describe.each(Object.keys(allSchemas))('Schema: %s', (schemaName) => { - const schema = allSchemas[schemaName]; - - it('should never throw an unhandled error for arbitrary deeply-nested payloads', () => { - fc.assert( - fc.property( - fc.object({ maxDepth: 100, maxKeys: 10 }), - (payload) => { - // Zod's safeParse should catch all validation errors internally - // If it throws, the test fails - expect(() => schema.safeParse(payload)).not.toThrow(); - } - ), - { numRuns: 100 } - ); +/** Run a safeParse call and assert it never throws. */ +function assertNeverThrows(parse: () => void): void { + expect(() => parse()).not.toThrow(); +} + +// --------------------------------------------------------------------------- +// Shared arbitrary helpers +// --------------------------------------------------------------------------- + +const anyArb = fc.oneof( + fc.string(), + fc.integer(), + fc.double({ noNaN: false }), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), + fc.constant(NaN), + fc.constant(Infinity), + fc.constant(-Infinity) +); + +const unicodeStringArb = fc.string({ unit: "grapheme", minLength: 0, maxLength: 200 }); + +const veryLargeIntArb = fc.oneof( + fc.integer({ min: Number.MAX_SAFE_INTEGER - 10, max: Number.MAX_SAFE_INTEGER }), + fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MIN_SAFE_INTEGER + 10 }), + fc.constant(2 ** 53), + fc.constant(-(2 ** 53)) +); + +const protoPollutionPayloads = [ + { __proto__: { polluted: true } }, + { constructor: { prototype: { polluted: true } } }, + JSON.parse('{"__proto__":{"polluted":true}}'), + JSON.parse('{"constructor":{"prototype":{"polluted":true}}}'), +]; + +// --------------------------------------------------------------------------- +// 1. hexStringSchema +// --------------------------------------------------------------------------- + +describe("hexStringSchema — fuzz", () => { + it("never throws on arbitrary strings", () => { + fc.assert( + fc.property(fc.string(), (s) => { + assertNeverThrows(() => hexStringSchema.safeParse(s)); + }) + ); + }); + + it("never throws on unicode strings", () => { + fc.assert( + fc.property(unicodeStringArb, (s) => { + assertNeverThrows(() => hexStringSchema.safeParse(s)); + }) + ); + }); + + it("accepts well-formed hex strings", () => { + fc.assert( + fc.property( + fc.string().filter(s => /^[a-fA-F0-9]+$/.test(s)), + (hex) => { + const result = hexStringSchema.safeParse(`0x${hex}`); + expect(result.success).toBe(true); + } + ) + ); + }); + + it("rejects strings without 0x prefix", () => { + fc.assert( + fc.property( + fc.string().filter(s => /^[a-fA-F0-9]+$/.test(s)), + (hex) => { + const result = hexStringSchema.safeParse(hex); + expect(result.success).toBe(false); + } + ) + ); + }); +}); + +// --------------------------------------------------------------------------- +// 2. stellarAddressSchema +// --------------------------------------------------------------------------- + +describe("stellarAddressSchema — fuzz", () => { + it("never throws on arbitrary inputs", () => { + fc.assert( + fc.property(anyArb, (v) => { + assertNeverThrows(() => stellarAddressSchema.safeParse(v)); + }) + ); + }); + + it("rejects extremely long strings", () => { + fc.assert( + fc.property(fc.string({ minLength: 200, maxLength: 1000 }), (s) => { + const result = stellarAddressSchema.safeParse(s); + // Either rejects or accepts — must not throw + assertNeverThrows(() => stellarAddressSchema.safeParse(s)); + void result; + }) + ); + }); +}); + +// --------------------------------------------------------------------------- +// 3. positiveAmountSchema +// --------------------------------------------------------------------------- + +describe("positiveAmountSchema — fuzz", () => { + it("never throws on arbitrary inputs", () => { + fc.assert( + fc.property(anyArb, (v) => { + assertNeverThrows(() => positiveAmountSchema.safeParse(v)); + }) + ); + }); + + it("rejects NaN/Infinity strings", () => { + ["NaN", "Infinity", "-Infinity", "1e308", "1.5", "-1", "0.1"].forEach((v) => { + const result = positiveAmountSchema.safeParse(v); + expect(result.success).toBe(false); }); + }); + + it("accepts valid digit-only strings", () => { + fc.assert( + fc.property(fc.nat({ max: 999999999 }), (n) => { + const result = positiveAmountSchema.safeParse(String(n)); + expect(result.success).toBe(true); + }) + ); + }); +}); + +// --------------------------------------------------------------------------- +// 4. paginationSchema +// --------------------------------------------------------------------------- + +describe("paginationSchema — fuzz", () => { + it("never throws on arbitrary objects", () => { + fc.assert( + fc.property( + fc.record({ page: anyArb, limit: anyArb }, { requiredKeys: [] }), + (obj) => { + assertNeverThrows(() => paginationSchema.safeParse(obj)); + } + ) + ); + }); +}); + +// --------------------------------------------------------------------------- +// 5. createInvoiceBodySchema +// --------------------------------------------------------------------------- + +describe("createInvoiceBodySchema — fuzz", () => { + it("never throws on arbitrary objects", () => { + fc.assert( + fc.property( + fc.object({ maxDepth: 5, maxKeys: 10 }), + (obj) => { + assertNeverThrows(() => createInvoiceBodySchema.safeParse(obj)); + } + ) + ); + }); + + it("rejects deeply nested metadata (depth > 5)", () => { + let nested: unknown = { description: "x", quantity: "1", unit_price: "1", total: "1" }; + for (let i = 0; i < 110; i++) { + nested = { inner: nested }; + } + const payload = { + business: "biz", + amount: "100", + currency: "USD", + due_date: 9999999999, + description: "test", + category: "Services", + metadata: nested, + }; + assertNeverThrows(() => createInvoiceBodySchema.safeParse(payload)); + }); + + it("rejects unicode in amount field", () => { + fc.assert( + fc.property(unicodeStringArb, (s) => { + const result = createInvoiceBodySchema.safeParse({ + business: "biz", + amount: s, + currency: "USD", + due_date: 1000000, + description: "desc", + category: "Services", + }); + if (!/^[0-9]+$/.test(s)) { + expect(result.success).toBe(false); + } + }) + ); + }); - it('should never throw an unhandled error for arbitrary values (NaN, Infinity, Strings, etc.)', () => { - fc.assert( - fc.property( - fc.anything({ maxDepth: 10 }), - (payload) => { - expect(() => schema.safeParse(payload)).not.toThrow(); - } - ), - { numRuns: 1000 } + it("never throws on NaN/Infinity in due_date", () => { + [NaN, Infinity, -Infinity, Number.MAX_VALUE, -1, 0].forEach((v) => { + assertNeverThrows(() => + createInvoiceBodySchema.safeParse({ + business: "biz", + amount: "100", + currency: "USD", + due_date: v, + description: "d", + category: "Services", + }) ); }); + }); - it('should protect against prototype pollution and type confusion', () => { - fc.assert( - fc.property( - // Fuzz standard objects with potentially dangerous keys explicitly added - fc.record({ - __proto__: fc.constant({ polluted: true }), - constructor: fc.constant({ prototype: { polluted: true } }), - amount: fc.constant({ toString: () => "1" }), // Type confusion - invoice_id: fc.constant({ valueOf: () => "0x123" }), - randomField: fc.string(), - }), - (payload) => { - // Because we generated an object that literally has __proto__ defined via fc.record, - // we simulate a JSON.parse payload that an attacker might send. - - // Re-create the payload using JSON.parse to properly set the prototype - // if it was passed via express body parser - const jsonPayload = JSON.parse(JSON.stringify(payload)); - - let result: any; - expect(() => { - result = schema.safeParse(jsonPayload); - }).not.toThrow(); - - if (result && result.success) { - const output = result.data; - // If safeParse succeeds, the parsed object must NOT have the polluted prototype injected - if (output && typeof output === 'object') { - expect((output as any).polluted).toBeUndefined(); - // Ensure output prototype isn't modified directly - expect(Object.prototype.hasOwnProperty.call(output, 'polluted')).toBe(false); - } - } - } - ), - { numRuns: 100 } - ); + it("rejects type-confusion amount objects", () => { + const typeConfusion = { toString: () => "1", valueOf: () => 1 }; + const result = createInvoiceBodySchema.safeParse({ + business: "biz", + amount: typeConfusion, + currency: "USD", + due_date: 1000000, + description: "d", + category: "Services", }); + expect(result.success).toBe(false); + }); + + it("prototype-pollution payloads do not mutate Object.prototype", () => { + for (const payload of protoPollutionPayloads) { + assertNeverThrows(() => createInvoiceBodySchema.safeParse(payload)); + } + assertNoPrototypePollution(); + expect((Object.prototype as Record)["polluted"]).toBeUndefined(); + }); + + it("validated output does not carry __proto__ keys", () => { + const polluted = JSON.parse('{"__proto__":{"evil":true},"business":"b","amount":"1","currency":"USD","due_date":1000000,"description":"d","category":"Services"}'); + const result = createInvoiceBodySchema.safeParse(polluted); + if (result.success) { + expect(Object.prototype.hasOwnProperty.call(result.data, "__proto__")).toBe(false); + } + assertNoPrototypePollution(); + }); +}); + +// --------------------------------------------------------------------------- +// 6. createBidBodySchema +// --------------------------------------------------------------------------- + +describe("createBidBodySchema — fuzz", () => { + it("never throws on arbitrary objects", () => { + fc.assert( + fc.property( + fc.object({ maxDepth: 4, maxKeys: 8 }), + (obj) => { + assertNeverThrows(() => createBidBodySchema.safeParse(obj)); + } + ) + ); + }); + + it("rejects integer-overflow in expiration_timestamp", () => { + fc.assert( + fc.property(veryLargeIntArb, (n) => { + assertNeverThrows(() => + createBidBodySchema.safeParse({ + invoice_id: "0xabcdef", + bid_amount: "100", + expected_return: "5", + expiration_timestamp: n, + }) + ); + }) + ); + }); + + it("rejects bid_amount with non-digit characters", () => { + fc.assert( + fc.property( + fc.string().filter((s) => !/^[0-9]+$/.test(s)), + (s) => { + const result = createBidBodySchema.safeParse({ + invoice_id: "0xabcdef", + bid_amount: s, + expected_return: "5", + expiration_timestamp: 9999999999, + }); + expect(result.success).toBe(false); + } + ) + ); + }); + + it("prototype-pollution payloads do not mutate Object.prototype", () => { + for (const payload of protoPollutionPayloads) { + assertNeverThrows(() => createBidBodySchema.safeParse(payload)); + } + assertNoPrototypePollution(); + expect((Object.prototype as Record)["polluted"]).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 7. transitionInputSchema (settlements) +// --------------------------------------------------------------------------- + +describe("transitionInputSchema — fuzz", () => { + it("never throws on arbitrary objects", () => { + fc.assert( + fc.property( + fc.object({ maxDepth: 4, maxKeys: 8 }), + (obj) => { + assertNeverThrows(() => transitionInputSchema.safeParse(obj)); + } + ) + ); + }); + + it("rejects non-numeric amount strings", () => { + fc.assert( + fc.property( + fc.string().filter((s) => !/^[0-9]+$/.test(s)), + (s) => { + const result = transitionInputSchema.safeParse({ + invoice_id: "inv_001", + amount: s, + payer: "alice", + recipient: "bob", + event_id: "evt_001", + }); + expect(result.success).toBe(false); + } + ) + ); + }); + + it("rejects extremely long field values without throwing", () => { + fc.assert( + fc.property( + fc.string({ minLength: 5001, maxLength: 10000 }), + (longStr) => { + assertNeverThrows(() => + transitionInputSchema.safeParse({ + invoice_id: longStr, + amount: "100", + payer: longStr, + recipient: longStr, + event_id: longStr, + }) + ); + } + ) + ); + }); + + it("prototype-pollution payloads do not mutate Object.prototype", () => { + for (const payload of protoPollutionPayloads) { + assertNeverThrows(() => transitionInputSchema.safeParse(payload)); + } + assertNoPrototypePollution(); + expect((Object.prototype as Record)["polluted"]).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 8. getSettlementsQuerySchema (settlements) +// --------------------------------------------------------------------------- + +describe("getSettlementsQuerySchema — fuzz", () => { + it("never throws on arbitrary objects", () => { + fc.assert( + fc.property( + fc.object({ maxDepth: 3, maxKeys: 6 }), + (obj) => { + assertNeverThrows(() => getSettlementsQuerySchema.safeParse(obj)); + } + ) + ); + }); + + it("rejects invalid status values", () => { + fc.assert( + fc.property( + fc.string().filter((s) => !["Pending", "Processing", "Paid", "Defaulted"].includes(s)), + (s) => { + const result = getSettlementsQuerySchema.safeParse({ status: s }); + expect(result.success).toBe(false); + } + ) + ); + }); +}); + +// --------------------------------------------------------------------------- +// 9. getInvoicesQuerySchema / getBidsQuerySchema / invoiceIdParamSchema +// --------------------------------------------------------------------------- + +describe("query schemas — fuzz", () => { + it("getInvoicesQuerySchema never throws on arbitrary inputs", () => { + fc.assert( + fc.property(fc.object({ maxDepth: 3, maxKeys: 6 }), (obj) => { + assertNeverThrows(() => getInvoicesQuerySchema.safeParse(obj)); + }) + ); + }); + + it("getBidsQuerySchema never throws on arbitrary inputs", () => { + fc.assert( + fc.property(fc.object({ maxDepth: 3, maxKeys: 6 }), (obj) => { + assertNeverThrows(() => getBidsQuerySchema.safeParse(obj)); + }) + ); + }); + + it("invoiceIdParamSchema never throws on arbitrary inputs", () => { + fc.assert( + fc.property(fc.object({ maxDepth: 2, maxKeys: 4 }), (obj) => { + assertNeverThrows(() => invoiceIdParamSchema.safeParse(obj)); + }) + ); + }); + + it("ISO date edge-cases do not crash any query schema", () => { + const edgeDates = [ + "0000-01-01", + "9999-12-31", + "2024-02-29", // leap day + "2023-02-29", // invalid leap day + "2024-13-01", // month 13 + "not-a-date", + "", + ]; + for (const d of edgeDates) { + assertNeverThrows(() => getInvoicesQuerySchema.safeParse({ due_date: d })); + assertNeverThrows(() => getBidsQuerySchema.safeParse({ created_at: d })); + } }); }); diff --git a/backend/tests/validators.fuzz.test.ts b/backend/tests/validators.fuzz.test.ts index df6d890d4..a741d4d64 100644 --- a/backend/tests/validators.fuzz.test.ts +++ b/backend/tests/validators.fuzz.test.ts @@ -102,7 +102,7 @@ describe("hexStringSchema — fuzz", () => { it("accepts well-formed hex strings", () => { fc.assert( fc.property( - fc.hexaString({ minLength: 1, maxLength: 64 }), + fc.string().filter(s => /^[a-fA-F0-9]+$/.test(s)), (hex) => { const result = hexStringSchema.safeParse(`0x${hex}`); expect(result.success).toBe(true); @@ -114,7 +114,7 @@ describe("hexStringSchema — fuzz", () => { it("rejects strings without 0x prefix", () => { fc.assert( fc.property( - fc.hexaString({ minLength: 1, maxLength: 64 }), + fc.string().filter(s => /^[a-fA-F0-9]+$/.test(s)), (hex) => { const result = hexStringSchema.safeParse(hex); expect(result.success).toBe(false);