morphit/apps/indexer/test/handlers/fuzz.test.ts

203 lines
7.7 KiB
TypeScript

/**
* Property-based payload fuzz harness for every op handler (cp426 audit,
* recommendation #2).
*
* A hostile actor can broadcast a custom_json with ANY `json` payload. Each
* handler receives it as `unknown` and must narrow it defensively. The
* per-handler unit tests cover known-shape rejections; this harness instead
* throws THOUSANDS of adversarial payloads at every handler — primitives, huge
* strings, deeply nested objects, prototype-pollution keys (__proto__,
* constructor, prototype), wrong-typed fields, and near-miss valid shapes — and
* asserts the crash-safety invariants that keep one bad op from wedging a block:
*
* 1. TERMINATES — no hang / catastrophic backtracking (bounded wall time).
* 2. VALID RESULT SHAPE — returns {ok:true} or {ok:false, reason:string},
* OR throws an Error (which the dispatcher catches per-op). Never returns
* some other shape, never throws a non-Error.
* 3. NO PROTOTYPE POLLUTION — a payload carrying __proto__/constructor keys
* never mutates Object.prototype.
*
* The DB client is mocked to return empty rows, so most inputs are rejected at
* the narrowing stage before any query — exactly the path a hostile op hits.
*/
import { describe, it, expect } from 'vitest';
import { makeCtx } from '../testutils/context';
import { makeMockClient } from '../testutils/mockClient';
import order from '$indexer/handlers/order';
import orderReplace from '$indexer/handlers/orderReplace';
import orderCancel from '$indexer/handlers/orderCancel';
import feedback from '$indexer/handlers/feedback';
import feedbackResponse from '$indexer/handlers/feedbackResponse';
import chat from '$indexer/handlers/chat';
import chatIdentity from '$indexer/handlers/chatIdentity';
import chatRead from '$indexer/handlers/chatRead';
import profile from '$indexer/handlers/profile';
import feeAttest from '$indexer/handlers/feeAttest';
import strangerFee from '$indexer/handlers/strangerFee';
import operatorBlock from '$indexer/handlers/operatorBlock';
import operatorPaymentMethod from '$indexer/handlers/operatorPaymentMethod';
import operatorRegister from '$indexer/handlers/operatorRegister';
import release from '$indexer/handlers/release';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Handler = (ctx: any, client: any) => Promise<unknown>;
const HANDLERS: Readonly<Record<string, Handler>> = {
order,
orderReplace,
orderCancel,
feedback,
feedbackResponse,
chat,
chatIdentity,
chatRead,
profile,
feeAttest,
strangerFee,
operatorBlock,
operatorPaymentMethod,
operatorRegister,
release
};
// ─── Deterministic PRNG ────────────────────────────────────────────
function makeRng(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Field names the handlers actually read — so near-miss payloads exercise the
// per-field type checks, not just the top-level object guard.
const KNOWN_FIELDS = [
'subject', 'rating', 'comment', 'recipient', 'permlink', 'order_permlink',
'order_account', 'ciphertext', 'chat_pub', 'blocked', 'reason', 'version',
'action', 'key', 'name', 'description', 'tag', 'side', 'asset', 'amount_min',
'amount_max', 'fiat_currency', 'payment_methods', 'terms', 'accepted_assets',
'fee_method', 'network', 'address', 'treasury', 'quoted_blurt', 'v', 'txid'
];
const PRIMITIVES: readonly unknown[] = [
null, undefined, true, false, 0, -1, 1, 3.14, NaN, Infinity, -Infinity,
Number.MAX_SAFE_INTEGER, '', 'x', 'a'.repeat(100_000), '../../etc/passwd',
'\u0000\u0001\u202e', '😀🔥', '{"nested":"json"}', ' ', '5' + 'A'.repeat(50)
];
function randomValue(rng: () => number, depth: number): unknown {
if (depth > 4 || rng() < 0.5) {
return PRIMITIVES[Math.floor(rng() * PRIMITIVES.length)];
}
const r = rng();
if (r < 0.4) {
// array
const n = Math.floor(rng() * 6);
return Array.from({ length: n }, () => randomValue(rng, depth + 1));
}
// object — sometimes seed a known field, sometimes a pollution key
const obj: Record<string, unknown> = {};
const n = 1 + Math.floor(rng() * 5);
for (let i = 0; i < n; i++) {
let k: string;
const kr = rng();
if (kr < 0.15) k = ['__proto__', 'constructor', 'prototype'][Math.floor(rng() * 3)]!;
else if (kr < 0.7) k = KNOWN_FIELDS[Math.floor(rng() * KNOWN_FIELDS.length)]!;
else k = 'k' + Math.floor(rng() * 1000);
obj[k] = randomValue(rng, depth + 1);
}
return obj;
}
function isValidResultShape(r: unknown): boolean {
if (typeof r !== 'object' || r === null) return false;
const o = r as Record<string, unknown>;
if (o.ok === true) return true;
if (o.ok === false) return typeof o.reason === 'string' && o.reason.length > 0;
return false;
}
async function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, rej) => {
timer = setTimeout(() => rej(new Error(`HANG: ${label} exceeded ${ms}ms`)), ms);
});
try {
return await Promise.race([p, timeout]);
} finally {
clearTimeout(timer!);
}
}
describe('handler payload fuzz — crash-safety invariants', () => {
for (const [name, handler] of Object.entries(HANDLERS)) {
it(`${name}: survives 400 adversarial payloads (terminate / valid-shape / no-pollution / catchable)`, async () => {
const rng = makeRng(0xc0ffee ^ name.length ^ (name.charCodeAt(0) << 8));
const ITER = 400;
const protoKeysBefore = Object.keys(Object.prototype).length;
const badShapes: { payload: unknown; result: unknown }[] = [];
const badThrows: { payload: unknown; err: unknown }[] = [];
let ok = 0;
let rejected = 0;
let threw = 0;
for (let i = 0; i < ITER; i++) {
const payload = randomValue(rng, 0);
const ctx = makeCtx({ signer: 'alice', payload });
const client = makeMockClient().client;
try {
const result = await withTimeout(
Promise.resolve(handler(ctx, client)),
1500,
`${name}#${i}`
);
if (!isValidResultShape(result)) {
badShapes.push({ payload, result });
} else if ((result as { ok: boolean }).ok) {
ok++;
} else {
rejected++;
}
} catch (err) {
// A throw is contract-acceptable (the dispatcher catches it per-op
// with a SAVEPOINT rollback) — BUT it must be an Error instance, and
// it must NOT be our HANG sentinel.
threw++;
if (!(err instanceof Error) || /^HANG:/.test((err as Error).message)) {
badThrows.push({ payload, err });
}
}
}
// Invariant 3 — no prototype pollution from __proto__/constructor keys.
expect(Object.keys(Object.prototype).length).toBe(protoKeysBefore);
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
// Invariant 2 — every result was a valid shape or a catchable throw.
if (badShapes.length > 0) {
const b = badShapes[0]!;
throw new Error(
`${name} returned an INVALID result shape for ${badShapes.length}/${ITER} payloads. ` +
`First: payload=${JSON.stringify(b.payload)?.slice(0, 200)} result=${JSON.stringify(b.result)?.slice(0, 200)}`
);
}
if (badThrows.length > 0) {
const b = badThrows[0]!;
throw new Error(
`${name} threw a non-Error or HUNG for ${badThrows.length}/${ITER} payloads. ` +
`First: payload=${JSON.stringify(b.payload)?.slice(0, 200)} err=${String(b.err)}`
);
}
// Sanity — the fuzz actually reached the handlers (not all no-ops).
expect(ok + rejected + threw).toBe(ITER);
// Random payloads are overwhelmingly invalid, so rejections must dominate.
expect(rejected).toBeGreaterThan(0);
});
}
});