Sanitize mute tags before publishing block list

This commit is contained in:
thePR0M3TH3AN committed 2025-11-12 09:28:04 -05:00
1 parent ad6374de92
commit 9e969e8120
2 files changed
+279 -36

No files matched your search

+117 -36
View File
@@ -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) => {
+162
View File
@@ -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();
}
}
})();