Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
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)
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.
return 'blocked: reposts must not embed protected events'
}
} catch (_e) {
// Ignore invalid JSON: repost content is not a valid embedded event
}
}
}

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 = [['-']]
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
})
})
})
Loading