mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 03:12:31 +00:00
Sanitize mute tags before publishing block list
This commit is contained in:
1 parent
ad6374de92
commit
9e969e8120
2 files changed
+279
-36
No files matched your search
+117
-36
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user