From 34e6a80cd55aa7ce88fd6d181d584d292411071c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 23 Mar 2026 05:30:15 +0100 Subject: [PATCH] block anything with stringified json in the content --- src/lib/event-ingest-filter.ts | 25 +++++++++++++++ src/services/client-events.service.ts | 46 +++++++++++++++++++-------- src/services/client-query.service.ts | 12 +++++-- src/services/client.service.ts | 15 +++++++-- 4 files changed, 80 insertions(+), 18 deletions(-) create mode 100644 src/lib/event-ingest-filter.ts diff --git a/src/lib/event-ingest-filter.ts b/src/lib/event-ingest-filter.ts new file mode 100644 index 00000000..f01c5c2d --- /dev/null +++ b/src/lib/event-ingest-filter.ts @@ -0,0 +1,25 @@ +import type { Event as NEvent } from 'nostr-tools' + +/** + * Kinds whose `content` is conventionally a JSON object string per Nostr usage (not treated as app-protocol spam). + * Extend when another NIP documents JSON-in-content for a kind. + */ +export const NOSTR_KINDS_ALLOWED_STRINGIFIED_JSON_OBJECT_CONTENT = new Set([0]) + +/** + * True when `content` is a stringified JSON **object** (not arrays/primitives) on a kind that should carry human text + * or other non-JSON payloads — e.g. game/app data published as kind 31987 relay reviews. + */ +export function isStringifiedJsonObjectContentNostrEvent( + event: Pick +): boolean { + if (NOSTR_KINDS_ALLOWED_STRINGIFIED_JSON_OBJECT_CONTENT.has(event.kind)) return false + const c = typeof event.content === 'string' ? event.content.trim() : '' + if (c.length < 2 || c[0] !== '{' || c[c.length - 1] !== '}') return false + try { + const v = JSON.parse(c) as unknown + return v !== null && typeof v === 'object' && !Array.isArray(v) + } catch { + return false + } +} diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 7fcf7f64..5eb5811a 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -7,6 +7,7 @@ import indexedDb from './indexed-db.service' import type { QueryService } from './client-query.service' import client from './client.service' import { isReplaceableEvent } from '@/lib/event' +import { isStringifiedJsonObjectContentNostrEvent } from '@/lib/event-ingest-filter' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' /** @@ -70,6 +71,17 @@ export class EventService { return null } + /** Returns cached event or undefined; evicts stringified-JSON-object spam from the session LRU. */ + private getSessionEventIfAllowed(hexId: string): NEvent | undefined { + const e = this.sessionEventCache.get(hexId) + if (!e) return undefined + if (isStringifiedJsonObjectContentNostrEvent(e)) { + this.sessionEventCache.delete(hexId) + return undefined + } + return e + } + private notifySessionEventWaiters(hexId: string): void { const waiters = this.sessionEventWaiters.get(hexId) if (!waiters?.size) return @@ -90,7 +102,7 @@ export class EventService { const hex = this.resolveHexWaiterKey(eventId) if (!hex) return () => {} - if (this.sessionEventCache.has(hex)) { + if (this.getSessionEventIfAllowed(hex)) { queueMicrotask(() => callback()) } @@ -134,16 +146,16 @@ export class EventService { } } if (hexId) { - const fromSession = this.sessionEventCache.get(hexId) + const fromSession = this.getSessionEventIfAllowed(hexId) if (fromSession) return fromSession const cachedPromise = this.eventCacheMap.get(hexId) if (cachedPromise) { const resolved = await cachedPromise - if (resolved) return resolved - const fromSessionAfterMiss = this.sessionEventCache.get(hexId) + if (resolved && !isStringifiedJsonObjectContentNostrEvent(resolved)) return resolved + const fromSessionAfterMiss = this.getSessionEventIfAllowed(hexId) if (fromSessionAfterMiss) return fromSessionAfterMiss const fromDb = await indexedDb.getEventFromPublicationStore(hexId) - if (fromDb) { + if (fromDb && !isStringifiedJsonObjectContentNostrEvent(fromDb)) { this.addEventToCache(fromDb) return fromDb } @@ -153,9 +165,12 @@ export class EventService { } const loaded = await this.eventDataLoader.load(hexId ?? trimmed) if (hexId) { - const fromSessionAfter = this.sessionEventCache.get(hexId) + const fromSessionAfter = this.getSessionEventIfAllowed(hexId) if (fromSessionAfter) return fromSessionAfter } + if (loaded && isStringifiedJsonObjectContentNostrEvent(loaded)) { + return undefined + } return loaded } @@ -203,7 +218,7 @@ export class EventService { .filter((id) => /^[0-9a-f]{64}$/.test(id)) ) ] - const toFetch = hexIds.filter((id) => !this.sessionEventCache.has(id)) + const toFetch = hexIds.filter((id) => !this.getSessionEventIfAllowed(id)) if (toFetch.length === 0) return const relayUrls = await buildComprehensiveRelayListForEvents(undefined, [], [], []) @@ -303,6 +318,7 @@ export class EventService { * Add event to session cache */ addEventToCache(event: NEvent): void { + if (isStringifiedJsonObjectContentNostrEvent(event)) return const cleanEvent = { ...event } delete (cleanEvent as any).relayStatuses // REQ filters and nip19 decode use lowercase hex; some relays/clients emit uppercase ids. @@ -324,8 +340,9 @@ export class EventService { const queryLower = query.toLowerCase() for (const [, event] of this.sessionEventCache.entries()) { + if (isStringifiedJsonObjectContentNostrEvent(event)) continue if (allowedKinds && !allowedKinds.includes(event.kind)) continue - + const content = event.content.toLowerCase() if (content.includes(queryLower)) { results.push(event) @@ -419,7 +436,7 @@ export class EventService { // Try cache first if (filter.ids?.length) { const cached = await indexedDb.getEventFromPublicationStore(filter.ids[0]) - if (cached) { + if (cached && !isStringifiedJsonObjectContentNostrEvent(cached)) { this.addEventToCache(cached) // Extract relay hints from cached event's tags (e, a, q tags) const eventRelayHints = this.extractRelayHintsFromEvent(cached) @@ -433,7 +450,7 @@ export class EventService { // Try big relays first (uses user's inboxes + defaults) if (filter.ids?.length) { const event = await this.fetchEventFromBigRelaysDataloader.load(filter.ids[0]) - if (event) { + if (event && !isStringifiedJsonObjectContentNostrEvent(event)) { this.addEventToCache(event) // Extract relay hints from found event's tags (e, a, q tags) const eventRelayHints = this.extractRelayHintsFromEvent(event) @@ -446,7 +463,7 @@ export class EventService { // Always try comprehensive relay list (author's outboxes + user's inboxes + hints + seen + defaults) const event = await this.tryHarderToFetchEvent(relays, filter, true) - if (event) { + if (event && !isStringifiedJsonObjectContentNostrEvent(event)) { this.addEventToCache(event) return event } @@ -455,7 +472,7 @@ export class EventService { if (filter.ids?.length === 1) { const raw = filter.ids[0] const key = /^[0-9a-f]{64}$/i.test(raw) ? raw.toLowerCase() : raw - const sess = this.sessionEventCache.get(key) + const sess = this.getSessionEventIfAllowed(key) if (sess) return sess } @@ -505,7 +522,9 @@ export class EventService { globalTimeout: isSingleEventById ? 12000 : 10000 }) - const event = events.sort((a, b) => b.created_at - a.created_at)[0] + const event = events + .filter((e) => !isStringifiedJsonObjectContentNostrEvent(e)) + .sort((a, b) => b.created_at - a.created_at)[0] // For non-replaceable events, we've already returned immediately via immediateReturn // But log it for debugging @@ -542,6 +561,7 @@ export class EventService { const eventsMap = new Map() for (const event of events) { + if (isStringifiedJsonObjectContentNostrEvent(event)) continue const key = /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id eventsMap.set(key, event) // Note: We can't track which relay returned which event in batch queries, diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index aaed30ae..5c30bfe5 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -5,6 +5,7 @@ import { MAX_CONCURRENT_RELAY_CONNECTIONS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { isStringifiedJsonObjectContentNostrEvent } from '@/lib/event-ingest-filter' import logger from '@/lib/logger' import { normalizeUrl } from '@/lib/url' import type { Filter, Event as NEvent } from 'nostr-tools' @@ -393,6 +394,13 @@ export class QueryService { return false } + const forwardOnevent = callbacks.onevent + ? (evt: NEvent) => { + if (isStringifiedJsonObjectContentNostrEvent(evt)) return + callbacks.onevent!(evt) + } + : undefined + const subs: { relayKey: string; close: () => void }[] = [] const allOpened = Promise.all( groupedRequests.map(async ({ url, filters: relayFilters }, i) => { @@ -419,7 +427,7 @@ export class QueryService { const sub = relay.subscribe(relayFilters, { receivedEvent: (_relay, id) => this.trackEventSeenOn(id, _relay), - onevent: (evt: NEvent) => callbacks.onevent?.(evt), + onevent: (evt: NEvent) => forwardOnevent?.(evt), oneose: () => handleEose(i), onclose: (reason: string) => { releaseOnce() @@ -452,7 +460,7 @@ export class QueryService { try { const sub2 = liveRelay.subscribe(relayFilters, { receivedEvent: (_relay, id) => this.trackEventSeenOn(id, _relay), - onevent: (evt: NEvent) => callbacks.onevent?.(evt), + onevent: (evt: NEvent) => forwardOnevent?.(evt), oneose: () => handleEose(i), onclose: (reason2: string) => { releaseSlot2() diff --git a/src/services/client.service.ts b/src/services/client.service.ts index b659d35c..60055f28 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -17,6 +17,7 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { const { search: _search, ...rest } = f return rest as Filter } +import { isStringifiedJsonObjectContentNostrEvent } from '@/lib/event-ingest-filter' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { dispatchTombstonesUpdated } from '@/lib/tombstone-events' @@ -1338,6 +1339,13 @@ class ClientService extends EventTarget { return false } + const forwardOnevent = onevent + ? (evt: NEvent) => { + if (isStringifiedJsonObjectContentNostrEvent(evt)) return + onevent(evt) + } + : undefined + const subs: { relayKey: string; close: () => void }[] = [] const allOpened = Promise.all( groupedRequests.map(async ({ url, filters: relayFilters }, i) => { @@ -1367,7 +1375,7 @@ class ClientService extends EventTarget { onevent: (evt: NEvent) => { logFirstEventIfFirstResponseWasEmpty(evt, relayKey) logFirstRelayResponse('event', relayKey) - onevent?.(evt) + forwardOnevent?.(evt) }, oneose: () => handleEose(i), onclose: (reason: string) => { @@ -1408,7 +1416,7 @@ class ClientService extends EventTarget { onevent: (evt: NEvent) => { logFirstEventIfFirstResponseWasEmpty(evt, relayKey) logFirstRelayResponse('event', relayKey) - onevent?.(evt) + forwardOnevent?.(evt) }, oneose: () => handleEose(i), onclose: (reason2: string) => { @@ -1474,13 +1482,14 @@ class ClientService extends EventTarget { const customEvent = data as CustomEvent const evt = customEvent.detail if (!matchFilters(filters, evt)) return + if (isStringifiedJsonObjectContentNostrEvent(evt)) return const id = evt.id const have = _knownIds.has(id) if (have) return _knownIds.add(id) - onevent?.(evt) + forwardOnevent?.(evt) } this.addEventListener('newEvent', handleNewEventFromInternal)