Browse Source

block anything with stringified json in the content

imwald
Silberengel 1 month ago
parent
commit
34e6a80cd5
  1. 25
      src/lib/event-ingest-filter.ts
  2. 46
      src/services/client-events.service.ts
  3. 12
      src/services/client-query.service.ts
  4. 15
      src/services/client.service.ts

25
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<number>([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<NEvent, 'kind' | 'content'>
): 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
}
}

46
src/services/client-events.service.ts

@ -7,6 +7,7 @@ import indexedDb from './indexed-db.service'
import type { QueryService } from './client-query.service' import type { QueryService } from './client-query.service'
import client from './client.service' import client from './client.service'
import { isReplaceableEvent } from '@/lib/event' import { isReplaceableEvent } from '@/lib/event'
import { isStringifiedJsonObjectContentNostrEvent } from '@/lib/event-ingest-filter'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
/** /**
@ -70,6 +71,17 @@ export class EventService {
return null 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 { private notifySessionEventWaiters(hexId: string): void {
const waiters = this.sessionEventWaiters.get(hexId) const waiters = this.sessionEventWaiters.get(hexId)
if (!waiters?.size) return if (!waiters?.size) return
@ -90,7 +102,7 @@ export class EventService {
const hex = this.resolveHexWaiterKey(eventId) const hex = this.resolveHexWaiterKey(eventId)
if (!hex) return () => {} if (!hex) return () => {}
if (this.sessionEventCache.has(hex)) { if (this.getSessionEventIfAllowed(hex)) {
queueMicrotask(() => callback()) queueMicrotask(() => callback())
} }
@ -134,16 +146,16 @@ export class EventService {
} }
} }
if (hexId) { if (hexId) {
const fromSession = this.sessionEventCache.get(hexId) const fromSession = this.getSessionEventIfAllowed(hexId)
if (fromSession) return fromSession if (fromSession) return fromSession
const cachedPromise = this.eventCacheMap.get(hexId) const cachedPromise = this.eventCacheMap.get(hexId)
if (cachedPromise) { if (cachedPromise) {
const resolved = await cachedPromise const resolved = await cachedPromise
if (resolved) return resolved if (resolved && !isStringifiedJsonObjectContentNostrEvent(resolved)) return resolved
const fromSessionAfterMiss = this.sessionEventCache.get(hexId) const fromSessionAfterMiss = this.getSessionEventIfAllowed(hexId)
if (fromSessionAfterMiss) return fromSessionAfterMiss if (fromSessionAfterMiss) return fromSessionAfterMiss
const fromDb = await indexedDb.getEventFromPublicationStore(hexId) const fromDb = await indexedDb.getEventFromPublicationStore(hexId)
if (fromDb) { if (fromDb && !isStringifiedJsonObjectContentNostrEvent(fromDb)) {
this.addEventToCache(fromDb) this.addEventToCache(fromDb)
return fromDb return fromDb
} }
@ -153,9 +165,12 @@ export class EventService {
} }
const loaded = await this.eventDataLoader.load(hexId ?? trimmed) const loaded = await this.eventDataLoader.load(hexId ?? trimmed)
if (hexId) { if (hexId) {
const fromSessionAfter = this.sessionEventCache.get(hexId) const fromSessionAfter = this.getSessionEventIfAllowed(hexId)
if (fromSessionAfter) return fromSessionAfter if (fromSessionAfter) return fromSessionAfter
} }
if (loaded && isStringifiedJsonObjectContentNostrEvent(loaded)) {
return undefined
}
return loaded return loaded
} }
@ -203,7 +218,7 @@ export class EventService {
.filter((id) => /^[0-9a-f]{64}$/.test(id)) .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 if (toFetch.length === 0) return
const relayUrls = await buildComprehensiveRelayListForEvents(undefined, [], [], []) const relayUrls = await buildComprehensiveRelayListForEvents(undefined, [], [], [])
@ -303,6 +318,7 @@ export class EventService {
* Add event to session cache * Add event to session cache
*/ */
addEventToCache(event: NEvent): void { addEventToCache(event: NEvent): void {
if (isStringifiedJsonObjectContentNostrEvent(event)) return
const cleanEvent = { ...event } const cleanEvent = { ...event }
delete (cleanEvent as any).relayStatuses delete (cleanEvent as any).relayStatuses
// REQ filters and nip19 decode use lowercase hex; some relays/clients emit uppercase ids. // 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() const queryLower = query.toLowerCase()
for (const [, event] of this.sessionEventCache.entries()) { for (const [, event] of this.sessionEventCache.entries()) {
if (isStringifiedJsonObjectContentNostrEvent(event)) continue
if (allowedKinds && !allowedKinds.includes(event.kind)) continue if (allowedKinds && !allowedKinds.includes(event.kind)) continue
const content = event.content.toLowerCase() const content = event.content.toLowerCase()
if (content.includes(queryLower)) { if (content.includes(queryLower)) {
results.push(event) results.push(event)
@ -419,7 +436,7 @@ export class EventService {
// Try cache first // Try cache first
if (filter.ids?.length) { if (filter.ids?.length) {
const cached = await indexedDb.getEventFromPublicationStore(filter.ids[0]) const cached = await indexedDb.getEventFromPublicationStore(filter.ids[0])
if (cached) { if (cached && !isStringifiedJsonObjectContentNostrEvent(cached)) {
this.addEventToCache(cached) this.addEventToCache(cached)
// Extract relay hints from cached event's tags (e, a, q tags) // Extract relay hints from cached event's tags (e, a, q tags)
const eventRelayHints = this.extractRelayHintsFromEvent(cached) const eventRelayHints = this.extractRelayHintsFromEvent(cached)
@ -433,7 +450,7 @@ export class EventService {
// Try big relays first (uses user's inboxes + defaults) // Try big relays first (uses user's inboxes + defaults)
if (filter.ids?.length) { if (filter.ids?.length) {
const event = await this.fetchEventFromBigRelaysDataloader.load(filter.ids[0]) const event = await this.fetchEventFromBigRelaysDataloader.load(filter.ids[0])
if (event) { if (event && !isStringifiedJsonObjectContentNostrEvent(event)) {
this.addEventToCache(event) this.addEventToCache(event)
// Extract relay hints from found event's tags (e, a, q tags) // Extract relay hints from found event's tags (e, a, q tags)
const eventRelayHints = this.extractRelayHintsFromEvent(event) 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) // Always try comprehensive relay list (author's outboxes + user's inboxes + hints + seen + defaults)
const event = await this.tryHarderToFetchEvent(relays, filter, true) const event = await this.tryHarderToFetchEvent(relays, filter, true)
if (event) { if (event && !isStringifiedJsonObjectContentNostrEvent(event)) {
this.addEventToCache(event) this.addEventToCache(event)
return event return event
} }
@ -455,7 +472,7 @@ export class EventService {
if (filter.ids?.length === 1) { if (filter.ids?.length === 1) {
const raw = filter.ids[0] const raw = filter.ids[0]
const key = /^[0-9a-f]{64}$/i.test(raw) ? raw.toLowerCase() : raw 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 if (sess) return sess
} }
@ -505,7 +522,9 @@ export class EventService {
globalTimeout: isSingleEventById ? 12000 : 10000 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 // For non-replaceable events, we've already returned immediately via immediateReturn
// But log it for debugging // But log it for debugging
@ -542,6 +561,7 @@ export class EventService {
const eventsMap = new Map<string, NEvent>() const eventsMap = new Map<string, NEvent>()
for (const event of events) { for (const event of events) {
if (isStringifiedJsonObjectContentNostrEvent(event)) continue
const key = /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id const key = /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id
eventsMap.set(key, event) eventsMap.set(key, event)
// Note: We can't track which relay returned which event in batch queries, // Note: We can't track which relay returned which event in batch queries,

12
src/services/client-query.service.ts

@ -5,6 +5,7 @@ import {
MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_CONCURRENT_RELAY_CONNECTIONS,
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { isStringifiedJsonObjectContentNostrEvent } from '@/lib/event-ingest-filter'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import type { Filter, Event as NEvent } from 'nostr-tools' import type { Filter, Event as NEvent } from 'nostr-tools'
@ -393,6 +394,13 @@ export class QueryService {
return false return false
} }
const forwardOnevent = callbacks.onevent
? (evt: NEvent) => {
if (isStringifiedJsonObjectContentNostrEvent(evt)) return
callbacks.onevent!(evt)
}
: undefined
const subs: { relayKey: string; close: () => void }[] = [] const subs: { relayKey: string; close: () => void }[] = []
const allOpened = Promise.all( const allOpened = Promise.all(
groupedRequests.map(async ({ url, filters: relayFilters }, i) => { groupedRequests.map(async ({ url, filters: relayFilters }, i) => {
@ -419,7 +427,7 @@ export class QueryService {
const sub = relay.subscribe(relayFilters, { const sub = relay.subscribe(relayFilters, {
receivedEvent: (_relay, id) => this.trackEventSeenOn(id, _relay), receivedEvent: (_relay, id) => this.trackEventSeenOn(id, _relay),
onevent: (evt: NEvent) => callbacks.onevent?.(evt), onevent: (evt: NEvent) => forwardOnevent?.(evt),
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: (reason: string) => { onclose: (reason: string) => {
releaseOnce() releaseOnce()
@ -452,7 +460,7 @@ export class QueryService {
try { try {
const sub2 = liveRelay.subscribe(relayFilters, { const sub2 = liveRelay.subscribe(relayFilters, {
receivedEvent: (_relay, id) => this.trackEventSeenOn(id, _relay), receivedEvent: (_relay, id) => this.trackEventSeenOn(id, _relay),
onevent: (evt: NEvent) => callbacks.onevent?.(evt), onevent: (evt: NEvent) => forwardOnevent?.(evt),
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: (reason2: string) => { onclose: (reason2: string) => {
releaseSlot2() releaseSlot2()

15
src/services/client.service.ts

@ -17,6 +17,7 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter {
const { search: _search, ...rest } = f const { search: _search, ...rest } = f
return rest as Filter return rest as Filter
} }
import { isStringifiedJsonObjectContentNostrEvent } from '@/lib/event-ingest-filter'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { dispatchTombstonesUpdated } from '@/lib/tombstone-events' import { dispatchTombstonesUpdated } from '@/lib/tombstone-events'
@ -1338,6 +1339,13 @@ class ClientService extends EventTarget {
return false return false
} }
const forwardOnevent = onevent
? (evt: NEvent) => {
if (isStringifiedJsonObjectContentNostrEvent(evt)) return
onevent(evt)
}
: undefined
const subs: { relayKey: string; close: () => void }[] = [] const subs: { relayKey: string; close: () => void }[] = []
const allOpened = Promise.all( const allOpened = Promise.all(
groupedRequests.map(async ({ url, filters: relayFilters }, i) => { groupedRequests.map(async ({ url, filters: relayFilters }, i) => {
@ -1367,7 +1375,7 @@ class ClientService extends EventTarget {
onevent: (evt: NEvent) => { onevent: (evt: NEvent) => {
logFirstEventIfFirstResponseWasEmpty(evt, relayKey) logFirstEventIfFirstResponseWasEmpty(evt, relayKey)
logFirstRelayResponse('event', relayKey) logFirstRelayResponse('event', relayKey)
onevent?.(evt) forwardOnevent?.(evt)
}, },
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: (reason: string) => { onclose: (reason: string) => {
@ -1408,7 +1416,7 @@ class ClientService extends EventTarget {
onevent: (evt: NEvent) => { onevent: (evt: NEvent) => {
logFirstEventIfFirstResponseWasEmpty(evt, relayKey) logFirstEventIfFirstResponseWasEmpty(evt, relayKey)
logFirstRelayResponse('event', relayKey) logFirstRelayResponse('event', relayKey)
onevent?.(evt) forwardOnevent?.(evt)
}, },
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: (reason2: string) => { onclose: (reason2: string) => {
@ -1474,13 +1482,14 @@ class ClientService extends EventTarget {
const customEvent = data as CustomEvent<NEvent> const customEvent = data as CustomEvent<NEvent>
const evt = customEvent.detail const evt = customEvent.detail
if (!matchFilters(filters, evt)) return if (!matchFilters(filters, evt)) return
if (isStringifiedJsonObjectContentNostrEvent(evt)) return
const id = evt.id const id = evt.id
const have = _knownIds.has(id) const have = _knownIds.has(id)
if (have) return if (have) return
_knownIds.add(id) _knownIds.add(id)
onevent?.(evt) forwardOnevent?.(evt)
} }
this.addEventListener('newEvent', handleNewEventFromInternal) this.addEventListener('newEvent', handleNewEventFromInternal)

Loading…
Cancel
Save