diff --git a/.changeset/nip70-reject-protected-events.md b/.changeset/nip70-reject-protected-events.md new file mode 100644 index 00000000..a5247b13 --- /dev/null +++ b/.changeset/nip70-reject-protected-events.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: reject NIP-70 protected events and reposts embedding them diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 7139cbad..e6a78850 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -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, @@ -21,6 +21,7 @@ import { isEventSignatureValid, isExpiredEvent, isFileMessageEvent, + isProtectedEvent, isRequestToVanishEvent, isSealEvent, isWelcomeRumorEvent, @@ -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) @@ -224,6 +232,25 @@ 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) + if ( + Array.isArray(embedded?.tags) && + embedded.tags.some((tag: string[]) => Array.isArray(tag) && tag[0] === EventTags.Protected) + ) { + return 'blocked: reposts must not embed protected events' + } + } catch (_e) { + } + } + } + protected async isEventValid(event: Event): Promise { if (!(await isEventIdValid(event))) { return 'invalid: event id does not match' diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 57ee66a9..76401433 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -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 = [['-']] + 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 = [['-']] + 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 + }) + }) })