diff --git a/examples/reproducers/crash-schemas-buildAdminListEvent-848e5ba6.json b/examples/reproducers/crash-schemas-buildAdminListEvent-848e5ba6.json new file mode 100644 index 00000000..cb560c60 --- /dev/null +++ b/examples/reproducers/crash-schemas-buildAdminListEvent-848e5ba6.json @@ -0,0 +1,6 @@ +{ + "function": "buildAdminListEvent", + "input": "{\"listKey\":{},\"address\":\"Ꟁ뤷世㙭硇ఴ冸\\uddc2\\ud9ac謢௼\\udd5f஝逎맗\\ud97dᢈ哏⟃\\udb4d佢\",\"pubkey\":\"__undefined__\",\"rootKind\":\":c+p/*=7^=GB_j>Hhh]56YW&ZF$28%u/J5d0_\",\"eventId\":63930,\"includeSessionTag\":76813,\"pointerValue\":\"\"}", + "error": "Error: toString throw", + "stack": "Error: toString throw\n at Object.toString (file:///app/scripts/agent/fuzz-schemas.mjs:83:31)\n at Module.buildAdminListEvent (file:///app/js/nostrEventSchemas.js:2296:54)\n at fn (file:///app/scripts/agent/fuzz-schemas.mjs:152:64)\n at run (file:///app/scripts/agent/fuzz-schemas.mjs:167:32)\n at file:///app/scripts/agent/fuzz-schemas.mjs:216:1" +} \ No newline at end of file diff --git a/examples/reproducers/crash-schemas-buildAdminListEvent-b1eed32e.json b/examples/reproducers/crash-schemas-buildAdminListEvent-b1eed32e.json new file mode 100644 index 00000000..f727726e --- /dev/null +++ b/examples/reproducers/crash-schemas-buildAdminListEvent-b1eed32e.json @@ -0,0 +1,6 @@ +{ + "function": "buildAdminListEvent", + "input": "{\"repostKind\":true,\"listKey\":{},\"content\":false,\"q?F#3\":true}", + "error": "Error: toString throw", + "stack": "Error: toString throw\n at Object.toString (file:///app/scripts/agent/fuzz-schemas.mjs:83:31)\n at Module.buildAdminListEvent (file:///app/js/nostrEventSchemas.js:2296:54)\n at fn (file:///app/scripts/agent/fuzz-schemas.mjs:152:64)\n at run (file:///app/scripts/agent/fuzz-schemas.mjs:167:32)\n at file:///app/scripts/agent/fuzz-schemas.mjs:216:1" +} \ No newline at end of file diff --git a/examples/reproducers/crash-schemas-buildAdminListEvent-ead98a99.json b/examples/reproducers/crash-schemas-buildAdminListEvent-ead98a99.json new file mode 100644 index 00000000..012977e7 --- /dev/null +++ b/examples/reproducers/crash-schemas-buildAdminListEvent-ead98a99.json @@ -0,0 +1,6 @@ +{ + "function": "buildAdminListEvent", + "input": "{\"threadParticipantPubkey\":\"=\\\"-ge5\",\"includeSessionTag\":\"C2@IUQ8DFi3\",\"address\":\"\\udab6ꇷ뻼웖\\ude2d\\udf2c䙹轂岀ꊀ魯㣝»ヒ趃㩎떡\\ude0d얭⿴灎쳽幔箆⋙Ё⥩덙\",\"ciphertext\":{\"bQyO]\":null,\"6\":false},\"listKey\":{},\"Xx)&~\":\"䍖ᔩ㒪暸╝鋶簇ᯢ⽷\\udd40察畂캌\\udc5d൒\\uda52魾웁尢󳰘穁鶽ߒ\\uda6c諉┯쭈蟡㿏ࢬ\\udcdf\\udea9㺖\"}", + "error": "Error: toString throw", + "stack": "Error: toString throw\n at Object.toString (file:///app/scripts/agent/fuzz-schemas.mjs:83:31)\n at Module.buildAdminListEvent (file:///app/js/nostrEventSchemas.js:2296:54)\n at fn (file:///app/scripts/agent/fuzz-schemas.mjs:152:64)\n at run (file:///app/scripts/agent/fuzz-schemas.mjs:167:32)\n at file:///app/scripts/agent/fuzz-schemas.mjs:216:1" +} \ No newline at end of file diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index c6c3834e..fce3fcdd 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -663,6 +663,9 @@ export function setNostrEventSchemaOverrides(overrides = {}) { } export function getNostrEventSchema(type) { + if (typeof type !== "string") { + return null; + } const base = BASE_SCHEMAS[type]; if (!base) { return null; @@ -2293,7 +2296,7 @@ export function buildAdminListEvent(listKey, params) { const schema = getNostrEventSchema(resolveAdminNoteType(listKey)); if (!schema) { devLogger.warn( - `[nostrEventSchemas] Unknown admin list key: ${listKey}`, + `[nostrEventSchemas] Unknown admin list key: ${safeStringify(listKey)}`, ); return { kind: 30000, diff --git a/scripts/agent/fuzz-dm.mjs b/scripts/agent/fuzz-dm.mjs new file mode 100644 index 00000000..033bfab7 --- /dev/null +++ b/scripts/agent/fuzz-dm.mjs @@ -0,0 +1,153 @@ +import './fuzz-setup.mjs'; +import * as dm from '../../js/dmDecryptor.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +const REPRODUCER_DIR = 'examples/reproducers'; +const REPORT_FILE = 'artifacts/fuzz-report-dm.json'; + +const ITERATIONS = 5000; + +// Verify export +if (typeof dm.decryptDM !== 'function') { + console.error("Error: decryptDM is not exported from js/dmDecryptor.js"); + process.exit(1); +} + +// Random generators +function getRandomInt(max) { + return Math.floor(Math.random() * max); +} + +function getRandomString(length = 100) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +function getRandomHex(length = 64) { + const chars = '0123456789abcdef'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +function getRandomTags() { + const numTags = getRandomInt(5); + const tags = []; + for(let i=0; i 0.5 ? 'p' : (Math.random() > 0.5 ? 'encrypted' : getRandomString(5))); + for(let j=1; j 0.8 ? getRandomHex(64) : getRandomString(20)); + } + tags.push(tag); + } + // inject malformed tags + if (Math.random() < 0.1) tags.push("not-an-array"); + if (Math.random() < 0.1) tags.push([]); + return tags; +} + +async function run() { + console.log(`Starting fuzzing DM decryptor for ${ITERATIONS} iterations...`); + + const report = { + target: 'js/dmDecryptor.js', + timestamp: new Date().toISOString(), + iterations: ITERATIONS, + crashes: [], + }; + + const uniqueCrashes = new Set(); + + for (let i = 0; i < ITERATIONS; i++) { + const event = { + kind: Math.random() > 0.5 ? 4 : 1059, + pubkey: getRandomHex(64), + created_at: Math.floor(Date.now() / 1000), + tags: getRandomTags(), + content: getRandomString(50), + }; + + // Malform event + if (Math.random() < 0.1) event.kind = "string"; + if (Math.random() < 0.1) event.tags = null; + if (Math.random() < 0.1) event.content = null; + + const context = { + actorPubkey: getRandomHex(64), + decryptors: [ + { + scheme: 'nip44', + decrypt: async (pk, ciphertext) => { + if (Math.random() < 0.1) throw new Error("Decrypt failed"); + if (Math.random() < 0.1) return null; + return "decrypted-content"; + }, + priority: 1 + }, + { + scheme: 'nip04', + decrypt: async () => "legacy-decrypted", + priority: 0 + } + ] + }; + + // Malform context + if (Math.random() < 0.1) context.decryptors = null; + if (Math.random() < 0.1) context.decryptors = [null, {}]; + + try { + await dm.decryptDM(event, context); + } catch (err) { + const crashKey = `decryptDM:${err.message}`; + if (uniqueCrashes.has(crashKey)) { + continue; + } + uniqueCrashes.add(crashKey); + + let safeInput = "Could not stringify input"; + try { + safeInput = JSON.stringify({ event, context: { ...context, decryptors: 'mocked' } }); + } catch (e) { + safeInput = "[Input causes JSON.stringify to throw]"; + } + + const crashInfo = { + function: 'decryptDM', + input: safeInput, + error: err.toString(), + stack: err.stack, + }; + + const hash = crypto.createHash('sha256').update(JSON.stringify(crashInfo)).digest('hex').slice(0, 8); + const filename = `crash-dm-${hash}.json`; + fs.writeFileSync(path.join(REPRODUCER_DIR, filename), JSON.stringify(crashInfo, null, 2)); + + report.crashes.push({ + file: filename, + function: 'decryptDM', + error: err.message + }); + + console.error(`[CRASH] decryptDM failed! Saved to ${filename}`); + } + } + + if (!fs.existsSync(path.dirname(REPORT_FILE))) { + fs.mkdirSync(path.dirname(REPORT_FILE), { recursive: true }); + } + fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2)); + console.log(`Fuzzing complete. Report saved to ${REPORT_FILE}. Crashes: ${report.crashes.length}`); +} + +run(); diff --git a/scripts/agent/fuzz-magnet.mjs b/scripts/agent/fuzz-magnet.mjs new file mode 100644 index 00000000..86b8d25a --- /dev/null +++ b/scripts/agent/fuzz-magnet.mjs @@ -0,0 +1,120 @@ +import './fuzz-setup.mjs'; +import * as magnetUtils from '../../js/magnetUtils.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +const REPRODUCER_DIR = 'examples/reproducers'; +const REPORT_FILE = 'artifacts/fuzz-report-magnet.json'; + +const ITERATIONS = 5000; + +function getRandomString(length = 100) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+{}|:"<>?~`-=[]\;\',./% '; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +function getRandomMagnet() { + const hash = getRandomString(40).replace(/[^0-9a-f]/gi, 'a'); + return `magnet:?xt=urn:btih:${hash}&dn=${getRandomString(10)}&tr=${encodeURIComponent(getRandomString(20))}`; +} + +async function run() { + console.log(`Starting fuzzing magnet utils for ${ITERATIONS} iterations...`); + + const report = { + target: 'js/magnetUtils.js', + timestamp: new Date().toISOString(), + iterations: ITERATIONS, + crashes: [], + }; + + const uniqueCrashes = new Set(); + + const targets = [ + { name: 'normalizeAndAugmentMagnet', fn: magnetUtils.normalizeAndAugmentMagnet, args: 2 }, + { name: 'safeDecodeMagnet', fn: magnetUtils.safeDecodeMagnet, args: 1 }, + ]; + + for (const { name, fn, args } of targets) { + for (let i = 0; i < ITERATIONS; i++) { + let input, options; + + if (Math.random() < 0.3) { + input = getRandomMagnet(); + } else if (Math.random() < 0.3) { + input = getRandomString(getRandomInt(200)); + } else { + input = null; // or undefined, or object + if (Math.random() < 0.5) input = {}; + } + + if (args > 1) { + options = { + webSeed: Math.random() < 0.5 ? getRandomString(20) : [getRandomString(20)], + torrentUrl: getRandomString(20), + extraTrackers: [getRandomString(20)], + appProtocol: 'https:', + }; + if (Math.random() < 0.1) options = null; + if (Math.random() < 0.1) options = "string"; + } + + try { + if (args === 1) { + fn(input); + } else { + fn(input, options); + } + } catch (err) { + const crashKey = `${name}:${err.message}`; + if (uniqueCrashes.has(crashKey)) { + continue; + } + uniqueCrashes.add(crashKey); + + let safeInput = "Could not stringify input"; + try { + safeInput = JSON.stringify(args === 1 ? input : { input, options }); + } catch (e) { + safeInput = "[Input causes JSON.stringify to throw]"; + } + + const crashInfo = { + function: name, + input: safeInput, + error: err.toString(), + stack: err.stack, + }; + + const hash = crypto.createHash('sha256').update(JSON.stringify(crashInfo)).digest('hex').slice(0, 8); + const filename = `crash-magnet-${name}-${hash}.json`; + fs.writeFileSync(path.join(REPRODUCER_DIR, filename), JSON.stringify(crashInfo, null, 2)); + + report.crashes.push({ + file: filename, + function: name, + error: err.message + }); + + console.error(`[CRASH] ${name} failed! Saved to ${filename}`); + } + } + } + + if (!fs.existsSync(path.dirname(REPORT_FILE))) { + fs.mkdirSync(path.dirname(REPORT_FILE), { recursive: true }); + } + fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2)); + console.log(`Fuzzing complete. Report saved to ${REPORT_FILE}. Crashes: ${report.crashes.length}`); +} + +function getRandomInt(max) { + return Math.floor(Math.random() * max); +} + +run(); diff --git a/scripts/agent/fuzz-schemas.mjs b/scripts/agent/fuzz-schemas.mjs new file mode 100644 index 00000000..8a07bc4b --- /dev/null +++ b/scripts/agent/fuzz-schemas.mjs @@ -0,0 +1,217 @@ +import './fuzz-setup.mjs'; +import * as schemas from '../../js/nostrEventSchemas.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +const REPRODUCER_DIR = 'examples/reproducers'; +const REPORT_FILE = 'artifacts/fuzz-report-schemas.json'; + +const ITERATIONS = 5000; + +// Fuzzing inputs generator +function getRandomInt(max) { + return Math.floor(Math.random() * max); +} + +function getRandomString(length = 100) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+{}|:"<>?~`-=[]\;\',./'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +function getRandomUnicodeString(length = 100) { + let result = ''; + // Include surrogates and other edge cases + for (let i = 0; i < length; i++) { + if (Math.random() < 0.1) { + // Surrogate pair halves + result += String.fromCharCode(0xD800 + Math.random() * 0x800); + } else { + result += String.fromCharCode(Math.random() * 0xFFFF); + } + } + return result; +} + +function getRandomValue(depth = 0) { + if (depth > 3) return null; + const type = Math.random(); + if (type < 0.1) return null; + if (type < 0.2) return undefined; + if (type < 0.3) return getRandomInt(100000); + if (type < 0.5) return getRandomString(getRandomInt(50)); + if (type < 0.6) return getRandomUnicodeString(getRandomInt(50)); + if (type < 0.7) return true; + if (type < 0.8) return false; + if (type < 0.9) { + const arr = []; + const len = getRandomInt(5); + for(let i=0; i { throw new Error('toString throw'); } }, + { toJSON: () => { throw new Error('toJSON throw'); } }, + new Array(1000).fill('a').join(''), // large string + "javascript:alert(1)", + "__proto__", + "constructor", +]; + +function generateFuzzInput(paramsSchema) { + // If we knew the schema of params, we could be smarter. + // For now, generate a random object with random keys, plus keys that likely exist. + const input = {}; + const commonKeys = [ + 'pubkey', 'created_at', 'content', 'tags', 'additionalTags', + 'id', 'kind', 'dTagValue', 'eventId', 'eventRelay', 'address', + 'addressRelay', 'authorPubkey', 'repostKind', 'targetKind', + 'targetEvent', 'serializedEvent', 'video', 'relays', 'metadata', + 'pTags', 'eventIds', 'addresses', 'reason', 'recipientPubkey', + 'ciphertext', 'attachment', 'messageKind', 'expiresAt', + 'pointerValue', 'pointerTag', 'pointerTags', 'dedupeTag', + 'includeSessionTag', 'amountSats', 'lnurl', 'coordinate', + 'targetPointer', 'targetAuthorPubkey', 'videoEventId', + 'videoEventRelay', 'videoDefinitionAddress', 'rootIdentifier', + 'parentCommentId', 'threadParticipantPubkey', 'rootKind', + 'monthIdentifier', 'encryption', 'hexPubkeys', 'listKey' + ]; + + // Pick some common keys and assign random or interesting values + const numKeys = getRandomInt(10); + for (let i = 0; i < numKeys; i++) { + const key = commonKeys[getRandomInt(commonKeys.length)]; + const useInteresting = Math.random() < 0.2; + input[key] = useInteresting ? interestingValues[getRandomInt(interestingValues.length)] : getRandomValue(); + } + + // Also add completely random keys + const numRandomKeys = getRandomInt(3); + for (let i = 0; i < numRandomKeys; i++) { + input[getRandomString(5)] = getRandomValue(); + } + + return input; +} + +async function run() { + console.log(`Starting fuzzing schemas for ${ITERATIONS} iterations...`); + + const functionsToFuzz = [ + { name: 'buildVideoPostEvent', fn: schemas.buildVideoPostEvent }, + { name: 'buildVideoMirrorEvent', fn: schemas.buildVideoMirrorEvent }, + { name: 'buildRepostEvent', fn: schemas.buildRepostEvent }, + { name: 'buildShareEvent', fn: schemas.buildShareEvent }, + { name: 'buildRelayListEvent', fn: schemas.buildRelayListEvent }, + { name: 'buildDmRelayListEvent', fn: schemas.buildDmRelayListEvent }, + { name: 'buildProfileMetadataEvent', fn: schemas.buildProfileMetadataEvent }, + { name: 'buildMuteListEvent', fn: schemas.buildMuteListEvent }, + { name: 'buildDeletionEvent', fn: schemas.buildDeletionEvent }, + { name: 'buildLegacyDirectMessageEvent', fn: schemas.buildLegacyDirectMessageEvent }, + { name: 'buildDmAttachmentEvent', fn: schemas.buildDmAttachmentEvent }, + { name: 'buildDmReadReceiptEvent', fn: schemas.buildDmReadReceiptEvent }, + { name: 'buildDmTypingIndicatorEvent', fn: schemas.buildDmTypingIndicatorEvent }, + { name: 'buildViewEvent', fn: schemas.buildViewEvent }, + { name: 'buildZapRequestEvent', fn: schemas.buildZapRequestEvent }, + { name: 'buildReactionEvent', fn: schemas.buildReactionEvent }, + { name: 'buildCommentEvent', fn: schemas.buildCommentEvent }, + { name: 'buildWatchHistoryEvent', fn: schemas.buildWatchHistoryEvent }, + { name: 'buildSubscriptionListEvent', fn: schemas.buildSubscriptionListEvent }, + { name: 'buildBlockListEvent', fn: schemas.buildBlockListEvent }, + { name: 'buildHashtagPreferenceEvent', fn: schemas.buildHashtagPreferenceEvent }, + { name: 'buildAdminListEvent', fn: (params) => schemas.buildAdminListEvent(params.listKey, params) }, // Wrapper since it takes 2 args + ]; + + const report = { + target: 'js/nostrEventSchemas.js', + timestamp: new Date().toISOString(), + iterations: ITERATIONS, + crashes: [], + }; + + const uniqueCrashes = new Set(); + + for (const { name, fn } of functionsToFuzz) { + for (let i = 0; i < ITERATIONS; i++) { + const input = generateFuzzInput(); + try { + const result = fn(input); + // Optional: validate output structure + // if (result) { + // schemas.validateEventAgainstSchema(result.kind, result); + // } + } catch (err) { + const crashKey = `${name}:${err.message}`; + if (uniqueCrashes.has(crashKey)) { + continue; + } + uniqueCrashes.add(crashKey); + + let safeInput = "Could not stringify input"; + try { + safeInput = JSON.stringify(input, (k, v) => v === undefined ? '__undefined__' : v); + } catch (e) { + safeInput = "[Input causes JSON.stringify to throw]"; + } + + const crashInfo = { + function: name, + input: safeInput, + error: err.toString(), + stack: err.stack, + }; + + // Save reproducer + const hash = crypto.createHash('sha256').update(JSON.stringify(crashInfo)).digest('hex').slice(0, 8); + const filename = `crash-schemas-${name}-${hash}.json`; + fs.writeFileSync(path.join(REPRODUCER_DIR, filename), JSON.stringify(crashInfo, null, 2)); + + report.crashes.push({ + file: filename, + function: name, + error: err.message + }); + + console.error(`[CRASH] ${name} failed! Saved to ${filename}`); + } + } + } + + if (!fs.existsSync(path.dirname(REPORT_FILE))) { + fs.mkdirSync(path.dirname(REPORT_FILE), { recursive: true }); + } + fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2)); + console.log(`Fuzzing complete. Report saved to ${REPORT_FILE}. Crashes: ${report.crashes.length}`); +} + +run(); diff --git a/scripts/agent/fuzz-setup.mjs b/scripts/agent/fuzz-setup.mjs new file mode 100644 index 00000000..17cb7525 --- /dev/null +++ b/scripts/agent/fuzz-setup.mjs @@ -0,0 +1,55 @@ +import crypto from 'node:crypto'; + +// Mock localStorage +const localStorageStore = new Map(); +const localStorageMock = { + getItem: (key) => localStorageStore.get(String(key)) ?? null, + setItem: (key, value) => localStorageStore.set(String(key), String(value)), + removeItem: (key) => localStorageStore.delete(String(key)), + clear: () => localStorageStore.clear(), + key: (index) => Array.from(localStorageStore.keys())[index] ?? null, + get length() { + return localStorageStore.size; + }, +}; + +// Mock window +const windowMock = { + localStorage: localStorageMock, + crypto: { + getRandomValues: (buffer) => crypto.getRandomValues(buffer), + subtle: crypto.webcrypto.subtle, + }, + location: { + protocol: 'https:', + hostname: 'bitvid.network', + href: 'https://bitvid.network/', + }, +}; + +// Polyfill globals +if (typeof globalThis.window === 'undefined') { + globalThis.window = windowMock; +} + +if (typeof globalThis.localStorage === 'undefined') { + globalThis.localStorage = localStorageMock; +} + +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = windowMock.crypto; +} + +if (typeof globalThis.WebSocket === 'undefined') { + globalThis.WebSocket = class WebSocket { + constructor(url) { + this.url = url; + this.readyState = 0; + } + send() {} + close() {} + }; +} + +// Mock bitvid-specific globals if needed (though config.js sets them on window) +globalThis.bitvidNostrEventOverrides = {};