diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 979fac3b..908fbe76 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -119,4 +119,10 @@ Running `nostream` for the first time creates the settings file in `=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index 2227f5bc..85beb93c 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "prestart": "npm run build", "start": "cd dist && node src/index.js", "build:check": "npm run build -- --noEmit", - "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .ts ./src ./test", - "lint:report": "ESLINT_USE_FLAT_CONFIG=false eslint -o .lint-reports/eslint.json -f json --ext .ts ./src ./test", + "lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint --ext .ts ./src ./test", + "lint:report": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint -o .lint-reports/eslint.json -f json --ext .ts ./src ./test", "lint:fix": "npm run lint -- --fix", "knip": "knip --config .knip.json --production --no-progress --reporter compact", "check:all": "npm run lint && npm run knip", @@ -80,6 +80,8 @@ "@commitlint/config-conventional": "17.2.0", "@cucumber/cucumber": "10.2.1", "@cucumber/pretty-formatter": "1.0.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.39.1", "@semantic-release/commit-analyzer": "9.0.2", "@semantic-release/git": "10.0.1", "@semantic-release/github": "8.1.0", @@ -97,13 +99,12 @@ "@types/sinon": "^10.0.11", "@types/sinon-chai": "^3.2.8", "@types/ws": "^8.5.12", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.39.1", "@typescript-eslint/eslint-plugin": "^8.58.1", "@typescript-eslint/parser": "^8.58.1", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", "conventional-changelog-conventionalcommits": "5.0.0", + "cross-env": "^10.1.0", "cz-conventional-changelog": "3.3.0", "eslint": "^9.39.4", "husky": "8.0.2", @@ -125,11 +126,11 @@ }, "dependencies": { "@noble/secp256k1": "1.7.1", + "accepts": "^1.3.8", "axios": "^1.15.0", "bech32": "2.0.0", "debug": "4.3.4", "dotenv": "16.0.3", - "accepts": "^1.3.8", "express": "4.22.1", "helmet": "6.0.1", "js-yaml": "4.1.1", diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 9c2e888a..2a659de5 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -35,6 +35,24 @@ paymentsProcessors: opennode: baseURL: api.opennode.com callbackBaseURL: https://nostream.your-domain.com/callbacks/opennode +nip05: + # NIP-05 verification of event authors as a spam reduction measure. + # mode: 'enabled' requires NIP-05 for publishing (except kind 0), + # 'passive' verifies but never blocks, 'disabled' does nothing. + mode: disabled + # How long (ms) a successful verification remains valid before re-check. + # Matches nostr-rs-relay default of 1 week. + verifyExpiration: 604800000 + # Minimum interval (ms) between re-verification attempts for a given author. + # Matches nostr-rs-relay default of 24 hours. + verifyUpdateFrequency: 86400000 + # How many consecutive failed checks before giving up on verifying an author. + # Matches nostr-rs-relay default of 20. + maxConsecutiveFailures: 20 + # Only allow authors with NIP-05 at these domains (empty = allow all) + domainWhitelist: [] + # Block authors with NIP-05 at these domains + domainBlacklist: [] network: maxPayloadSize: 524288 # Comment the next line if using CloudFlare proxy diff --git a/src/@types/nip05.ts b/src/@types/nip05.ts new file mode 100644 index 00000000..56b6fbde --- /dev/null +++ b/src/@types/nip05.ts @@ -0,0 +1,25 @@ +import { Pubkey } from './base' + +export interface Nip05Verification { + pubkey: Pubkey + nip05: string + domain: string + isVerified: boolean + lastVerifiedAt: Date | null + lastCheckedAt: Date + failureCount: number + createdAt: Date + updatedAt: Date +} + +export interface DBNip05Verification { + pubkey: Buffer + nip05: string + domain: string + is_verified: boolean + last_verified_at: Date | null + last_checked_at: Date + failure_count: number + created_at: Date + updated_at: Date +} diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 1271509d..8395cc57 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -5,6 +5,7 @@ import { DBEvent, Event } from './event' import { EventKinds } from '../constants/base' import { EventKindsRange } from './settings' import { Invoice } from './invoice' +import { Nip05Verification } from './nip05' import { SubscriptionFilter } from './subscription' import { User } from './user' @@ -64,3 +65,14 @@ export interface IUserRepository { getBalanceByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise admitUser(pubkey: Pubkey, admittedAt: Date, client?: DatabaseClient): Promise } + +export interface INip05VerificationRepository { + findByPubkey(pubkey: Pubkey): Promise + upsert(verification: Nip05Verification): Promise + findPendingVerifications( + updateFrequencyMs: number, + maxFailures: number, + limit: number, + ): Promise + deleteByPubkey(pubkey: Pubkey): Promise +} diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 3dbf1bbf..0e4ec4a6 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -228,6 +228,29 @@ export interface Mirroring { static?: Mirror[] } +export type Nip05Mode = 'enabled' | 'passive' | 'disabled' + +export interface Nip05Settings { + mode: Nip05Mode + /** + * Maximum age (in ms) of a successful verification before an author is blocked. + * Defaults to 604800000 (7 days) when unset. + */ + verifyExpiration?: number + /** + * Minimum interval (in ms) between background re-verifications per author. + * Defaults to 86400000 (24 hours) when unset. + */ + verifyUpdateFrequency?: number + /** + * Number of consecutive verification failures after which an author is no longer + * re-checked. Defaults to 20 when unset. + */ + maxConsecutiveFailures?: number + domainWhitelist?: string[] + domainBlacklist?: string[] +} + export interface Settings { info: Info payments?: Payments @@ -236,4 +259,5 @@ export interface Settings { workers?: Worker limits?: Limits mirroring?: Mirroring + nip05?: Nip05Settings } diff --git a/src/app/maintenance-worker.ts b/src/app/maintenance-worker.ts index 686590c8..d078aa6e 100644 --- a/src/app/maintenance-worker.ts +++ b/src/app/maintenance-worker.ts @@ -1,17 +1,71 @@ +import { + DEFAULT_NIP05_MAX_CONSECUTIVE_FAILURES, + DEFAULT_NIP05_VERIFY_UPDATE_FREQUENCY_MS, + Nip05VerificationOutcome, + verifyNip05Identifier, +} from '../utils/nip05' import { IMaintenanceService, IPaymentsService } from '../@types/services' import { mergeDeepLeft, path, pipe } from 'ramda' import { IRunnable } from '../@types/base' import { createLogger } from '../factories/logger-factory' import { delayMs } from '../utils/misc' +import { INip05VerificationRepository } from '../@types/repositories' import { InvoiceStatus } from '../@types/invoice' +import { Nip05Verification } from '../@types/nip05' import { Settings } from '../@types/settings' const UPDATE_INVOICE_INTERVAL = 60000 +const NIP05_REVERIFICATION_BATCH_SIZE = 50 const CLEAR_OLD_EVENTS_TIMEOUT_MS = 5000 const debug = createLogger('maintenance-worker') +/** + * Merge a re-verification outcome onto an existing verification row. + * + * Definitive outcomes (`verified`, `mismatch`, `invalid`) update `isVerified` + * and `lastVerifiedAt`. Transient `error` outcomes only bump `failureCount` / + * `lastCheckedAt` so a previously-verified author keeps their grace period + * until `verifyExpiration` elapses. This prevents a single network blip from + * immediately blocking publishing. + */ +export function applyReverificationOutcome( + existing: Nip05Verification, + outcome: Nip05VerificationOutcome, +): Nip05Verification { + const now = new Date() + const base: Nip05Verification = { + ...existing, + lastCheckedAt: now, + updatedAt: now, + } + + switch (outcome.status) { + case 'verified': + return { + ...base, + isVerified: true, + lastVerifiedAt: now, + failureCount: 0, + } + case 'mismatch': + case 'invalid': + return { + ...base, + isVerified: false, + lastVerifiedAt: null, + failureCount: existing.failureCount + 1, + } + case 'error': + default: + return { + ...base, + failureCount: existing.failureCount + 1, + } + } +} + export class MaintenanceWorker implements IRunnable { private interval: NodeJS.Timeout | undefined private isRunning = false @@ -21,6 +75,7 @@ export class MaintenanceWorker implements IRunnable { private readonly paymentsService: IPaymentsService, private readonly maintenanceService: IMaintenanceService, private readonly settings: () => Settings, + private readonly nip05VerificationRepository: INip05VerificationRepository, ) { this.process .on('SIGINT', this.onExit.bind(this)) @@ -65,6 +120,8 @@ export class MaintenanceWorker implements IRunnable { const currentSettings = this.settings() const clearOldEventsPromise = this.clearOldEventsSafely() + await this.processNip05Reverifications(currentSettings) + if (!path(['payments','enabled'], currentSettings)) { await clearOldEventsPromise return @@ -120,6 +177,43 @@ export class MaintenanceWorker implements IRunnable { await clearOldEventsPromise } + private async processNip05Reverifications(currentSettings: Settings): Promise { + const nip05Settings = currentSettings.nip05 + if (!nip05Settings || nip05Settings.mode === 'disabled') { + return + } + + try { + const updateFrequency = nip05Settings.verifyUpdateFrequency ?? DEFAULT_NIP05_VERIFY_UPDATE_FREQUENCY_MS + const maxFailures = nip05Settings.maxConsecutiveFailures ?? DEFAULT_NIP05_MAX_CONSECUTIVE_FAILURES + + const pendingVerifications = await this.nip05VerificationRepository.findPendingVerifications( + updateFrequency, + maxFailures, + NIP05_REVERIFICATION_BATCH_SIZE, + ) + + if (!pendingVerifications.length) { + return + } + + debug('found %d NIP-05 verifications to re-check', pendingVerifications.length) + + for (const verification of pendingVerifications) { + try { + const outcome = await verifyNip05Identifier(verification.nip05, verification.pubkey) + const updated = applyReverificationOutcome(verification, outcome) + await this.nip05VerificationRepository.upsert(updated) + await delayMs(200 + Math.floor(Math.random() * 100)) + } catch (error) { + debug('failed to re-verify NIP-05 for %s: %o', verification.pubkey, error) + } + } + } catch (error) { + debug('NIP-05 re-verification batch failed: %o', error) + } + } + private onError(error: Error) { debug('error: %o', error) throw error diff --git a/src/factories/maintenance-worker-factory.ts b/src/factories/maintenance-worker-factory.ts index 9eddcd1d..94197b7c 100644 --- a/src/factories/maintenance-worker-factory.ts +++ b/src/factories/maintenance-worker-factory.ts @@ -1,13 +1,18 @@ import { createMaintenanceService } from './maintenance-service-factory' import { createPaymentsService } from './payments-service-factory' import { createSettings } from './settings-factory' +import { getMasterDbClient } from '../database/client' import { MaintenanceWorker } from '../app/maintenance-worker' +import { Nip05VerificationRepository } from '../repositories/nip05-verification-repository' export const maintenanceWorkerFactory = () => { + const dbClient = getMasterDbClient() + const nip05VerificationRepository = new Nip05VerificationRepository(dbClient) return new MaintenanceWorker( process, createPaymentsService(), createMaintenanceService(), - createSettings + createSettings, + nip05VerificationRepository, ) } diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 34c37493..e1fb14b5 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -1,4 +1,4 @@ -import { IEventRepository, IUserRepository } from '../@types/repositories' +import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { IncomingMessage, MessageType } from '../@types/messages' import { createSettings } from './settings-factory' import { EventMessageHandler } from '../handlers/event-message-handler' @@ -11,6 +11,7 @@ import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handl export const messageHandlerFactory = ( eventRepository: IEventRepository, userRepository: IUserRepository, + nip05VerificationRepository: INip05VerificationRepository, ) => ([message, adapter]: [IncomingMessage, IWebSocketAdapter]) => { switch (message[0]) { case MessageType.EVENT: @@ -22,6 +23,7 @@ export const messageHandlerFactory = ( userRepository, createSettings, slidingWindowRateLimiterFactory, + nip05VerificationRepository, ) } case MessageType.REQ: diff --git a/src/factories/websocket-adapter-factory.ts b/src/factories/websocket-adapter-factory.ts index 8d92fa6f..7b3f83df 100644 --- a/src/factories/websocket-adapter-factory.ts +++ b/src/factories/websocket-adapter-factory.ts @@ -1,7 +1,7 @@ import { IncomingMessage } from 'http' import { WebSocket } from 'ws' -import { IEventRepository, IUserRepository } from '../@types/repositories' +import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { createSettings } from './settings-factory' import { IWebSocketServerAdapter } from '../@types/adapters' import { messageHandlerFactory } from './message-handler-factory' @@ -12,12 +12,13 @@ import { WebSocketAdapter } from '../adapters/web-socket-adapter' export const webSocketAdapterFactory = ( eventRepository: IEventRepository, userRepository: IUserRepository, + nip05VerificationRepository: INip05VerificationRepository, ) => ([client, request, webSocketServerAdapter]: [WebSocket, IncomingMessage, IWebSocketServerAdapter]) => new WebSocketAdapter( client, request, webSocketServerAdapter, - messageHandlerFactory(eventRepository, userRepository), + messageHandlerFactory(eventRepository, userRepository, nip05VerificationRepository), slidingWindowRateLimiterFactory, createSettings, ) diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index 123e59d2..bd758989 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -8,6 +8,7 @@ import { AppWorker } from '../app/worker' import { createSettings } from '../factories/settings-factory' import { createWebApp } from './web-app-factory' import { EventRepository } from '../repositories/event-repository' +import { Nip05VerificationRepository } from '../repositories/nip05-verification-repository' import { UserRepository } from '../repositories/user-repository' import { webSocketAdapterFactory } from './websocket-adapter-factory' import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter' @@ -17,6 +18,7 @@ export const workerFactory = (): AppWorker => { const readReplicaDbClient = getReadReplicaDbClient() const eventRepository = new EventRepository(dbClient, readReplicaDbClient) const userRepository = new UserRepository(dbClient) + const nip05VerificationRepository = new Nip05VerificationRepository(dbClient) const settings = createSettings() @@ -58,7 +60,7 @@ export const workerFactory = (): AppWorker => { const adapter = new WebSocketServerAdapter( server, webSocketServer, - webSocketAdapterFactory(eventRepository, userRepository), + webSocketAdapterFactory(eventRepository, userRepository, nip05VerificationRepository), createSettings, ) diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 238e121f..d968b993 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -1,5 +1,13 @@ import { ContextMetadataKey, EventExpirationTimeMetadataKey, EventKinds } from '../constants/base' -import { Event, ExpiringEvent } from '../@types/event' +import { + DEFAULT_NIP05_VERIFY_EXPIRATION_MS, + extractNip05FromEvent, + isDomainAllowed, + Nip05VerificationOutcome, + parseNip05Identifier, + verifyNip05Identifier, +} from '../utils/nip05' +import { Event, ExpiringEvent } from '../@types/event' import { EventRateLimit, FeeSchedule, Settings } from '../@types/settings' import { getEventExpiration, @@ -16,7 +24,7 @@ import { isRequestToVanishEvent, isSealEvent, } from '../utils/event' -import { IEventRepository, IUserRepository } from '../@types/repositories' +import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { IEventStrategy, IMessageHandler } from '../@types/message-handlers' import { createCommandResult } from '../utils/messages' import { createLogger } from '../factories/logger-factory' @@ -24,6 +32,7 @@ import { Factory } from '../@types/base' import { IncomingEventMessage } from '../@types/messages' import { IRateLimiter } from '../@types/utils' import { IWebSocketAdapter } from '../@types/adapters' +import { Nip05Verification } from '../@types/nip05' import { WebSocketAdapterEvent } from '../constants/adapter' const debug = createLogger('event-message-handler') @@ -36,6 +45,7 @@ export class EventMessageHandler implements IMessageHandler { protected readonly userRepository: IUserRepository, private readonly settings: () => Settings, private readonly slidingWindowRateLimiter: Factory, + private readonly nip05VerificationRepository: INip05VerificationRepository, ) {} public async handleMessage(message: IncomingEventMessage): Promise { @@ -85,6 +95,13 @@ export class EventMessageHandler implements IMessageHandler { return } + reason = await this.checkNip05Verification(event) + if (reason) { + debug('event %s rejected: %s', event.id, reason) + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, reason)) + return + } + const strategy = this.strategyFactory([event, this.webSocket]) if (typeof strategy?.execute !== 'function') { @@ -94,6 +111,7 @@ export class EventMessageHandler implements IMessageHandler { try { await strategy.execute(event) + this.processNip05Metadata(event) } catch (error) { console.error('error handling message', message, error) this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, 'error: unable to process event')) @@ -349,4 +367,161 @@ export class EventMessageHandler implements IMessageHandler { return expiringEvent } + + protected async checkNip05Verification(event: Event): Promise { + const nip05Settings = this.settings().nip05 + if (!nip05Settings || nip05Settings.mode !== 'enabled') { + return + } + + if (this.getRelayPublicKey() === event.pubkey) { + return + } + + if (event.kind === EventKinds.SET_METADATA) { + return + } + + const verification = await this.nip05VerificationRepository.findByPubkey(event.pubkey) + + if (!verification) { + return 'blocked: NIP-05 verification required' + } + + if (!isDomainAllowed(verification.domain, nip05Settings.domainWhitelist, nip05Settings.domainBlacklist)) { + return 'blocked: NIP-05 domain not allowed' + } + + // `lastVerifiedAt` is the single source of truth for "currently allowed". + // A transient network error during background re-verification leaves this + // value intact, so a verified author keeps publishing until the configured + // expiration elapses. Only a definitive mismatch (handled in the maintenance + // worker / processNip05Metadata) nulls this field and blocks the author. + // + // Historical rows could theoretically have `isVerified=true` with a null + // `lastVerifiedAt`; treat that as "needs re-verification" rather than + // "verified forever". + if (!verification.lastVerifiedAt) { + return 'blocked: NIP-05 verification required' + } + + const expirationMs = nip05Settings.verifyExpiration ?? DEFAULT_NIP05_VERIFY_EXPIRATION_MS + const elapsed = Date.now() - verification.lastVerifiedAt.getTime() + if (elapsed > expirationMs) { + return 'blocked: NIP-05 verification expired' + } + } + + protected processNip05Metadata(event: Event): void { + const nip05Settings = this.settings().nip05 + if (!nip05Settings || nip05Settings.mode === 'disabled') { + return + } + + if (event.kind !== EventKinds.SET_METADATA) { + return + } + + const nip05Identifier = extractNip05FromEvent(event) + if (!nip05Identifier) { + this.nip05VerificationRepository.deleteByPubkey(event.pubkey).catch((error) => { + debug('failed to remove NIP-05 verification for %s: %o', event.pubkey, error) + }) + return + } + + const parsed = parseNip05Identifier(nip05Identifier) + if (!parsed) { + return + } + + if (!isDomainAllowed(parsed.domain, nip05Settings.domainWhitelist, nip05Settings.domainBlacklist)) { + debug('NIP-05 domain %s not allowed for %s', parsed.domain, event.pubkey) + return + } + + const repo = this.nip05VerificationRepository + Promise.all([ + repo.findByPubkey(event.pubkey), + verifyNip05Identifier(nip05Identifier, event.pubkey), + ]) + .then(([existing, outcome]) => { + const verification = buildMetadataVerification( + event.pubkey, + nip05Identifier, + parsed.domain, + existing, + outcome, + ) + return repo.upsert(verification) + }) + .catch((error) => { + debug('NIP-05 verification failed for %s: %o', event.pubkey, error) + }) + } +} + +/** + * Build the row to upsert after a kind-0 verification attempt. + * + * - `verified` resets failureCount and refreshes lastVerifiedAt. + * - `mismatch` / `invalid` are definitive: flip to unverified, null out + * lastVerifiedAt (the author is no longer the domain's owner), and bump + * failureCount relative to any prior row. + * - `error` is transient: keep the prior isVerified/lastVerifiedAt (if any) + * so previously-verified authors aren't blocked by a single network hiccup, + * but still bump failureCount + lastCheckedAt so the re-verification backoff + * can take effect. + */ +function buildMetadataVerification( + pubkey: string, + nip05: string, + domain: string, + existing: Nip05Verification | undefined, + outcome: Nip05VerificationOutcome, +): Nip05Verification { + const now = new Date() + const priorFailureCount = existing?.failureCount ?? 0 + const createdAt = existing?.createdAt ?? now + + switch (outcome.status) { + case 'verified': + return { + pubkey, + nip05, + domain, + isVerified: true, + lastVerifiedAt: now, + lastCheckedAt: now, + failureCount: 0, + createdAt, + updatedAt: now, + } + case 'mismatch': + case 'invalid': + return { + pubkey, + nip05, + domain, + isVerified: false, + lastVerifiedAt: null, + lastCheckedAt: now, + failureCount: priorFailureCount + 1, + createdAt, + updatedAt: now, + } + case 'error': + default: + return { + pubkey, + nip05, + domain, + isVerified: existing?.isVerified ?? false, + lastVerifiedAt: existing?.lastVerifiedAt ?? null, + lastCheckedAt: now, + failureCount: priorFailureCount + 1, + createdAt, + updatedAt: now, + } + } } diff --git a/src/repositories/nip05-verification-repository.ts b/src/repositories/nip05-verification-repository.ts new file mode 100644 index 00000000..46ee2911 --- /dev/null +++ b/src/repositories/nip05-verification-repository.ts @@ -0,0 +1,107 @@ +import { applySpec, pipe, prop } from 'ramda' + +import { DatabaseClient, Pubkey } from '../@types/base' +import { DBNip05Verification, Nip05Verification } from '../@types/nip05' +import { fromBuffer, toBuffer } from '../utils/transform' +import { createLogger } from '../factories/logger-factory' +import { INip05VerificationRepository } from '../@types/repositories' + +const debug = createLogger('nip05-verification-repository') + +const fromDBNip05Verification = applySpec({ + pubkey: pipe(prop('pubkey') as () => Buffer, fromBuffer), + nip05: prop('nip05') as () => string, + domain: prop('domain') as () => string, + isVerified: prop('is_verified') as () => boolean, + lastVerifiedAt: prop('last_verified_at') as () => Date | null, + lastCheckedAt: prop('last_checked_at') as () => Date, + failureCount: prop('failure_count') as () => number, + createdAt: prop('created_at') as () => Date, + updatedAt: prop('updated_at') as () => Date, +}) + +export class Nip05VerificationRepository implements INip05VerificationRepository { + public constructor( + private readonly dbClient: DatabaseClient, + ) {} + + public async findByPubkey(pubkey: Pubkey): Promise { + debug('find by pubkey: %s', pubkey) + + const [row] = await this.dbClient('nip05_verifications') + .where('pubkey', toBuffer(pubkey)) + .select() + + if (!row) { + return undefined + } + + return fromDBNip05Verification(row) + } + + public async upsert(verification: Nip05Verification): Promise { + debug('upsert: %s (%s)', verification.pubkey, verification.nip05) + + const now = new Date() + + const row: DBNip05Verification = { + pubkey: toBuffer(verification.pubkey), + nip05: verification.nip05, + domain: verification.domain, + is_verified: verification.isVerified, + last_verified_at: verification.lastVerifiedAt, + last_checked_at: verification.lastCheckedAt || now, + failure_count: verification.failureCount, + created_at: now, + updated_at: now, + } + + const query = this.dbClient('nip05_verifications') + .insert(row) + .onConflict('pubkey') + .merge({ + nip05: row.nip05, + domain: row.domain, + is_verified: row.is_verified, + last_verified_at: row.last_verified_at, + last_checked_at: row.last_checked_at, + failure_count: row.failure_count, + updated_at: now, + }) + + return { + then: ( + onfulfilled: (value: number) => T1 | PromiseLike, + onrejected: (reason: any) => T2 | PromiseLike, + ) => query.then(prop('rowCount') as () => number).then(onfulfilled, onrejected), + catch: (onrejected: (reason: any) => T | PromiseLike) => query.catch(onrejected), + toString: (): string => query.toString(), + } as Promise + } + + public async findPendingVerifications( + updateFrequencyMs: number, + maxFailures: number, + limit: number, + ): Promise { + debug('find pending verifications (frequency: %dms, maxFailures: %d)', updateFrequencyMs, maxFailures) + + const cutoff = new Date(Date.now() - updateFrequencyMs) + + const rows = await this.dbClient('nip05_verifications') + .where('last_checked_at', '<', cutoff) + .andWhere('failure_count', '<', maxFailures) + .orderBy('last_checked_at', 'asc') + .limit(limit) + + return rows.map(fromDBNip05Verification) + } + + public async deleteByPubkey(pubkey: Pubkey): Promise { + debug('delete by pubkey: %s', pubkey) + + return this.dbClient('nip05_verifications') + .where('pubkey', toBuffer(pubkey)) + .delete() + } +} diff --git a/src/utils/nip05.ts b/src/utils/nip05.ts new file mode 100644 index 00000000..ccefea2d --- /dev/null +++ b/src/utils/nip05.ts @@ -0,0 +1,226 @@ +import axios, { AxiosError } from 'axios' +import { z } from 'zod' + +import { createLogger } from '../factories/logger-factory' +import { Event } from '../@types/event' +import { EventKinds } from '../constants/base' +import { pubkeySchema } from '../schemas/base-schema' + +const debug = createLogger('nip05') + +const VERIFICATION_TIMEOUT_MS = 10000 +// NIP-05 responses are trivially small; cap hard to protect relay memory/bandwidth. +const MAX_RESPONSE_BYTES = 64 * 1024 +// Allow at most a single redirect. Zero would break some operators that front +// their well-known under a redirect; unlimited would enable SSRF pivoting. +const MAX_REDIRECTS = 1 +const DOMAIN_REGEX = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/ + +// Public defaults used by callers when settings omit these fields. Exported so the +// runtime defaults stay in one place and are easy to unit test. +export const DEFAULT_NIP05_VERIFY_EXPIRATION_MS = 604800000 +export const DEFAULT_NIP05_VERIFY_UPDATE_FREQUENCY_MS = 86400000 +export const DEFAULT_NIP05_MAX_CONSECUTIVE_FAILURES = 20 + +/** + * Result of a NIP-05 verification attempt. + * + * `verified` / `mismatch` / `invalid` are *definitive* outcomes: the remote host + * responded with a well-formed document and we could decide the pubkey question. + * Callers should update `isVerified` / `lastVerifiedAt` based on these. + * + * `error` is *transient* (network failure, timeout, malformed JSON, oversized + * response, redirect to a disallowed target, etc.). Callers should keep any + * prior successful verification intact and only bump `failureCount` / `lastCheckedAt`. + */ +export type Nip05VerificationOutcome = + | { status: 'verified' } + | { status: 'mismatch' } + | { status: 'invalid'; reason: string } + | { status: 'error'; reason: string } + +interface Nip05ParsedIdentifier { + localPart: string + domain: string +} + +// https://github.com/nostr-protocol/nips/blob/master/05.md +// `names` is a map of local-part -> 64-char lowercase hex pubkey. +// `relays` is optional and unused server-side; passthrough() keeps unknown keys. +const nip05ResponseSchema = z.object({ + names: z.record(z.string(), pubkeySchema), +}).passthrough() + +export function parseNip05Identifier(nip05: string): Nip05ParsedIdentifier | undefined { + if (!nip05 || typeof nip05 !== 'string') { + return undefined + } + + const atIndex = nip05.lastIndexOf('@') + if (atIndex <= 0 || atIndex === nip05.length - 1) { + return undefined + } + + const localPart = nip05.substring(0, atIndex) + const domain = nip05.substring(atIndex + 1) + + if (!localPart || !domain || !DOMAIN_REGEX.test(domain)) { + return undefined + } + + return { + localPart: localPart.toLowerCase(), + domain: domain.toLowerCase(), + } +} + +export function extractNip05FromEvent(event: Event): string | undefined { + if (event.kind !== EventKinds.SET_METADATA) { + return undefined + } + + try { + const metadata = JSON.parse(event.content) + if (metadata && typeof metadata.nip05 === 'string' && metadata.nip05.length > 0) { + return metadata.nip05 + } + } catch { + debug('failed to parse metadata content for event %s', event.id) + } + + return undefined +} + +/** + * Reject redirect targets that would turn this endpoint into an SSRF primitive: + * non-https schemes, loopback, link-local, and RFC1918 private ranges. + * Domain-based checks (e.g. `localhost`) are included for belt-and-suspenders. + */ +function isRedirectTargetSafe(targetUrl: string): boolean { + let parsed: URL + try { + parsed = new URL(targetUrl) + } catch { + return false + } + + if (parsed.protocol !== 'https:') { + return false + } + + const host = parsed.hostname.toLowerCase() + if (host === 'localhost' || host === '0.0.0.0' || host.endsWith('.localhost')) { + return false + } + + // IPv4 literal check. Covers loopback (127/8), private ranges (10/8, 172.16/12, + // 192.168/16), link-local (169.254/16), and multicast/reserved (>=224). + const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (ipv4) { + const [a, b] = ipv4.slice(1, 3).map(Number) + if ( + a === 10 || + a === 127 || + a === 0 || + a >= 224 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ) { + return false + } + } + + // IPv6 literal: reject any bracketed v6 address; NIP-05 hosts are domain names. + if (host.startsWith('[') && host.endsWith(']')) { + return false + } + + return true +} + +export async function verifyNip05Identifier( + nip05: string, + pubkey: string, +): Promise { + const parsed = parseNip05Identifier(nip05) + if (!parsed) { + return { status: 'invalid', reason: 'unparseable NIP-05 identifier' } + } + + const { localPart, domain } = parsed + const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}` + + try { + debug('verifying %s for pubkey %s via %s', nip05, pubkey, url) + + const response = await axios.get(url, { + timeout: VERIFICATION_TIMEOUT_MS, + headers: { 'Accept': 'application/json' }, + responseType: 'json', + validateStatus: (status) => status === 200, + maxRedirects: MAX_REDIRECTS, + maxContentLength: MAX_RESPONSE_BYTES, + maxBodyLength: MAX_RESPONSE_BYTES, + // `beforeRedirect` is forwarded to follow-redirects by axios. Any throw + // here aborts the request with that error, which we catch below. + beforeRedirect: (options: { href?: string; protocol?: string; hostname?: string }) => { + const href = options.href + ?? `${options.protocol ?? ''}//${options.hostname ?? ''}` + if (!isRedirectTargetSafe(href)) { + throw new Error(`refused redirect to unsafe target: ${href}`) + } + }, + }) + + const parseResult = nip05ResponseSchema.safeParse(response.data) + + if (!parseResult.success) { + const zodError = parseResult as z.SafeParseError> + const reason = zodError.error.issues.map((i) => i.message).join('; ') + debug('malformed response from %s: %s', url, reason) + return { status: 'invalid', reason: `malformed response: ${reason}` } + } + + const registeredPubkey = parseResult.data.names[localPart] + if (typeof registeredPubkey !== 'string') { + debug('name %s not found in response from %s', localPart, domain) + return { status: 'mismatch' } + } + + if (registeredPubkey.toLowerCase() !== pubkey.toLowerCase()) { + debug('pubkey mismatch for %s (got %s)', nip05, registeredPubkey) + return { status: 'mismatch' } + } + + debug('verification succeeded for %s', nip05) + return { status: 'verified' } + } catch (error: unknown) { + const axiosError = error as AxiosError + const message = axiosError?.message ?? (error instanceof Error ? error.message : String(error)) + debug('verification request failed for %s: %s', nip05, message) + return { status: 'error', reason: message } + } +} + +export function isDomainAllowed( + domain: string, + whitelist?: string[], + blacklist?: string[], +): boolean { + const lowerDomain = domain.toLowerCase() + + if (Array.isArray(blacklist) && blacklist.length > 0) { + if (blacklist.some((d) => lowerDomain === d.toLowerCase())) { + return false + } + } + + if (Array.isArray(whitelist) && whitelist.length > 0) { + if (!whitelist.some((d) => lowerDomain === d.toLowerCase())) { + return false + } + } + + return true +} diff --git a/test/unit/app/maintenance-worker.spec.ts b/test/unit/app/maintenance-worker.spec.ts index a901fe0e..a01fbe5e 100644 --- a/test/unit/app/maintenance-worker.spec.ts +++ b/test/unit/app/maintenance-worker.spec.ts @@ -1,45 +1,79 @@ -import * as chai from 'chai' -import * as sinon from 'sinon' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' import sinonChai from 'sinon-chai' chai.use(sinonChai) +chai.use(chaiAsPromised) -const { expect } = chai - +import { applyReverificationOutcome, MaintenanceWorker } from '../../../src/app/maintenance-worker' import { IMaintenanceService, IPaymentsService } from '../../../src/@types/services' -import { MaintenanceWorker } from '../../../src/app/maintenance-worker' +import { Nip05Verification } from '../../../src/@types/nip05' import { Settings } from '../../../src/@types/settings' +import * as nip05Utils from '../../../src/utils/nip05' + +const { expect } = chai + describe('MaintenanceWorker', () => { + let sandbox: Sinon.SinonSandbox let worker: MaintenanceWorker - let sandbox: sinon.SinonSandbox - let paymentsService: sinon.SinonStubbedInstance - let maintenanceService: sinon.SinonStubbedInstance - let settings: sinon.SinonStub - let processMock: any + let nip05VerificationRepository: any + let verifyStub: Sinon.SinonStub + let settings: Settings + let mockProcess: any + let paymentsService: Sinon.SinonStubbedInstance + let maintenanceService: Sinon.SinonStubbedInstance beforeEach(() => { - sandbox = sinon.createSandbox() + sandbox = Sinon.createSandbox() + + nip05VerificationRepository = { + findByPubkey: sandbox.stub(), + upsert: sandbox.stub().resolves(1), + deleteByPubkey: sandbox.stub(), + findPendingVerifications: sandbox.stub().resolves([]), + } + + verifyStub = sandbox.stub(nip05Utils, 'verifyNip05Identifier') + + settings = { + info: { + relay_url: 'relay_url', + }, + nip05: { + mode: 'enabled', + verifyExpiration: 604800000, + verifyUpdateFrequency: 86400000, + maxConsecutiveFailures: 20, + domainWhitelist: [], + domainBlacklist: [], + }, + } as any + + mockProcess = { + on: sandbox.stub().returnsThis(), + exit: sandbox.stub(), + } + paymentsService = { - getPendingInvoices: sandbox.stub(), + getPendingInvoices: sandbox.stub().resolves([]), getInvoiceFromPaymentsProcessor: sandbox.stub(), updateInvoiceStatus: sandbox.stub(), confirmInvoice: sandbox.stub(), sendInvoiceUpdateNotification: sandbox.stub(), } as any + maintenanceService = { - clearOldEvents: sandbox.stub(), + clearOldEvents: sandbox.stub().resolves(), } as any - settings = sandbox.stub() - processMock = { - on: sandbox.stub().returnsThis(), - } worker = new MaintenanceWorker( - processMock as any, + mockProcess, paymentsService as any, maintenanceService as any, - settings as any, + () => settings, + nip05VerificationRepository, ) }) @@ -47,16 +81,194 @@ describe('MaintenanceWorker', () => { sandbox.restore() }) + const verification = (overrides: Partial = {}): Nip05Verification => ({ + pubkey: 'a'.repeat(64), + nip05: 'alice@example.com', + domain: 'example.com', + isVerified: true, + lastVerifiedAt: new Date(Date.now() - 100000000), + lastCheckedAt: new Date(Date.now() - 100000000), + failureCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) + + describe('applyReverificationOutcome', () => { + it('marks verified on a successful outcome and resets failureCount', () => { + const existing = verification({ isVerified: false, lastVerifiedAt: null, failureCount: 5 }) + + const updated = applyReverificationOutcome(existing, { status: 'verified' }) + + expect(updated.isVerified).to.be.true + expect(updated.lastVerifiedAt).to.be.an.instanceOf(Date) + expect(updated.failureCount).to.equal(0) + expect(updated.lastCheckedAt).to.be.an.instanceOf(Date) + }) + + it('flips to unverified and nulls lastVerifiedAt on definitive mismatch', () => { + const existing = verification({ failureCount: 2 }) + + const updated = applyReverificationOutcome(existing, { status: 'mismatch' }) + + expect(updated.isVerified).to.be.false + expect(updated.lastVerifiedAt).to.be.null + expect(updated.failureCount).to.equal(3) + }) + + it('flips to unverified and nulls lastVerifiedAt on malformed response', () => { + const existing = verification() + + const updated = applyReverificationOutcome(existing, { status: 'invalid', reason: 'bad json' }) + + expect(updated.isVerified).to.be.false + expect(updated.lastVerifiedAt).to.be.null + }) + + it('preserves lastVerifiedAt/isVerified on transient errors', () => { + const lastVerified = new Date(Date.now() - 10000) + const existing = verification({ lastVerifiedAt: lastVerified, failureCount: 1 }) + + const updated = applyReverificationOutcome(existing, { status: 'error', reason: 'ETIMEDOUT' }) + + expect(updated.isVerified).to.equal(existing.isVerified) + expect(updated.lastVerifiedAt).to.equal(lastVerified) + expect(updated.failureCount).to.equal(2) + expect(updated.lastCheckedAt).to.be.an.instanceOf(Date) + }) + }) + + describe('processNip05Reverifications', () => { + it('returns early when nip05 settings are undefined', async () => { + (settings as any).nip05 = undefined + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.findPendingVerifications).not.to.have.been.called + }) + + it('returns early when mode is disabled', async () => { + (settings as any).nip05.mode = 'disabled' + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.findPendingVerifications).not.to.have.been.called + }) + + it('does nothing when no pending verifications', async () => { + nip05VerificationRepository.findPendingVerifications.resolves([]) + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.findPendingVerifications).to.have.been.calledOnceWithExactly( + 86400000, + 20, + 50, + ) + expect(verifyStub).not.to.have.been.called + }) + + it('re-verifies and updates successful verifications', async () => { + const row = verification() + nip05VerificationRepository.findPendingVerifications.resolves([row]) + verifyStub.resolves({ status: 'verified' }) + + await (worker as any).processNip05Reverifications(settings) + + expect(verifyStub).to.have.been.calledOnceWithExactly('alice@example.com', 'a'.repeat(64)) + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.isVerified).to.be.true + expect(upsertArg.failureCount).to.equal(0) + expect(upsertArg.lastVerifiedAt).to.be.an.instanceOf(Date) + }) + + it('increments failure count and nulls lastVerifiedAt on definitive mismatch', async () => { + const row = verification({ pubkey: 'b'.repeat(64), nip05: 'bob@example.com', failureCount: 3 }) + nip05VerificationRepository.findPendingVerifications.resolves([row]) + verifyStub.resolves({ status: 'mismatch' }) + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.isVerified).to.be.false + expect(upsertArg.failureCount).to.equal(4) + expect(upsertArg.lastVerifiedAt).to.be.null + }) + + it('preserves prior verification on transient network errors', async () => { + const lastVerifiedAt = new Date(Date.now() - 10000) + const row = verification({ pubkey: 'c'.repeat(64), nip05: 'carol@example.com', failureCount: 1, lastVerifiedAt }) + nip05VerificationRepository.findPendingVerifications.resolves([row]) + verifyStub.resolves({ status: 'error', reason: 'ETIMEDOUT' }) + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.isVerified).to.be.true + expect(upsertArg.lastVerifiedAt).to.equal(lastVerifiedAt) + expect(upsertArg.failureCount).to.equal(2) + }) + + it('handles individual verification errors gracefully', async () => { + const v1 = verification({ pubkey: 'a'.repeat(64) }) + const v2 = verification({ pubkey: 'b'.repeat(64), nip05: 'bob@example.com' }) + nip05VerificationRepository.findPendingVerifications.resolves([v1, v2]) + verifyStub.onFirstCall().rejects(new Error('network error')) + verifyStub.onSecondCall().resolves({ status: 'verified' }) + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.pubkey).to.equal('b'.repeat(64)) + }) + + it('uses configured updateFrequency and maxFailures', async () => { + (settings as any).nip05.verifyUpdateFrequency = 3600000 + ;(settings as any).nip05.maxConsecutiveFailures = 5 + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.findPendingVerifications).to.have.been.calledOnceWithExactly( + 3600000, + 5, + 50, + ) + }) + + it('uses defaults when settings values are undefined', async () => { + (settings as any).nip05.verifyUpdateFrequency = undefined + ;(settings as any).nip05.maxConsecutiveFailures = undefined + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.findPendingVerifications).to.have.been.calledOnceWithExactly( + 86400000, + 20, + 50, + ) + }) + + it('processes in passive mode', async () => { + (settings as any).nip05.mode = 'passive' + const row = verification({ pubkey: 'c'.repeat(64), nip05: 'charlie@example.com' }) + nip05VerificationRepository.findPendingVerifications.resolves([row]) + verifyStub.resolves({ status: 'verified' }) + + await (worker as any).processNip05Reverifications(settings) + + expect(verifyStub).to.have.been.calledOnce + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + }) + }) + describe('onSchedule', () => { it('calls maintenance service and processes invoices', async () => { - const currentSettings: Settings = { - info: {} as any, - network: {} as any, - payments: { - enabled: true, - } as any, - } - settings.returns(currentSettings) + (settings as any).payments = { enabled: true } maintenanceService.clearOldEvents.resolves() paymentsService.getPendingInvoices.resolves([]) @@ -67,14 +279,7 @@ describe('MaintenanceWorker', () => { }) it('calls maintenance service even if payments are disabled', async () => { - const currentSettings: Settings = { - info: {} as any, - network: {} as any, - payments: { - enabled: false, - } as any, - } - settings.returns(currentSettings) + (settings as any).payments = { enabled: false } maintenanceService.clearOldEvents.resolves() await (worker as any).onSchedule() diff --git a/test/unit/factories/message-handler-factory.spec.ts b/test/unit/factories/message-handler-factory.spec.ts index 4c5299ff..ea19038f 100644 --- a/test/unit/factories/message-handler-factory.spec.ts +++ b/test/unit/factories/message-handler-factory.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai' -import { IEventRepository, IUserRepository } from '../../../src/@types/repositories' +import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories' import { IncomingMessage, MessageType } from '../../../src/@types/messages' import { Event } from '../../../src/@types/event' import { EventMessageHandler } from '../../../src/handlers/event-message-handler' @@ -13,6 +13,7 @@ describe('messageHandlerFactory', () => { let event: Event let eventRepository: IEventRepository let userRepository: IUserRepository + let nip05VerificationRepository: INip05VerificationRepository let message: IncomingMessage let adapter: IWebSocketAdapter let factory @@ -20,11 +21,12 @@ describe('messageHandlerFactory', () => { beforeEach(() => { eventRepository = {} as any userRepository = {} as any + nip05VerificationRepository = {} as any adapter = {} as any event = { tags: [], } as any - factory = messageHandlerFactory(eventRepository, userRepository) + factory = messageHandlerFactory(eventRepository, userRepository, nip05VerificationRepository) }) it('returns EventMessageHandler when given an EVENT message', () => { diff --git a/test/unit/factories/websocket-adapter-factory.spec.ts b/test/unit/factories/websocket-adapter-factory.spec.ts index 1a5002dc..35b38f90 100644 --- a/test/unit/factories/websocket-adapter-factory.spec.ts +++ b/test/unit/factories/websocket-adapter-factory.spec.ts @@ -3,7 +3,7 @@ import { IncomingMessage } from 'http' import Sinon from 'sinon' import WebSocket from 'ws' -import { IEventRepository, IUserRepository } from '../../../src/@types/repositories' +import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories' import { IWebSocketServerAdapter } from '../../../src/@types/adapters' import { SettingsStatic } from '../../../src/utils/settings' import { WebSocketAdapter } from '../../../src/adapters/web-socket-adapter' @@ -31,6 +31,7 @@ describe('webSocketAdapterFactory', () => { }) const eventRepository: IEventRepository = {} as any const userRepository: IUserRepository = {} as any + const nip05VerificationRepository: INip05VerificationRepository = {} as any const client: WebSocket = { on: onStub, @@ -46,8 +47,11 @@ describe('webSocketAdapterFactory', () => { } as any const webSocketServerAdapter: IWebSocketServerAdapter = {} as any + const factory = webSocketAdapterFactory( + eventRepository, userRepository, nip05VerificationRepository, + ) expect( - webSocketAdapterFactory(eventRepository, userRepository)([client, request, webSocketServerAdapter]) + factory([client, request, webSocketServerAdapter]) ).to.be.an.instanceOf(WebSocketAdapter) }) }) diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 30995f80..22fead86 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -18,6 +18,8 @@ import { IUserRepository } from '../../../src/@types/repositories' import { IWebSocketAdapter } from '../../../src/@types/adapters' import { WebSocketAdapterEvent } from '../../../src/constants/adapter' +import * as nip05Utils from '../../../src/utils/nip05' + const { expect } = chai describe('EventMessageHandler', () => { @@ -89,7 +91,8 @@ describe('EventMessageHandler', () => { () => ({ info: { relay_url: 'relay_url' }, }) as any, - () => ({ hit: async () => false }) + () => ({ hit: async () => false }), + {} as any, ) }) @@ -263,7 +266,8 @@ describe('EventMessageHandler', () => { { hasActiveRequestToVanish: async () => false } as any, userRepository, () => settings, - () => ({ hit: async () => false }) + () => ({ hit: async () => false }), + {} as any, ) }) @@ -789,7 +793,8 @@ describe('EventMessageHandler', () => { { hasActiveRequestToVanish: async () => false } as any, userRepository, () => settings, - () => ({ hit: rateLimiterHitStub }) + () => ({ hit: rateLimiterHitStub }), + {} as any, ) }) @@ -1057,7 +1062,8 @@ describe('EventMessageHandler', () => { { hasActiveRequestToVanish: async () => false } as any, userRepository, () => settings, - () => ({ hit: async () => false }) + () => ({ hit: async () => false }), + {} as any, ) }) @@ -1160,4 +1166,403 @@ describe('EventMessageHandler', () => { return expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined }) }) + + describe('checkNip05Verification', () => { + let settings: Settings + let nip05VerificationRepository: any + let getRelayPublicKeyStub: Sinon.SinonStub + + beforeEach(() => { + settings = { + info: { + relay_url: 'relay_url', + }, + nip05: { + mode: 'enabled', + verifyExpiration: 86400000, + verifyUpdateFrequency: 3600000, + maxConsecutiveFailures: 10, + domainWhitelist: [], + domainBlacklist: [], + }, + } as any + event = { + content: 'hello', + created_at: 1665546189, + id: 'f'.repeat(64), + kind: 1, + pubkey: 'f'.repeat(64), + sig: 'f'.repeat(128), + tags: [], + } + nip05VerificationRepository = { + findByPubkey: sandbox.stub(), + upsert: sandbox.stub(), + deleteByPubkey: sandbox.stub(), + findPendingVerifications: sandbox.stub(), + } + getRelayPublicKeyStub = sandbox.stub(EventMessageHandler.prototype, 'getRelayPublicKey' as any) + handler = new EventMessageHandler( + {} as any, + () => null, + { hasActiveRequestToVanish: async () => false } as any, + userRepository, + () => settings, + () => ({ hit: async () => false }), + nip05VerificationRepository, + ) + }) + + it('returns undefined if nip05 settings are not set', async () => { + settings.nip05 = undefined + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns undefined if nip05 mode is disabled', async () => { + settings.nip05.mode = 'disabled' + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns undefined if nip05 mode is passive', async () => { + settings.nip05.mode = 'passive' + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns undefined for kind 0 events (SET_METADATA)', async () => { + event.kind = 0 + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns undefined if event pubkey equals relay public key', async () => { + getRelayPublicKeyStub.returns(event.pubkey) + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns reason if no verification found for pubkey', async () => { + nip05VerificationRepository.findByPubkey.resolves(undefined) + + return expect((handler as any).checkNip05Verification(event)) + .to.eventually.equal('blocked: NIP-05 verification required') + }) + + it('returns reason if verification exists but has no lastVerifiedAt', async () => { + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: false, + lastVerifiedAt: null, + domain: 'example.com', + }) + + return expect((handler as any).checkNip05Verification(event)) + .to.eventually.equal('blocked: NIP-05 verification required') + }) + + it('treats isVerified=true with null lastVerifiedAt as unverified (historical/bad data)', async () => { + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: true, + lastVerifiedAt: null, + domain: 'example.com', + }) + + return expect((handler as any).checkNip05Verification(event)) + .to.eventually.equal('blocked: NIP-05 verification required') + }) + + it('returns reason if verification is expired', async () => { + const expired = new Date(Date.now() - 86400001) + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: true, + lastVerifiedAt: expired, + domain: 'example.com', + }) + + return expect((handler as any).checkNip05Verification(event)) + .to.eventually.equal('blocked: NIP-05 verification expired') + }) + + it('returns undefined if verification is valid and not expired', async () => { + const recent = new Date(Date.now() - 1000) + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: true, + lastVerifiedAt: recent, + domain: 'example.com', + }) + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('allows author when lastVerifiedAt is recent even if isVerified is false (transient re-check failure)', async () => { + const recent = new Date(Date.now() - 1000) + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: false, + lastVerifiedAt: recent, + domain: 'example.com', + }) + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns reason if domain is blacklisted', async () => { + settings.nip05.domainBlacklist = ['spam.com'] + const recent = new Date(Date.now() - 1000) + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: true, + lastVerifiedAt: recent, + domain: 'spam.com', + }) + + return expect((handler as any).checkNip05Verification(event)) + .to.eventually.equal('blocked: NIP-05 domain not allowed') + }) + + it('returns reason if domain is not in whitelist', async () => { + settings.nip05.domainWhitelist = ['allowed.com'] + const recent = new Date(Date.now() - 1000) + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: true, + lastVerifiedAt: recent, + domain: 'other.com', + }) + + return expect((handler as any).checkNip05Verification(event)) + .to.eventually.equal('blocked: NIP-05 domain not allowed') + }) + + it('returns undefined if domain is in whitelist', async () => { + settings.nip05.domainWhitelist = ['allowed.com'] + const recent = new Date(Date.now() - 1000) + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: true, + lastVerifiedAt: recent, + domain: 'allowed.com', + }) + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + }) + + describe('processNip05Metadata', () => { + let settings: Settings + let nip05VerificationRepository: any + let verifyStub: Sinon.SinonStub + + beforeEach(() => { + settings = { + info: { + relay_url: 'relay_url', + }, + nip05: { + mode: 'enabled', + verifyExpiration: 86400000, + verifyUpdateFrequency: 3600000, + maxConsecutiveFailures: 10, + domainWhitelist: [], + domainBlacklist: [], + }, + } as any + nip05VerificationRepository = { + findByPubkey: sandbox.stub(), + upsert: sandbox.stub().resolves(1), + deleteByPubkey: sandbox.stub().resolves(1), + findPendingVerifications: sandbox.stub(), + } + verifyStub = sandbox.stub(nip05Utils, 'verifyNip05Identifier') + handler = new EventMessageHandler( + {} as any, + () => null, + { hasActiveRequestToVanish: async () => false } as any, + userRepository, + () => settings, + () => ({ hit: async () => false }), + nip05VerificationRepository, + ) + }) + + it('does nothing when nip05 settings are undefined', async () => { + settings.nip05 = undefined + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).not.to.have.been.called + }) + + it('does nothing when nip05 mode is disabled', async () => { + settings.nip05.mode = 'disabled' + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).not.to.have.been.called + }) + + it('does nothing for non-kind-0 events', async () => { + event.kind = EventKinds.TEXT_NOTE + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).not.to.have.been.called + }) + + it('deletes verification when kind-0 has no nip05 in content', async () => { + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ name: 'alice' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(nip05VerificationRepository.deleteByPubkey).to.have.been.calledOnceWithExactly(event.pubkey) + expect(verifyStub).not.to.have.been.called + }) + + it('does nothing when nip05 identifier is unparseable', async () => { + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'invalid-no-at-sign' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).not.to.have.been.called + expect(nip05VerificationRepository.deleteByPubkey).not.to.have.been.called + }) + + it('does nothing when domain is not allowed', async () => { + settings.nip05.domainBlacklist = ['blocked.com'] + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@blocked.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).not.to.have.been.called + }) + + it('verifies and upserts on successful verification', async () => { + nip05VerificationRepository.findByPubkey.resolves(undefined) + verifyStub.resolves({ status: 'verified' }) + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).to.have.been.calledOnceWithExactly('alice@example.com', event.pubkey) + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.pubkey).to.equal(event.pubkey) + expect(upsertArg.nip05).to.equal('alice@example.com') + expect(upsertArg.domain).to.equal('example.com') + expect(upsertArg.isVerified).to.be.true + expect(upsertArg.failureCount).to.equal(0) + expect(upsertArg.lastVerifiedAt).to.be.an.instanceOf(Date) + }) + + it('upserts with unverified state and nulls lastVerifiedAt on definitive mismatch', async () => { + nip05VerificationRepository.findByPubkey.resolves(undefined) + verifyStub.resolves({ status: 'mismatch' }) + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).to.have.been.calledOnce + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.isVerified).to.be.false + expect(upsertArg.failureCount).to.equal(1) + expect(upsertArg.lastVerifiedAt).to.be.null + }) + + it('increments failureCount from existing row on definitive mismatch', async () => { + const priorVerifiedAt = new Date(Date.now() - 1000) + nip05VerificationRepository.findByPubkey.resolves({ + pubkey: event.pubkey, + nip05: 'alice@example.com', + domain: 'example.com', + isVerified: true, + lastVerifiedAt: priorVerifiedAt, + lastCheckedAt: priorVerifiedAt, + failureCount: 2, + createdAt: priorVerifiedAt, + updatedAt: priorVerifiedAt, + }) + verifyStub.resolves({ status: 'mismatch' }) + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.failureCount).to.equal(3) + expect(upsertArg.isVerified).to.be.false + expect(upsertArg.lastVerifiedAt).to.be.null + }) + + it('preserves prior isVerified/lastVerifiedAt on transient error', async () => { + const priorVerifiedAt = new Date(Date.now() - 1000) + nip05VerificationRepository.findByPubkey.resolves({ + pubkey: event.pubkey, + nip05: 'alice@example.com', + domain: 'example.com', + isVerified: true, + lastVerifiedAt: priorVerifiedAt, + lastCheckedAt: priorVerifiedAt, + failureCount: 1, + createdAt: priorVerifiedAt, + updatedAt: priorVerifiedAt, + }) + verifyStub.resolves({ status: 'error', reason: 'ETIMEDOUT' }) + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.isVerified).to.be.true + expect(upsertArg.lastVerifiedAt).to.equal(priorVerifiedAt) + expect(upsertArg.failureCount).to.equal(2) + expect(upsertArg.lastCheckedAt).to.be.an.instanceOf(Date) + }) + + it('handles verification errors gracefully (thrown by verifier)', async () => { + nip05VerificationRepository.findByPubkey.resolves(undefined) + verifyStub.rejects(new Error('network error')) + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(nip05VerificationRepository.upsert).not.to.have.been.called + }) + + it('works correctly in passive mode', async () => { + settings.nip05.mode = 'passive' + nip05VerificationRepository.findByPubkey.resolves(undefined) + verifyStub.resolves({ status: 'verified' }) + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).to.have.been.calledOnce + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + }) + }) }) diff --git a/test/unit/utils/nip05.spec.ts b/test/unit/utils/nip05.spec.ts new file mode 100644 index 00000000..67ff3fa5 --- /dev/null +++ b/test/unit/utils/nip05.spec.ts @@ -0,0 +1,330 @@ +import axios from 'axios' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' + +chai.use(chaiAsPromised) + +import { + extractNip05FromEvent, + isDomainAllowed, + parseNip05Identifier, + verifyNip05Identifier, +} from '../../../src/utils/nip05' +import { Event } from '../../../src/@types/event' +import { EventKinds } from '../../../src/constants/base' + +const { expect } = chai + +describe('NIP-05 utils', () => { + describe('parseNip05Identifier', () => { + it('returns parsed identifier for valid input', () => { + const result = parseNip05Identifier('user@example.com') + expect(result).to.deep.equal({ localPart: 'user', domain: 'example.com' }) + }) + + it('handles underscores in local part', () => { + const result = parseNip05Identifier('_@example.com') + expect(result).to.deep.equal({ localPart: '_', domain: 'example.com' }) + }) + + it('lowercases domain and local part', () => { + const result = parseNip05Identifier('User@Example.COM') + expect(result).to.deep.equal({ localPart: 'user', domain: 'example.com' }) + }) + + it('handles subdomains', () => { + const result = parseNip05Identifier('alice@relay.example.co.uk') + expect(result).to.deep.equal({ localPart: 'alice', domain: 'relay.example.co.uk' }) + }) + + it('returns undefined for empty string', () => { + expect(parseNip05Identifier('')).to.be.undefined + }) + + it('returns undefined for null input', () => { + expect(parseNip05Identifier(null as any)).to.be.undefined + }) + + it('returns undefined for non-string input', () => { + expect(parseNip05Identifier(123 as any)).to.be.undefined + }) + + it('returns undefined for missing @', () => { + expect(parseNip05Identifier('userexample.com')).to.be.undefined + }) + + it('returns undefined for missing local part', () => { + expect(parseNip05Identifier('@example.com')).to.be.undefined + }) + + it('returns undefined for missing domain', () => { + expect(parseNip05Identifier('user@')).to.be.undefined + }) + + it('returns undefined for invalid domain', () => { + expect(parseNip05Identifier('user@.com')).to.be.undefined + }) + + it('returns undefined for domain without TLD', () => { + expect(parseNip05Identifier('user@localhost')).to.be.undefined + }) + }) + + describe('extractNip05FromEvent', () => { + it('extracts nip05 from kind 0 event', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.SET_METADATA, + tags: [], + content: JSON.stringify({ name: 'alice', nip05: 'alice@example.com' }), + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.equal('alice@example.com') + }) + + it('returns undefined for non-kind-0 event', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.TEXT_NOTE, + tags: [], + content: JSON.stringify({ nip05: 'alice@example.com' }), + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.be.undefined + }) + + it('returns undefined when nip05 is not in content', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.SET_METADATA, + tags: [], + content: JSON.stringify({ name: 'alice' }), + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.be.undefined + }) + + it('returns undefined for invalid JSON content', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.SET_METADATA, + tags: [], + content: 'not json', + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.be.undefined + }) + + it('returns undefined when nip05 is empty string', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.SET_METADATA, + tags: [], + content: JSON.stringify({ nip05: '' }), + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.be.undefined + }) + + it('returns undefined when nip05 is not a string', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.SET_METADATA, + tags: [], + content: JSON.stringify({ nip05: 42 }), + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.be.undefined + }) + }) + + describe('isDomainAllowed', () => { + it('returns true with no whitelist or blacklist', () => { + expect(isDomainAllowed('example.com')).to.be.true + }) + + it('returns true with empty whitelist and blacklist', () => { + expect(isDomainAllowed('example.com', [], [])).to.be.true + }) + + it('returns true if domain is in whitelist', () => { + expect(isDomainAllowed('example.com', ['example.com'])).to.be.true + }) + + it('returns false if domain is not in whitelist', () => { + expect(isDomainAllowed('other.com', ['example.com'])).to.be.false + }) + + it('returns false if domain is in blacklist', () => { + expect(isDomainAllowed('spam.com', undefined, ['spam.com'])).to.be.false + }) + + it('returns true if domain is not in blacklist', () => { + expect(isDomainAllowed('example.com', undefined, ['spam.com'])).to.be.true + }) + + it('is case-insensitive', () => { + expect(isDomainAllowed('Example.COM', ['example.com'])).to.be.true + expect(isDomainAllowed('SPAM.com', undefined, ['spam.COM'])).to.be.false + }) + + it('blacklist takes precedence over whitelist', () => { + expect(isDomainAllowed('example.com', ['example.com'], ['example.com'])).to.be.false + }) + }) + + describe('verifyNip05Identifier', () => { + let axiosGetStub: Sinon.SinonStub + const pubkey = 'a'.repeat(64) + + beforeEach(() => { + axiosGetStub = Sinon.stub(axios, 'get') + }) + + afterEach(() => { + axiosGetStub.restore() + }) + + it('returns invalid for unparseable identifier (no network call)', async () => { + const outcome = await verifyNip05Identifier('not-an-identifier', pubkey) + expect(outcome).to.deep.equal({ status: 'invalid', reason: 'unparseable NIP-05 identifier' }) + expect(axiosGetStub).not.to.have.been.called + }) + + it('sends request with capped redirects and body sizes', async () => { + axiosGetStub.resolves({ data: { names: { alice: pubkey } } }) + + await verifyNip05Identifier('alice@example.com', pubkey) + + const config = axiosGetStub.firstCall.args[1] + expect(config.maxRedirects).to.equal(1) + expect(config.maxContentLength).to.be.a('number').and.to.be.at.most(64 * 1024) + expect(config.maxBodyLength).to.be.a('number').and.to.be.at.most(64 * 1024) + expect(config.validateStatus(200)).to.be.true + expect(config.validateStatus(301)).to.be.false + expect(typeof config.beforeRedirect).to.equal('function') + }) + + it('returns verified when response pubkey matches', async () => { + axiosGetStub.resolves({ data: { names: { alice: pubkey } } }) + + const outcome = await verifyNip05Identifier('alice@example.com', pubkey) + + expect(outcome).to.deep.equal({ status: 'verified' }) + }) + + it('returns mismatch when name is not present in response', async () => { + axiosGetStub.resolves({ data: { names: { bob: pubkey } } }) + + const outcome = await verifyNip05Identifier('alice@example.com', pubkey) + + expect(outcome).to.deep.equal({ status: 'mismatch' }) + }) + + it('returns mismatch when pubkey does not match', async () => { + axiosGetStub.resolves({ data: { names: { alice: 'b'.repeat(64) } } }) + + const outcome = await verifyNip05Identifier('alice@example.com', pubkey) + + expect(outcome).to.deep.equal({ status: 'mismatch' }) + }) + + it('returns invalid when response is not a JSON object', async () => { + axiosGetStub.resolves({ data: 'not-json' }) + + const outcome = await verifyNip05Identifier('alice@example.com', pubkey) + + expect(outcome.status).to.equal('invalid') + }) + + it('returns invalid when names is not an object', async () => { + axiosGetStub.resolves({ data: { names: 'oops' } }) + + const outcome = await verifyNip05Identifier('alice@example.com', pubkey) + + expect(outcome.status).to.equal('invalid') + }) + + it('returns invalid when a pubkey in names is not 64-char hex', async () => { + axiosGetStub.resolves({ data: { names: { alice: 'not-hex' } } }) + + const outcome = await verifyNip05Identifier('alice@example.com', pubkey) + + expect(outcome.status).to.equal('invalid') + }) + + it('returns error on network/timeout failure', async () => { + axiosGetStub.rejects(new Error('ETIMEDOUT')) + + const outcome = await verifyNip05Identifier('alice@example.com', pubkey) + + expect(outcome.status).to.equal('error') + if (outcome.status === 'error') { + expect(outcome.reason).to.equal('ETIMEDOUT') + } + }) + + describe('beforeRedirect SSRF guard', () => { + let guard: (options: { href?: string; protocol?: string; hostname?: string }) => void + + beforeEach(async () => { + axiosGetStub.resolves({ data: { names: { alice: pubkey } } }) + await verifyNip05Identifier('alice@example.com', pubkey) + guard = axiosGetStub.firstCall.args[1].beforeRedirect + }) + + const allows = (href: string) => { + expect(() => guard({ href })).not.to.throw() + } + const rejects = (href: string) => { + expect(() => guard({ href })).to.throw(/refused redirect/) + } + + it('allows https redirects to public hostnames', () => { + allows('https://other.example.com/.well-known/nostr.json?name=alice') + }) + + it('rejects http redirects', () => { + rejects('http://example.com/.well-known/nostr.json') + }) + + it('rejects redirects to loopback literal', () => { + rejects('https://127.0.0.1/') + rejects('https://127.99.99.99/') + }) + + it('rejects redirects to RFC1918 private ranges', () => { + rejects('https://10.0.0.1/') + rejects('https://192.168.1.1/') + rejects('https://172.16.0.1/') + rejects('https://172.31.255.254/') + }) + + it('rejects redirects to link-local addresses', () => { + rejects('https://169.254.169.254/latest/meta-data/') + }) + + it('rejects redirects to localhost hostname', () => { + rejects('https://localhost/') + rejects('https://foo.localhost/') + }) + + it('rejects redirects to IPv6 literals', () => { + rejects('https://[::1]/') + }) + }) + }) +})