Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/nip70-reject-protected-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat: reject NIP-70 protected events and reposts embedding them
2 changes: 2 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export enum EventKinds {
DELETE = 5,
REPOST = 6,
REACTION = 7,
// NIP-18: Reposts
GENERIC_REPOST = 16,
// NIP-17: Private Direct Messages
SEAL = 13,
DIRECT_MESSAGE = 14,
Expand Down
42 changes: 42 additions & 0 deletions src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ContextMetadataKey, EventExpirationTimeMetadataKey, EventKinds } from '../constants/base'
import { attemptValidation } from '../utils/validation'
import { eventSchema } from '../schemas/event-schema'
import {
DEFAULT_NIP05_VERIFY_EXPIRATION_MS,
extractNip05FromEvent,
Expand All @@ -21,6 +23,7 @@ import {
isEventSignatureValid,
isExpiredEvent,
isFileMessageEvent,
isProtectedEvent,
isRequestToVanishEvent,
isSealEvent,
isWelcomeRumorEvent,
Expand Down Expand Up @@ -88,6 +91,13 @@ export class EventMessageHandler implements IMessageHandler {
return
}

reason = await this.isProtectedEventBlocked(event)
if (reason) {
logger('event %s rejected: %s', event.id, reason)
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, reason))
return
}

reason = await this.isBlockedByRequestToVanish(event)
if (reason) {
logger('event %s rejected: %s', event.id, reason)
Expand Down Expand Up @@ -224,6 +234,38 @@ export class EventMessageHandler implements IMessageHandler {
}
}

protected async isProtectedEventBlocked(event: Event): Promise<string | undefined> {
if (isProtectedEvent(event)) {
if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) {
return 'auth-required: this event may only be published by its author'
}
}

const checkEmbedded = async (evt: Event, depth = 0): Promise<boolean> => {
if (depth > 10) return false // Prevent infinite loops or excessive recursion
if ((evt.kind === EventKinds.REPOST || evt.kind === EventKinds.GENERIC_REPOST) && evt.content.length > 0) {
try {
const embedded = attemptValidation(eventSchema)(JSON.parse(evt.content)) as Event
if (!(await isEventIdValid(embedded)) || !(await isEventSignatureValid(embedded))) {
return false
}
if (isProtectedEvent(embedded)) {
return true
}
return await checkEmbedded(embedded, depth + 1)
} catch (error) {
logger.warn('event %s repost embedded event validation failed: %o', evt.id, error)
return false
}
}
return false
}

if (await checkEmbedded(event)) {
return 'blocked: reposts must not embed protected events'
}
}

protected async isEventValid(event: Event): Promise<string | undefined> {
if (!(await isEventIdValid(event))) {
return 'invalid: event id does not match'
Expand Down
160 changes: 160 additions & 0 deletions test/unit/handlers/event-message-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2120,4 +2120,164 @@ describe('EventMessageHandler', () => {
expect(nip05VerificationRepository.upsert).to.have.been.calledOnce
})
})

describe('isProtectedEventBlocked', () => {
const PRIVKEY = '0000000000000000000000000000000000000000000000000000000000000001'

beforeEach(() => {
handler = new EventMessageHandler(
{ getAuthenticatedPubkeys: () => new Set() } as any,
() => null,
{} as any,
userRepository,
() =>
({
info: { relay_url: 'relay_url' },
}) as any,
{} as any,
{ hasKey: async () => false, setKey: async () => true } as any,
() => ({ hit: async () => false }),
)
})

const createValidEmbeddedEvent = async (tags: string[][]): Promise<Event> => {
const unsigned = await identifyEvent({
pubkey: '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'.slice(2),
created_at: 1000,
kind: 1,
tags: tags as any,
content: 'test content',
})
return signEvent(PRIVKEY)(unsigned)
}

it('returns reason if event has a protected tag and user is not authenticated', async () => {
event.tags = [['-']] as any
expect(await (handler as any).isProtectedEventBlocked(event)).to.equal(
'auth-required: this event may only be published by its author',
)
})

it('returns undefined if event has a protected tag and user is authenticated', async () => {
event.tags = [['-']] as any
handler = new EventMessageHandler(
{ getAuthenticatedPubkeys: () => new Set([event.pubkey]) } as any,
() => null,
{} as any,
userRepository,
() => ({ info: { relay_url: 'relay_url' } }) as any,
{} as any,
{ hasKey: async () => false, setKey: async () => true } as any,
() => ({ hit: async () => false }),
)
expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined
})

it('returns undefined if event has no protected tag', async () => {
event.tags = [['e', 'abc']]
expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined
})

it('returns undefined if event has no tags', async () => {
event.tags = []
expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined
})

it('returns reason if kind 6 repost embeds a valid protected event', async () => {
const embedded = await createValidEmbeddedEvent([['-']])
event.kind = EventKinds.REPOST
event.content = JSON.stringify(embedded)
event.tags = []
expect(await (handler as any).isProtectedEventBlocked(event)).to.equal(
'blocked: reposts must not embed protected events',
)
})

it('returns reason if kind 6 repost embeds a repost that embeds a valid protected event (nested)', async () => {
const protectedEvent = await createValidEmbeddedEvent([['-']])
const middleRepost = await identifyEvent({
pubkey: '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'.slice(2),
created_at: 1000,
kind: EventKinds.GENERIC_REPOST,
tags: [],
content: JSON.stringify(protectedEvent),
})
const signedMiddleRepost = await signEvent(PRIVKEY)(middleRepost)

event.kind = EventKinds.REPOST
event.content = JSON.stringify(signedMiddleRepost)
event.tags = []
expect(await (handler as any).isProtectedEventBlocked(event)).to.equal(
'blocked: reposts must not embed protected events',
)
})

it('returns undefined if kind 6 repost embeds a valid non-protected event', async () => {
const embedded = await createValidEmbeddedEvent([])
event.kind = EventKinds.REPOST
event.content = JSON.stringify(embedded)
event.tags = []
expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined
})

it('returns undefined if embedded event has invalid id/sig (forged protected tag)', async () => {
event.kind = EventKinds.REPOST
event.content = JSON.stringify({
id: 'a'.repeat(64),
pubkey: 'b'.repeat(64),
kind: 1,
tags: [['-']],
content: 'secret',
sig: 'c'.repeat(128),
created_at: 1000,
})
event.tags = []
expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined
})

it('returns undefined if kind 6 repost has empty content', async () => {
event.kind = EventKinds.REPOST
event.content = ''
event.tags = []
expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined
})

it('returns undefined if kind 6 repost has invalid JSON content', async () => {
event.kind = EventKinds.REPOST
event.content = 'not json'
event.tags = []
expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined
})

it('returns undefined for non-repost event kinds with JSON content', async () => {
event.kind = EventKinds.TEXT_NOTE
event.content = JSON.stringify({ tags: [['-']] })
event.tags = []
expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined
})

it('rejects on the outer protected tag before checking embedded repost content', async () => {
event.kind = EventKinds.REPOST
event.content = JSON.stringify({
id: 'a'.repeat(64),
pubkey: 'b'.repeat(64),
kind: 1,
tags: [['-']],
content: 'secret',
sig: 'c'.repeat(128),
created_at: 1000,
})
event.tags = [['-']] as any
expect(await (handler as any).isProtectedEventBlocked(event)).to.equal(
'auth-required: this event may only be published by its author',
)
})

it('returns undefined if kind 6 repost has non-array embedded tags', async () => {
event.kind = EventKinds.REPOST
event.content = JSON.stringify({ tags: 'not-an-array' })
event.tags = []
expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined
})
})
})
Loading