Merge pull request #1035 from PR0M3TH3AN/codex/add-tag-sanitization-helper-and-updates

Sanitize additional Nostr tags
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-11-12 09:57:43 -05:00
commit 6ae8e6ee19
7 files changed
+252 -75

No files matched your search

+2 -5
View File
@@ -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") {
+10 -8
View File
@@ -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()
+2 -6
View File
@@ -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()
+2 -5
View File
@@ -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"
+117 -44
View File
@@ -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;
+66 -3
View File
@@ -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",
);
});
+53 -4
View File
@@ -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() {