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
30 changes: 29 additions & 1 deletion src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ContextMetadataKey, EventExpirationTimeMetadataKey, EventKinds } from '../constants/base'
import { ContextMetadataKey, EventExpirationTimeMetadataKey, EventKinds, EventTags } from '../constants/base'
import {
DEFAULT_NIP05_VERIFY_EXPIRATION_MS,
extractNip05FromEvent,
Expand All @@ -21,6 +21,7 @@ import {
isEventSignatureValid,
isExpiredEvent,
isFileMessageEvent,
isProtectedEvent,
isRequestToVanishEvent,
isSealEvent,
Comment thread
Anshumancanrock marked this conversation as resolved.
isWelcomeRumorEvent,
Expand Down Expand Up @@ -88,6 +89,13 @@ export class EventMessageHandler implements IMessageHandler {
return
}

reason = 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 +232,26 @@ export class EventMessageHandler implements IMessageHandler {
}
}

protected isProtectedEventBlocked(event: Event): string | undefined {
if (isProtectedEvent(event)) {
return 'auth-required: this event may only be published by its author'
}

if (event.kind === EventKinds.REPOST && event.content.length > 0) {
try {
const embedded = JSON.parse(event.content)
Comment thread
Anshumancanrock marked this conversation as resolved.
Outdated
if (
Array.isArray(embedded?.tags) &&
embedded.tags.some((tag: string[]) => Array.isArray(tag) && tag[0] === EventTags.Protected)
) {
Comment thread
Anshumancanrock marked this conversation as resolved.
Outdated
return 'blocked: reposts must not embed protected events'
}
} catch (_e) {
// Ignore invalid JSON: repost content is not a valid embedded event
Comment thread
Anshumancanrock marked this conversation as resolved.
Outdated
}
}
}

protected async isEventValid(event: Event): Promise<string | undefined> {
if (!(await isEventIdValid(event))) {
return 'invalid: event id does not match'
Expand Down
112 changes: 112 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,116 @@ describe('EventMessageHandler', () => {
expect(nip05VerificationRepository.upsert).to.have.been.calledOnce
})
})

describe('isProtectedEventBlocked', () => {
beforeEach(() => {
handler = new EventMessageHandler(
{} 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 }),
)
})

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

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

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

it('returns reason if kind 6 repost embeds a protected event', () => {
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((handler as any).isProtectedEventBlocked(event)).to.equal(
'blocked: reposts must not embed protected events',
)
})

it('returns undefined if kind 6 repost embeds a non-protected event', () => {
event.kind = EventKinds.REPOST
event.content = JSON.stringify({
id: 'a'.repeat(64),
pubkey: 'b'.repeat(64),
kind: 1,
tags: [],
content: 'public',
sig: 'c'.repeat(128),
created_at: 1000,
})
event.tags = []
expect((handler as any).isProtectedEventBlocked(event)).to.be.undefined
})

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

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

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

it('rejects on the protected tag before checking embedded repost content', () => {
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((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', () => {
event.kind = EventKinds.REPOST
event.content = JSON.stringify({ tags: 'not-an-array' })
event.tags = []
expect((handler as any).isProtectedEventBlocked(event)).to.be.undefined
})
})
})
Loading