Merge pull request #2316 from PR0M3TH3AN/ai/fuzz-nostr-targets-20260210-192630771952606835

Add fuzzing suite for Nostr parsing, DM decryption, and Magnet normalization
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-10 14:23:53 -05:00
commit 587bf2bde9
5 files changed
+624 -193

No files matched your search

+80 -75
View File
@@ -1,86 +1,91 @@
// Fuzz harness for js/dmDecryptor.js
import "./setup-test-env.js";
import { Fuzzer } from "./fuzz-lib.mjs";
import {
randomInt,
randomBoolean,
randomString,
randomHex,
randomArray,
randomValue,
randomObject,
runFuzzer,
} from "./fuzz-utils.mjs";
import { decryptDM } from "../../js/dmDecryptor.js";
const fuzzer = new Fuzzer("dmDecryptor");
function genEvent() {
const kind = Math.random() < 0.8 ? (Math.random() < 0.5 ? 4 : 1059) : randomInt(0, 20000);
const tags = randomArray(() => {
const tagName = Math.random() < 0.5 ? "p" : (Math.random() < 0.5 ? "encrypted" : randomString(5));
return [tagName, randomString(20), randomString(10)];
}, 0, 10);
class MockDecryptor {
constructor(scheme, shouldFail = false) {
this.scheme = scheme;
this.shouldFail = shouldFail;
this.supportsGiftWrap = scheme === "nip44" || scheme === "nip44_v2";
}
async decrypt(pubkey, ciphertext) {
if (this.shouldFail) {
throw new Error("Decryption failed (mock)");
}
// Return something that might be JSON or might be garbage
if (fuzzer.randBool()) {
// Return valid JSON event string (inner rumor)
return JSON.stringify({
kind: 1,
content: "Hello world " + fuzzer.randString(10),
pubkey: fuzzer.randString(64, "0123456789abcdef"),
created_at: Math.floor(Date.now() / 1000),
tags: []
});
} else {
// Return garbage string
return fuzzer.randString(50);
}
}
return {
kind,
pubkey: randomHex(64),
created_at: randomInt(0, 2000000000),
tags,
content: randomString(randomInt(0, 500)),
};
}
async function fuzzTest(fuzzer, state) {
const schemes = ["nip04", "nip44", "nip44_v2", "unknown", null];
function mockDecryptor(behavior) {
return {
scheme: Math.random() < 0.5 ? "nip44" : "nip04",
decrypt: async (pubkey, ciphertext) => {
if (behavior === "fail") {
throw new Error("Decryption failed");
}
if (behavior === "invalid") {
return 123; // Invalid return type
}
if (behavior === "empty") {
return "";
}
if (behavior === "garbage") {
return randomString(100); // Not JSON
}
// Success
const innerEvent = {
kind: randomInt(0, 20000),
pubkey: randomHex(64),
created_at: randomInt(0, 2000000000),
content: randomString(50),
tags: [],
};
return JSON.stringify(innerEvent);
},
supportsGiftWrap: Math.random() < 0.7,
};
}
// Create decryptors
function genDecryptors() {
const count = randomInt(0, 5);
const decryptors = [];
const numDecryptors = fuzzer.randInt(0, 3);
for(let i=0; i<numDecryptors; i++) {
const scheme = fuzzer.pick(schemes);
decryptors.push(new MockDecryptor(scheme, fuzzer.randBool()));
for (let i = 0; i < count; i++) {
const behaviors = ["success", "fail", "invalid", "empty", "garbage"];
const behavior = behaviors[randomInt(0, behaviors.length - 1)];
decryptors.push(mockDecryptor(behavior));
}
// Create event
// kinds: 4 (legacy), 1059 (gift wrap), random
const kinds = [4, 1059, fuzzer.randInt(0, 20000)];
const kind = fuzzer.pick(kinds);
const event = {
kind: kind,
pubkey: fuzzer.randString(64, "0123456789abcdef"),
content: fuzzer.randString(100), // ciphertext
created_at: Math.floor(Date.now() / 1000),
tags: []
};
// Add random tags
const numTags = fuzzer.randInt(0, 5);
for(let i=0; i<numTags; i++) {
event.tags.push(fuzzer.randArray(() => fuzzer.randString(10), 1, 4));
}
// Context
const context = {
actorPubkey: fuzzer.randString(64, "0123456789abcdef"),
decryptors: decryptors
};
state.input = {
event,
context
};
try {
await decryptDM(event, context);
} catch (err) {
// We expect decryptDM to handle errors gracefully and return { ok: false, errors: ... }
// If it throws, it's a bug (except maybe argument validation if we pass null event)
throw err;
// Sometimes add invalid decryptor objects
if (Math.random() < 0.2) {
decryptors.push(null);
decryptors.push({});
decryptors.push({ decrypt: "not a function" });
}
return decryptors;
}
fuzzer.runFuzzLoop(5000, fuzzTest).catch(console.error);
function genContext() {
return {
actorPubkey: randomHex(64),
decryptors: genDecryptors(),
};
}
function genArgs() {
return [genEvent(), genContext()];
}
async function main() {
await runFuzzer("decryptDM", decryptDM, genArgs);
}
main().catch(console.error);
+45 -40
View File
@@ -1,45 +1,50 @@
// Fuzz harness for js/magnetUtils.js
import "./setup-test-env.js";
import { Fuzzer } from "./fuzz-lib.mjs";
import { normalizeAndAugmentMagnet } from "../../js/magnetUtils.js";
import {
randomInt,
randomBoolean,
randomString,
randomHex,
randomArray,
randomValue,
randomObject,
runFuzzer,
} from "./fuzz-utils.mjs";
import {
normalizeAndAugmentMagnet,
safeDecodeMagnet,
extractBtihFromMagnet,
normalizeInfoHash,
} from "../../js/magnetUtils.js";
const fuzzer = new Fuzzer("magnetUtils");
function genMagnetString() {
if (Math.random() < 0.2) return randomString(100); // Garbage
if (Math.random() < 0.2) return ""; // Empty
async function fuzzTest(fuzzer, state) {
// Generate inputs
const rawValue = fuzzer.randBool() ? fuzzer.randString(100) : "magnet:?xt=urn:btih:" + fuzzer.randString(40, "0123456789abcdef");
// Inject garbage into magnet link
let malformedValue = rawValue;
if (fuzzer.randBool()) {
// url encode some chars
malformedValue = encodeURIComponent(rawValue);
}
if (fuzzer.randBool()) {
// Inject huge string
malformedValue += fuzzer.randString(10000);
}
const options = {
webSeed: fuzzer.randBool() ? fuzzer.randString(50) : [fuzzer.randString(50)],
torrentUrl: fuzzer.randString(50),
xs: fuzzer.randString(50),
extraTrackers: fuzzer.randArray(() => fuzzer.randString(30), 0, 3),
appProtocol: fuzzer.randBool() ? "http:" : "https:",
logger: () => {}
};
state.input = {
rawValue: malformedValue,
options
};
try {
normalizeAndAugmentMagnet(malformedValue, options);
} catch (err) {
throw err;
let magnet = "magnet:?";
const params = [];
const count = randomInt(0, 5);
for (let i = 0; i < count; i++) {
const key = Math.random() < 0.5 ? (Math.random() < 0.5 ? "xt" : "tr") : randomString(2);
const value = randomString(20, true);
params.push(`${key}=${encodeURIComponent(value)}`);
}
return magnet + params.join("&");
}
fuzzer.runFuzzLoop(5000, fuzzTest).catch(console.error);
function genOptions() {
return {
webSeed: Math.random() < 0.5 ? randomString(20) : [randomString(20), randomString(20)],
torrentUrl: Math.random() < 0.5 ? randomString(30) : undefined,
xs: Math.random() < 0.5 ? randomString(30) : undefined,
extraTrackers: Math.random() < 0.5 ? [randomString(30)] : undefined,
appProtocol: Math.random() < 0.5 ? "http:" : "https:",
};
}
async function main() {
await runFuzzer("safeDecodeMagnet", safeDecodeMagnet, () => [genMagnetString()]);
await runFuzzer("extractBtihFromMagnet", extractBtihFromMagnet, () => [genMagnetString()]);
await runFuzzer("normalizeInfoHash", normalizeInfoHash, () => [randomString(40)]);
await runFuzzer("normalizeAndAugmentMagnet", normalizeAndAugmentMagnet, () => [genMagnetString(), genOptions()]);
}
main().catch(console.error);
+282 -78
View File
@@ -1,81 +1,285 @@
// Fuzz harness for js/nostrEventSchemas.js
import "./setup-test-env.js";
import { Fuzzer } from "./fuzz-lib.mjs";
import * as schemas from "../../js/nostrEventSchemas.js";
import {
randomInt,
randomBoolean,
randomString,
randomHex,
randomArray,
randomValue,
randomObject,
runFuzzer,
} from "./fuzz-utils.mjs";
import * as Schemas from "../../js/nostrEventSchemas.js";
const fuzzer = new Fuzzer("nostrEventSchemas");
// Mappers for build functions
const BUILDERS = Object.keys(schemas)
.filter(k => k.startsWith('build'))
.map(k => ({ name: k, fn: schemas[k] }));
async function fuzzTest(fuzzer, state) {
// 1. Fuzz Builder Functions
const builder = fuzzer.pick(BUILDERS);
// Generate random params
const params = fuzzer.randJSON();
// Inject huge strings occasionally
if (Math.random() < 0.05) {
if (!params || typeof params !== 'object') {
// make it an object
} else {
// add huge string field
params.hugeField = "A".repeat(1000000); // 1MB
}
}
state.input = {
target: builder.name,
params: params
};
try {
let args = [params];
if (builder.name === 'buildAdminListEvent') {
const listKeys = ["moderation", "editors", "whitelist", "blacklist", "invalid", null];
args = [fuzzer.pick(listKeys), params];
}
const event = builder.fn(...args);
// 2. Validate the generated event
if (event && event.kind) {
// Find schema type for this kind to validate
let type = null;
for (const [key, val] of Object.entries(schemas.NOTE_TYPES)) {
const schema = schemas.getNostrEventSchema(val);
if (schema && schema.kind === event.kind) {
type = val;
break;
}
}
if (type) {
schemas.validateEventAgainstSchema(type, event);
}
}
} catch (err) {
throw err;
}
// 3. Fuzz Validation directly with random events
if (fuzzer.randBool()) {
const types = Object.values(schemas.NOTE_TYPES);
const type = fuzzer.pick(types);
const randomEvent = fuzzer.randJSON();
state.input = {
target: "validateEventStructure",
type: type,
event: randomEvent
};
schemas.validateEventStructure(type, randomEvent);
}
function genTag() {
return randomArray(() => randomString(randomInt(0, 20), true), 0, 5);
}
// Run for 5000 iterations
fuzzer.runFuzzLoop(5000, fuzzTest).catch(console.error);
function genAdditionalTags() {
const choice = randomInt(0, 3);
if (choice === 0) return [null]; // Invalid input
if (choice === 1) return [randomString(100)]; // Invalid input
return [randomArray(genTag, 0, 10)];
}
function genEvent() {
return {
kind: randomInt(0, 50000),
pubkey: randomHex(64),
created_at: randomInt(0, 2000000000),
tags: randomArray(genTag, 0, 20),
content: randomString(randomInt(0, 1000), true),
};
}
function genValidateEventStructureArgs() {
const type = randomArray(() => randomString(10), 1)[0]; // Random type
// Also pick a valid type sometimes
const validTypes = Object.values(Schemas.NOTE_TYPES);
const selectedType =
Math.random() < 0.5
? validTypes[randomInt(0, validTypes.length - 1)]
: type;
const event = Math.random() < 0.1 ? null : genEvent();
return [selectedType, event];
}
function genParams(extraKeys = []) {
const base = randomObject(2, 2);
const commonKeys = [
"pubkey",
"created_at",
"additionalTags",
"content",
"tags",
];
const keys = [...commonKeys, ...extraKeys];
keys.forEach((key) => {
if (Math.random() < 0.7) {
if (key === "additionalTags") {
base[key] = randomArray(genTag, 0, 5);
} else if (key === "created_at") {
base[key] = randomInt(0, 2000000000);
} else if (key === "pubkey") {
base[key] = randomHex(64);
} else {
base[key] = randomValue(0, 2);
}
}
});
return [base];
}
async function main() {
await runFuzzer(
"sanitizeAdditionalTags",
Schemas.sanitizeAdditionalTags,
genAdditionalTags
);
await runFuzzer(
"validateEventStructure",
Schemas.validateEventStructure,
genValidateEventStructureArgs
);
// Build functions
const buildFunctions = [
{
name: "buildVideoPostEvent",
fn: Schemas.buildVideoPostEvent,
keys: ["dTagValue", "infoHash", "url", "videoRootId"],
},
{
name: "buildHttpAuthEvent",
fn: Schemas.buildHttpAuthEvent,
keys: ["url", "method", "payload"],
},
{
name: "buildReportEvent",
fn: Schemas.buildReportEvent,
keys: ["eventId", "userId", "reportType", "relayHint"],
},
{
name: "buildGiftWrapEvent",
fn: Schemas.buildGiftWrapEvent,
keys: ["recipientPubkey", "ciphertext", "relayHint"],
},
{
name: "buildSealEvent",
fn: Schemas.buildSealEvent,
keys: ["ciphertext"],
},
{
name: "buildChatMessageEvent",
fn: Schemas.buildChatMessageEvent,
keys: ["recipientPubkey"],
},
{
name: "buildVideoMirrorEvent",
fn: Schemas.buildVideoMirrorEvent,
keys: [],
},
{
name: "buildRepostEvent",
fn: Schemas.buildRepostEvent,
keys: [
"eventId",
"eventRelay",
"address",
"addressRelay",
"authorPubkey",
"repostKind",
"targetKind",
"targetEvent",
"serializedEvent",
],
},
{
name: "buildShareEvent",
fn: Schemas.buildShareEvent,
keys: ["video", "relays"],
},
{
name: "buildRelayListEvent",
fn: Schemas.buildRelayListEvent,
keys: ["relays"],
},
{
name: "buildDmRelayListEvent",
fn: Schemas.buildDmRelayListEvent,
keys: ["relays"],
},
{
name: "buildProfileMetadataEvent",
fn: Schemas.buildProfileMetadataEvent,
keys: ["metadata"],
},
{
name: "buildMuteListEvent",
fn: Schemas.buildMuteListEvent,
keys: ["pTags", "encrypted", "encryptionTag"],
},
{
name: "buildDeletionEvent",
fn: Schemas.buildDeletionEvent,
keys: ["eventIds", "addresses", "reason"],
},
{
name: "buildLegacyDirectMessageEvent",
fn: Schemas.buildLegacyDirectMessageEvent,
keys: ["recipientPubkey", "ciphertext"],
},
{
name: "buildDmAttachmentEvent",
fn: Schemas.buildDmAttachmentEvent,
keys: ["recipientPubkey", "attachment"],
},
{
name: "buildDmReadReceiptEvent",
fn: Schemas.buildDmReadReceiptEvent,
keys: ["recipientPubkey", "eventId", "messageKind"],
},
{
name: "buildDmTypingIndicatorEvent",
fn: Schemas.buildDmTypingIndicatorEvent,
keys: ["recipientPubkey", "eventId", "expiresAt"],
},
{
name: "buildViewEvent",
fn: Schemas.buildViewEvent,
keys: [
"pointerValue",
"pointerTag",
"pointerTags",
"dedupeTag",
"includeSessionTag",
],
},
{
name: "buildZapRequestEvent",
fn: Schemas.buildZapRequestEvent,
keys: [
"recipientPubkey",
"relays",
"amountSats",
"lnurl",
"eventId",
"coordinate",
],
},
{
name: "buildReactionEvent",
fn: Schemas.buildReactionEvent,
keys: [
"pointerValue",
"pointerTag",
"pointerTags",
"targetPointer",
"targetAuthorPubkey",
],
},
{
name: "buildCommentEvent",
fn: Schemas.buildCommentEvent,
keys: [
"videoEventId",
"videoEventRelay",
"videoDefinitionAddress",
"videoDefinitionRelay",
"rootIdentifier",
"rootIdentifierRelay",
"parentCommentId",
"parentCommentRelay",
"threadParticipantPubkey",
"threadParticipantRelay",
"parentIdentifier",
"parentIdentifierRelay",
"rootKind",
"rootAuthorPubkey",
"rootAuthorRelay",
"parentAuthorPubkey",
"parentAuthorRelay",
"parentKind",
],
},
{
name: "buildWatchHistoryEvent",
fn: Schemas.buildWatchHistoryEvent,
keys: ["monthIdentifier", "pointerTags"],
},
{
name: "buildSubscriptionListEvent",
fn: Schemas.buildSubscriptionListEvent,
keys: ["encryption"],
},
{
name: "buildBlockListEvent",
fn: Schemas.buildBlockListEvent,
keys: ["encryption"],
},
{
name: "buildHashtagPreferenceEvent",
fn: Schemas.buildHashtagPreferenceEvent,
keys: [],
},
];
for (const { name, fn, keys } of buildFunctions) {
await runFuzzer(name, fn, () => genParams(keys));
}
// buildAdminListEvent needs special handling for 2 args
await runFuzzer(
"buildAdminListEvent",
Schemas.buildAdminListEvent,
() => {
const listKey = Math.random() < 0.5 ? randomString(10) : (Math.random() < 0.5 ? "moderation" : "blacklist");
const params = genParams(["hexPubkeys"])[0];
return [listKey, params];
}
);
}
main().catch(console.error);
+140
View File
@@ -0,0 +1,140 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ARTIFACTS_DIR = path.resolve(__dirname, "../../artifacts");
const REPRODUCERS_DIR = path.resolve(__dirname, "../../examples/reproducers");
if (!fs.existsSync(ARTIFACTS_DIR)) {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
}
if (!fs.existsSync(REPRODUCERS_DIR)) {
fs.mkdirSync(REPRODUCERS_DIR, { recursive: true });
}
export function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export function randomBoolean() {
return Math.random() < 0.5;
}
export function randomString(length = 10, includeUnicode = false) {
let result = "";
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const unicodeChars = "😀😎🌍🚀中文测试ñçüéåß∂ƒ©˙∆˚¬…æΩ≈ç√∫˜µ≤≥÷";
const controlChars = "\x00\x01\x02\n\r\t\b\f\v";
// Use Array.from to correctly handle surrogate pairs
const pool = Array.from(
characters +
(includeUnicode ? unicodeChars : "") +
(includeUnicode ? controlChars : "")
);
for (let i = 0; i < length; i++) {
result += pool[Math.floor(Math.random() * pool.length)];
}
return result;
}
export function randomHex(length = 64) {
let result = "";
const characters = "0123456789abcdef";
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
export function randomArray(generator, minLength = 0, maxLength = 10) {
const length = randomInt(minLength, maxLength);
const result = [];
for (let i = 0; i < length; i++) {
result.push(generator());
}
return result;
}
export function randomValue(depth = 0, maxDepth = 3) {
if (depth > maxDepth) {
return randomString(5);
}
const type = randomInt(0, 5);
switch (type) {
case 0:
return randomInt(-1000, 1000);
case 1:
return randomString(randomInt(0, 100), true);
case 2:
return randomBoolean();
case 3:
return null;
case 4:
return undefined;
case 5:
return randomObject(depth + 1, maxDepth);
default:
return null;
}
}
export function randomObject(depth = 0, maxDepth = 3) {
if (depth > maxDepth) {
return {};
}
const numKeys = randomInt(0, 5);
const obj = {};
for (let i = 0; i < numKeys; i++) {
const key = randomString(5);
obj[key] = randomValue(depth + 1, maxDepth);
}
return obj;
}
export async function runFuzzer(targetName, fn, generator, iterations = 1000) {
const reportPath = path.join(ARTIFACTS_DIR, `fuzz-report-${targetName}.json`);
const failures = [];
console.log(`Starting fuzzer for ${targetName} with ${iterations} iterations...`);
for (let i = 0; i < iterations; i++) {
const inputs = generator();
try {
const result = await fn(...inputs);
// Optional: Check if result is valid if we have a way to know
} catch (error) {
console.error(`Crash in ${targetName} at iteration ${i}:`, error.message);
const failure = {
iteration: i,
inputs,
error: {
message: error.message,
stack: error.stack,
name: error.name
},
};
failures.push(failure);
// Save reproducer
const reproducerPath = path.join(
REPRODUCERS_DIR,
targetName,
`crash-${i}.json`
);
if (!fs.existsSync(path.dirname(reproducerPath))) {
fs.mkdirSync(path.dirname(reproducerPath), { recursive: true });
}
fs.writeFileSync(reproducerPath, JSON.stringify(inputs, null, 2));
}
}
fs.writeFileSync(reportPath, JSON.stringify(failures, null, 2));
console.log(`Fuzzer finished. Found ${failures.length} failures. Report saved to ${reportPath}`);
return failures;
}
+77
View File
@@ -0,0 +1,77 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ARTIFACTS_DIR = path.resolve(__dirname, "../../artifacts");
const TARGETS = {
nostrEventSchemas: [
"sanitizeAdditionalTags",
"validateEventStructure",
"buildVideoPostEvent",
"buildHttpAuthEvent",
"buildReportEvent",
"buildGiftWrapEvent",
"buildSealEvent",
"buildChatMessageEvent",
"buildVideoMirrorEvent",
"buildRepostEvent",
"buildShareEvent",
"buildRelayListEvent",
"buildDmRelayListEvent",
"buildProfileMetadataEvent",
"buildMuteListEvent",
"buildDeletionEvent",
"buildLegacyDirectMessageEvent",
"buildDmAttachmentEvent",
"buildDmReadReceiptEvent",
"buildDmTypingIndicatorEvent",
"buildViewEvent",
"buildZapRequestEvent",
"buildReactionEvent",
"buildCommentEvent",
"buildWatchHistoryEvent",
"buildSubscriptionListEvent",
"buildBlockListEvent",
"buildHashtagPreferenceEvent",
"buildAdminListEvent",
],
dmDecryptor: ["decryptDM"],
magnetUtils: [
"safeDecodeMagnet",
"extractBtihFromMagnet",
"normalizeInfoHash",
"normalizeAndAugmentMagnet",
],
};
function main() {
for (const [target, functions] of Object.entries(TARGETS)) {
const summary = {
target,
totalFailures: 0,
details: {},
};
for (const fn of functions) {
const reportFile = path.join(ARTIFACTS_DIR, `fuzz-report-${fn}.json`);
if (fs.existsSync(reportFile)) {
try {
const data = JSON.parse(fs.readFileSync(reportFile, "utf8"));
summary.details[fn] = data;
summary.totalFailures += data.length;
} catch (e) {
console.error(`Failed to read report for ${fn}:`, e);
}
}
}
const summaryPath = path.join(ARTIFACTS_DIR, `fuzz-report-${target}.json`);
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
console.log(`Generated summary for ${target} at ${summaryPath}`);
}
}
main();