From 9e969e81203cffc1a2e5313954daa14bb2642d1c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 12 Nov 2025 09:28:04 -0500 Subject: [PATCH] Sanitize mute tags before publishing block list --- js/userBlocks.js | 153 ++++++++++++++++++++++++++--------- tests/user-blocks.test.mjs | 162 +++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 36 deletions(-) diff --git a/js/userBlocks.js b/js/userBlocks.js index 029dbb5e..329cfe2c 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -159,10 +159,44 @@ function determineDecryptionOrder(event, availableSchemes) { return prioritized.length ? prioritized : available; } -function serializeBlockListTagMatrix(values, ownerPubkey) { - const tags = []; - const seen = new Set(); +function describeDiscardedEntry(entry) { + if (typeof entry === "string") { + return entry.trim(); + } + + if (entry === null) { + return "null"; + } + + if (typeof entry === "undefined") { + return "undefined"; + } + + if (Array.isArray(entry)) { + try { + return JSON.stringify(entry); + } catch (_error) { + return String(entry); + } + } + + if (typeof entry === "object") { + try { + return JSON.stringify(entry); + } catch (_error) { + return String(entry); + } + } + + return String(entry); +} + +function sanitizeMuteTags(values, ownerPubkey, options = {}) { + const logDiscarded = options?.logDiscarded !== false; const owner = normalizeHex(ownerPubkey); + const sanitized = []; + const seen = new Set(); + const iterable = Array.isArray(values) ? values : values instanceof Set @@ -171,15 +205,84 @@ function serializeBlockListTagMatrix(values, ownerPubkey) { ? Array.from(values) : []; - for (const candidate of iterable) { - const normalized = normalizeHex(candidate); - if (!normalized || normalized === owner || seen.has(normalized)) { + const logDiscard = (entry, reason, extra) => { + if (!logDiscarded) { + return; + } + const detail = describeDiscardedEntry(entry); + if (extra) { + userLogger.warn( + `[UserBlockList] Discarded block entry (${reason}).`, + detail, + extra, + ); + return; + } + userLogger.warn(`[UserBlockList] Discarded block entry (${reason}).`, detail); + }; + + for (const entry of iterable) { + let targetHex = null; + + if (Array.isArray(entry)) { + if (entry.length < 2) { + logDiscard(entry, "invalid-tuple"); + continue; + } + + const label = + typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : ""; + if (label !== "p") { + logDiscard(entry, "unsupported-tag"); + continue; + } + + targetHex = normalizeHex(entry[1]); + if (!targetHex) { + logDiscard(entry, "invalid-target"); + continue; + } + } else if (typeof entry === "string") { + targetHex = normalizeHex(entry); + if (!targetHex) { + logDiscard(entry, "invalid-string"); + continue; + } + } else if (entry && typeof entry === "object") { + if (typeof entry.pubkey === "string") { + targetHex = normalizeHex(entry.pubkey); + if (!targetHex) { + logDiscard(entry, "invalid-object-pubkey"); + continue; + } + } else { + logDiscard(entry, "unsupported-entry"); + continue; + } + } else { + logDiscard(entry, "unsupported-entry"); continue; } - seen.add(normalized); - tags.push(["p", normalized]); + + if (owner && targetHex === owner) { + logDiscard(entry, "self-target"); + continue; + } + + if (seen.has(targetHex)) { + logDiscard(entry, "duplicate", targetHex); + continue; + } + + seen.add(targetHex); + sanitized.push(["p", targetHex]); } + return sanitized; +} + +function serializeBlockListTagMatrix(values, ownerPubkey, options = {}) { + const tags = sanitizeMuteTags(values, ownerPubkey, options); return JSON.stringify(tags); } @@ -1267,30 +1370,7 @@ class UserBlockListManager { return null; } - const tagSet = new Set(); - const tags = []; - - const iterateBlocked = (input) => { - if (Array.isArray(input)) { - return input; - } - if (input instanceof Set) { - return Array.from(input); - } - if (input && typeof input[Symbol.iterator] === "function") { - return Array.from(input); - } - return []; - }; - - for (const value of iterateBlocked(blockedPubkeys)) { - const normalized = normalizeHex(value); - if (!normalized || normalized === owner || tagSet.has(normalized)) { - continue; - } - tagSet.add(normalized); - tags.push(["p", normalized]); - } + const tags = sanitizeMuteTags(blockedPubkeys, owner); const timestamp = Number.isFinite(createdAt) ? Math.max(0, Math.floor(createdAt)) @@ -1427,12 +1507,13 @@ class UserBlockListManager { throw new Error("Invalid user pubkey."); } + const sanitizedTags = sanitizeMuteTags(this.blockedPubkeys, normalized); const payload = { - blockedPubkeys: Array.from(this.blockedPubkeys).filter( - (candidate) => candidate && candidate !== normalized - ), + blockedPubkeys: sanitizedTags.map(([, target]) => target), }; - const plaintext = serializeBlockListTagMatrix(payload.blockedPubkeys, normalized); + const plaintext = serializeBlockListTagMatrix(sanitizedTags, normalized, { + logDiscarded: false, + }); const encryptors = []; const registerEncryptor = (scheme, handler) => { diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index 66c07d75..044c2568 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -91,6 +91,7 @@ await (async () => { [relays[0]]: [ { id: "event-old", + kind: 10000, created_at: 1700, pubkey: actor, content: "ciphertext-old", @@ -99,6 +100,7 @@ await (async () => { [relays[1]]: [ { id: "event-new", + kind: 10000, created_at: 1800, pubkey: actor, content: "ciphertext-new", @@ -241,6 +243,7 @@ await (async () => { backgroundResolver.resolve([ { id: "background-event", + kind: 10000, pubkey: actor, created_at: 2_000, content: "ciphertext-background", @@ -514,6 +517,7 @@ await (async () => { list: async () => [ { id: latestEventId, + kind: 10000, created_at: latestCreatedAt, pubkey: actor, content: latestCiphertext, @@ -856,6 +860,7 @@ await (async () => { return Promise.resolve([ { id: "event-nip44-read", + kind: 10000, created_at: 9_000, pubkey: actor, content: "cipher-nip44-read", @@ -882,3 +887,160 @@ await (async () => { } } })(); + +await (async () => { + const actor = "7".repeat(64); + const validOne = "8".repeat(64); + const validTwoUpper = "ABCDEF01".repeat(8); + const validTwo = validTwoUpper.toLowerCase(); + + const UserBlockListManager = userBlocks.constructor; + const manager = new UserBlockListManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalPool = nostrClient.pool; + const originalSigner = getActiveSigner(); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + const publishCalls = []; + const encryptionCalls = []; + const signedEvents = []; + const stderrChunks = []; + + process.stderr.write = (chunk, encoding, callback) => { + const text = + typeof chunk === "string" + ? chunk + : Buffer.from(chunk, typeof encoding === "string" ? encoding : undefined).toString(); + stderrChunks.push(text); + if (typeof callback === "function") { + callback(); + } + return true; + }; + + nostrClient.relays = ["wss://sanitize-blocks.example"]; + nostrClient.pool = { + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + handler(); + return true; + } + return false; + }, + }; + }, + }; + + setActiveSigner({ + type: "nsec", + pubkey: actor, + async nip44Encrypt(pubkey, plaintext) { + encryptionCalls.push({ pubkey, plaintext }); + return `cipher-${encryptionCalls.length}`; + }, + async signEvent(event) { + signedEvents.push(event); + return { ...event, id: `signed-${signedEvents.length}`, created_at: event.created_at }; + }, + }); + + manager.blockedPubkeys = new Set([ + validOne, + validTwoUpper, + actor, + ["p", ` ${validTwoUpper} `], + ["p"], + ["p", null], + "", + ["e", validOne], + null, + ]); + manager.loadBlocks = async () => {}; + + try { + await manager.publishBlockList(actor); + + assert.equal(publishCalls.length, 2, "block and mute events should publish"); + assert.equal(encryptionCalls.length, 2, "both block and mute payloads should encrypt"); + + const [blockPayload, mutePayload] = encryptionCalls; + assert.equal(blockPayload.pubkey, actor, "block payload should encrypt for the actor"); + assert.equal(mutePayload.pubkey, actor, "mute payload should encrypt for the actor"); + + const matrix = JSON.parse(blockPayload.plaintext); + assert.deepEqual( + matrix, + [ + ["p", validOne], + ["p", validTwo], + ], + "block payload should serialize only sanitized mute tags", + ); + assert.equal( + mutePayload.plaintext, + blockPayload.plaintext, + "mute payload should reuse the sanitized block payload", + ); + + const muteEvent = signedEvents.find((event) => + Array.isArray(event?.tags) && event.tags.some((tag) => Array.isArray(tag) && tag[0] === "p"), + ); + assert(muteEvent, "mute list snapshot should include public p tags"); + const muteTags = muteEvent.tags.filter((tag) => Array.isArray(tag) && tag[0] === "p"); + assert.deepEqual( + muteTags, + matrix, + "mute list snapshot should publish sanitized p tags", + ); + + const expectedReasons = [ + "self-target", + "duplicate", + "invalid-tuple", + "invalid-target", + "invalid-string", + "unsupported-tag", + "unsupported-entry", + ].sort(); + + const warningLines = stderrChunks + .join("") + .split(/\n+/) + .filter((line) => line.includes("[UserBlockList] Discarded block entry")); + + assert.equal( + warningLines.length, + expectedReasons.length, + "every malformed entry should trigger a warning", + ); + + const actualReasons = warningLines + .map((line) => { + const match = line.match(/\(([^)]+)\)/); + return match ? match[1] : null; + }) + .filter(Boolean) + .sort(); + + assert.deepEqual( + actualReasons, + expectedReasons, + "discarded entries should log their discard reasons", + ); + } finally { + process.stderr.write = originalStderrWrite; + nostrClient.relays = originalRelays; + nostrClient.pool = originalPool; + if (originalSigner) { + setActiveSigner(originalSigner); + } else { + clearActiveSigner(); + } + } +})();