From 6331896f6a3dda6f41e5ad900394da0dea1a390b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 12 Nov 2025 09:54:34 -0500 Subject: [PATCH] Sanitize additional Nostr tags --- js/nostr/commentEvents.js | 7 +- js/nostr/publishHelpers.js | 18 +-- js/nostr/reactionEvents.js | 8 +- js/nostr/viewEvents.js | 7 +- js/nostrEventSchemas.js | 161 +++++++++++++++++++-------- tests/comment-event-builder.test.mjs | 69 +++++++++++- tests/view-counter.test.mjs | 57 +++++++++- 7 files changed, 252 insertions(+), 75 deletions(-) diff --git a/js/nostr/commentEvents.js b/js/nostr/commentEvents.js index b508a803..b90bf7aa 100644 --- a/js/nostr/commentEvents.js +++ b/js/nostr/commentEvents.js @@ -2,6 +2,7 @@ import { buildCommentEvent, getNostrEventSchema, NOTE_TYPES, + sanitizeAdditionalTags, } from "../nostrEventSchemas.js"; import { publishEventToRelay } from "../nostrPublish.js"; import { RELAY_URLS } from "./toolkit.js"; @@ -1158,11 +1159,7 @@ export async function publishComment( ? Math.floor(options.created_at) : Math.floor(Date.now() / 1000); - const additionalTags = Array.isArray(options.additionalTags) - ? options.additionalTags.filter( - (tag) => Array.isArray(tag) && typeof tag[0] === "string", - ) - : []; + const additionalTags = sanitizeAdditionalTags(options.additionalTags); let content = ""; if (typeof options.content === "string") { diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js index 393c92a5..44da23d8 100644 --- a/js/nostr/publishHelpers.js +++ b/js/nostr/publishHelpers.js @@ -4,7 +4,11 @@ import { assertAnyRelayAccepted, summarizePublishResults, } from "../nostrPublish.js"; -import { buildRepostEvent, buildVideoMirrorEvent } from "../nostrEventSchemas.js"; +import { + buildRepostEvent, + buildVideoMirrorEvent, + sanitizeAdditionalTags, +} from "../nostrEventSchemas.js"; import { normalizePointerInput } from "./watchHistory.js"; import { sanitizeRelayList } from "./nip46Client.js"; import { @@ -893,9 +897,7 @@ export async function repostEvent({ ? Math.max(0, Math.floor(options.created_at)) : Math.floor(Date.now() / 1000); - const additionalTags = Array.isArray(options.additionalTags) - ? options.additionalTags.filter((tag) => Array.isArray(tag) && tag.length >= 2) - : []; + const additionalTags = sanitizeAdditionalTags(options.additionalTags); const repostKind = Number.isFinite(targetKind) && targetKind !== 1 @@ -1082,10 +1084,10 @@ export async function mirrorVideoEvent({ tags.push(["ox", originalFileSha256]); } - const additionalTags = Array.isArray(options.additionalTags) - ? options.additionalTags.filter((tag) => Array.isArray(tag) && tag.length >= 2) - : []; - tags.push(...additionalTags); + const additionalTags = sanitizeAdditionalTags(options.additionalTags); + if (additionalTags.length) { + tags.push(...additionalTags.map((tag) => tag.slice())); + } let actorPubkey = typeof options.actorPubkey === "string" && options.actorPubkey.trim() diff --git a/js/nostr/reactionEvents.js b/js/nostr/reactionEvents.js index 22e957ed..fba80c4c 100644 --- a/js/nostr/reactionEvents.js +++ b/js/nostr/reactionEvents.js @@ -1,4 +1,4 @@ -import { buildReactionEvent } from "../nostrEventSchemas.js"; +import { buildReactionEvent, sanitizeAdditionalTags } from "../nostrEventSchemas.js"; import { publishEventToRelay } from "../nostrPublish.js"; import { RELAY_URLS } from "./toolkit.js"; import { normalizePointerInput } from "./watchHistory.js"; @@ -157,11 +157,7 @@ export async function publishVideoReaction( ? Math.floor(options.created_at) : Math.floor(Date.now() / 1000); - const additionalTags = Array.isArray(options.additionalTags) - ? options.additionalTags.filter( - (tag) => Array.isArray(tag) && typeof tag[0] === "string" - ) - : []; + const additionalTags = sanitizeAdditionalTags(options.additionalTags); const optionRelay = typeof options.pointerRelay === "string" && options.pointerRelay.trim() diff --git a/js/nostr/viewEvents.js b/js/nostr/viewEvents.js index 1d3bf2c0..44c11da2 100644 --- a/js/nostr/viewEvents.js +++ b/js/nostr/viewEvents.js @@ -7,6 +7,7 @@ import { buildViewEvent, getNostrEventSchema, NOTE_TYPES, + sanitizeAdditionalTags, } from "../nostrEventSchemas.js"; import { publishEventToRelay } from "../nostrPublish.js"; import { RELAY_URLS } from "./toolkit.js"; @@ -710,11 +711,7 @@ export async function publishViewEvent( const usingSessionActor = normalizedActor && normalizedActor !== normalizedLogged; - const additionalTags = Array.isArray(options.additionalTags) - ? options.additionalTags.filter( - (tag) => Array.isArray(tag) && typeof tag[0] === "string" - ) - : []; + const additionalTags = sanitizeAdditionalTags(options.additionalTags); const pointerTag = pointer.type === "a" diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 2e3f0ecc..dfd2fbaf 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -1,4 +1,5 @@ import { devLogger } from "./utils/logger.js"; +import { decodeNpubToHex } from "./nostr/nip46Client.js"; import { ADMIN_LIST_NAMESPACE, ADMIN_COMMUNITY_BLACKLIST_SOURCES, @@ -45,6 +46,101 @@ const DEFAULT_APPEND_TAGS = []; let cachedUtf8Encoder = null; +const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]); +const HEX_32_BYTE_REGEX = /^[0-9a-f]{64}$/i; + +function normalizePointerIdentifier(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + + if (HEX_32_BYTE_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + const decoded = decodeNpubToHex(trimmed); + if (decoded && HEX_32_BYTE_REGEX.test(decoded)) { + return decoded.toLowerCase(); + } + + return ""; +} + +function normalizeTagSlotValue(value) { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed; + } + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return ""; + } + return String(value); + } + + if (value === null || value === undefined) { + return ""; + } + + return ""; +} + +export function sanitizeAdditionalTags(additionalTags) { + if (!Array.isArray(additionalTags) || !additionalTags.length) { + return []; + } + + const sanitized = []; + + additionalTags.forEach((tag) => { + if (!Array.isArray(tag) || tag.length < 2) { + return; + } + + const rawName = tag[0]; + if (typeof rawName !== "string") { + return; + } + + const tagName = rawName.trim(); + if (!tagName) { + return; + } + + const lowerName = tagName.toLowerCase(); + let primaryValue = ""; + + if (ADDITIONAL_POINTER_TAGS.has(lowerName)) { + primaryValue = normalizePointerIdentifier(tag[1]); + } else { + primaryValue = normalizeTagSlotValue(tag[1]); + } + + if (!primaryValue) { + return; + } + + const sanitizedTag = [tagName, primaryValue]; + + for (let index = 2; index < tag.length; index += 1) { + const extraValue = normalizeTagSlotValue(tag[index]); + if (extraValue) { + sanitizedTag.push(extraValue); + } + } + + sanitized.push(sanitizedTag); + }); + + return sanitized; +} + function ensureValidUtf8Content(value) { let normalized = ""; if (typeof value === "string") { @@ -606,12 +702,9 @@ export function buildVideoPostEvent({ tags.push([schema.identifierTag.name, dTagValue]); } appendSchemaTags(tags, schema); - if (Array.isArray(additionalTags)) { - additionalTags.forEach((tag) => { - if (Array.isArray(tag) && tag.length >= 2) { - tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); - } - }); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); } return { @@ -693,13 +786,9 @@ export function buildRepostEvent({ } appendSchemaTags(tags, schema); - - if (Array.isArray(additionalTags)) { - additionalTags.forEach((tag) => { - if (Array.isArray(tag) && tag.length >= 2) { - tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); - } - }); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); } const normalizedRepostKind = @@ -822,12 +911,9 @@ export function buildRelayListEvent({ appendSchemaTags(tags, schema); - if (Array.isArray(additionalTags)) { - additionalTags.forEach((tag) => { - if (Array.isArray(tag) && tag.length >= 2) { - tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); - } - }); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); } return { @@ -864,12 +950,9 @@ export function buildViewEvent({ normalizedPointerTags.forEach((tag) => { tags.push(tag); }); - if (Array.isArray(additionalTags)) { - additionalTags.forEach((tag) => { - if (Array.isArray(tag) && tag.length >= 2) { - tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); - } - }); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); } if (dedupeTag && schema?.identifierTag?.name) { @@ -1015,12 +1098,9 @@ export function buildReactionEvent({ } } - if (Array.isArray(additionalTags)) { - additionalTags.forEach((tag) => { - if (Array.isArray(tag) && tag.length >= 2) { - tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); - } - }); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); } appendSchemaTags(tags, schema); @@ -1250,12 +1330,9 @@ export function buildCommentEvent({ } } - if (Array.isArray(additionalTags)) { - additionalTags.forEach((tag) => { - if (Array.isArray(tag) && tag.length >= 2) { - tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); - } - }); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); } appendSchemaTags(tags, schema); @@ -1303,13 +1380,9 @@ export function buildWatchHistoryIndexEvent({ }); appendSchemaTags(tags, schema); - - if (Array.isArray(additionalTags)) { - additionalTags.forEach((tag) => { - if (Array.isArray(tag) && tag.length >= 2) { - tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); - } - }); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); } let resolvedContent = content; diff --git a/tests/comment-event-builder.test.mjs b/tests/comment-event-builder.test.mjs index eef58ede..b1f80962 100644 --- a/tests/comment-event-builder.test.mjs +++ b/tests/comment-event-builder.test.mjs @@ -1,11 +1,34 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { +const SANITIZED_SAMPLE_HEX = "deadbeef".repeat(8); +const SANITIZED_SAMPLE_NPUB = "npub1commentadditionaltagfixture000000000000000000000"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (!globalThis.window.NostrTools) { + globalThis.window.NostrTools = {}; +} + +const existingNip19 = + typeof globalThis.window.NostrTools.nip19 === "object" + ? globalThis.window.NostrTools.nip19 + : {}; + +if (typeof existingNip19.decode !== "function") { + globalThis.window.NostrTools.nip19 = { + ...existingNip19, + decode: (value) => ({ type: "npub", data: SANITIZED_SAMPLE_HEX }), + }; +} + +const { buildCommentEvent, getNostrEventSchema, NOTE_TYPES, -} from "../js/nostrEventSchemas.js"; +} = await import("../js/nostrEventSchemas.js"); const VIDEO_COMMENT_KIND = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT)?.kind ?? 1111; @@ -84,7 +107,6 @@ test("buildCommentEvent normalizes relays and preserves explicit overrides", () ["k", String(VIDEO_COMMENT_KIND)], ["p", "cafecafe", "wss://profile"], ["client", "bitvid"], - ["p", "cafecafe", "wss://override"], ]); assert.equal(event.content, " Appreciated! "); }); @@ -136,3 +158,44 @@ test("buildCommentEvent accepts partial metadata for parent overrides", () => { ["p", "parent-only", "wss://parent-author"], ]); }); + +test("buildCommentEvent sanitizes additional tags before signing", () => { + const event = buildCommentEvent({ + pubkey: "sanitizer", + created_at: 1700000600, + videoEventId: "sanitized-event", + threadParticipantPubkey: "cafecafe", + additionalTags: [ + ["p", null], + ["p", SANITIZED_SAMPLE_NPUB], + ["client", { foo: "bar" }], + ["client", "bitvid"], + ], + content: "sanitize", + }); + + const clientTags = event.tags.filter( + (tag) => Array.isArray(tag) && tag[0] === "client", + ); + assert.deepStrictEqual(clientTags, [["client", "bitvid"]]); + + const normalizedAdditionalPubkeys = event.tags.filter( + (tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === SANITIZED_SAMPLE_HEX, + ); + assert.equal( + normalizedAdditionalPubkeys.length, + 1, + "sanitized npub additional tag should normalize to hex", + ); + + assert.ok( + !event.tags.some( + (tag) => + Array.isArray(tag) && + tag[0] === "client" && + typeof tag[1] === "string" && + tag[1].includes("[object Object]"), + ), + "invalid object values should be removed instead of stringified", + ); +}); diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index d0f68408..9c5d2356 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -10,10 +10,6 @@ const { VIEW_COUNT_CACHE_TTL_MS, } = await import("../js/config.js"); -const { buildViewEvent, setNostrEventSchemaOverrides } = await import( - "../js/nostrEventSchemas.js" -); - const VIEW_COUNTER_STORAGE_KEY = "bitvid:view-counter:v1"; const CACHE_TTL_TEST_POINTER = { type: "e", value: "view-counter-cache-ttl" }; @@ -47,6 +43,19 @@ if (!globalThis.window.NostrTools) { globalThis.window.NostrTools = {}; } +const VIEW_SANITIZED_SAMPLE_HEX = "deadbeef".repeat(8); +const existingNip19Decoder = + typeof globalThis.window.NostrTools.nip19 === "object" + ? globalThis.window.NostrTools.nip19 + : {}; + +if (typeof existingNip19Decoder.decode !== "function") { + globalThis.window.NostrTools.nip19 = { + ...existingNip19Decoder, + decode: () => ({ type: "npub", data: VIEW_SANITIZED_SAMPLE_HEX }), + }; +} + if (typeof globalThis.window.NostrTools.getEventHash !== "function") { globalThis.window.NostrTools.getEventHash = () => "test-event-hash"; } @@ -55,6 +64,10 @@ if (typeof globalThis.window.NostrTools.signEvent !== "function") { globalThis.window.NostrTools.signEvent = () => "test-event-sig"; } +const { buildViewEvent, setNostrEventSchemaOverrides } = await import( + "../js/nostrEventSchemas.js" +); + setNostrEventSchemaOverrides({}); const canonicalViewEvent = buildViewEvent({ @@ -93,6 +106,42 @@ assert.deepEqual( "view event should append the session tag when requested" ); +const sanitizedViewEvent = buildViewEvent({ + pubkey: "actor-sanitize", + created_at: 3000, + pointerValue: "event-sanitize", + pointerTag: ["e", "event-sanitize"], + additionalTags: [ + ["p", null], + ["p", "npub1viewadditionaltagfixture000000000000000000000"], + ["client", { foo: "bar" }], + ["client", "bitvid"], + ], +}); + +assert.ok( + sanitizedViewEvent.tags.some( + (tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === VIEW_SANITIZED_SAMPLE_HEX + ), + "view event should normalize npub additional tags to hex" +); + +const sanitizedClientTags = sanitizedViewEvent.tags.filter( + (tag) => Array.isArray(tag) && tag[0] === "client" +); +assert.deepEqual(sanitizedClientTags, [["client", "bitvid"]]); + +assert.ok( + !sanitizedViewEvent.tags.some( + (tag) => + Array.isArray(tag) && + tag[0] === "client" && + typeof tag[1] === "string" && + tag[1].includes("[object Object]") + ), + "invalid additional tag values should not be coerced to object strings" +); + const { nostrClient } = await import("../js/nostr.js"); function createMockNostrHarness() {