diff --git a/.changeset/nip98-admin-middleware.md b/.changeset/nip98-admin-middleware.md new file mode 100644 index 00000000..edd6a1d3 --- /dev/null +++ b/.changeset/nip98-admin-middleware.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(admin): accept NIP-98 Authorization on protected admin API routes diff --git a/.knip.json b/.knip.json index 7129b278..df375161 100644 --- a/.knip.json +++ b/.knip.json @@ -5,7 +5,6 @@ "src/import-events.ts!", "src/cli/index.ts!", "src/scripts/benchmark-queries.ts!", - "src/utils/nip98.ts!", "knexfile.js!" ], "project": [ diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 4ffe7209..5733dee1 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -134,6 +134,9 @@ The settings below are listed in alphabetical order by name. Please keep this ta | Name | Description | |---------------------------------------------|-------------------------------------------------------------------------------| +| admin.nip98.allowedPubkeys | Hex pubkeys allowed to use NIP-98 on the admin API. Empty means nobody (fail-closed). Defaults to []. | +| admin.nip98.enabled | Accept `Authorization: Nostr` (NIP-98) on protected admin API routes alongside session auth. Defaults to false. Clients must sign `u` as `https:///admin/...` (scheme/host from `info.relay_url`, not the request Host). Successful auth events are one-time within `maxSkewSeconds` (Redis). | +| admin.nip98.maxSkewSeconds | Max skew in seconds between now and the auth event `created_at`. Also the Redis TTL for one-time auth event ids. Defaults to 60. | | dvm.workers[].args | Arguments passed to the spawned command. Optional. | | dvm.workers[].command | Command to spawn for this DVM worker (e.g. an interpreter or executable path). | | dvm.workers[].kinds | NIP-90 job request kinds (5000-5999) this worker accepts. Optional. | diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 7e04e9ba..b711c53e 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -280,3 +280,7 @@ limits: admin: enabled: false sessionTtlSeconds: 86400 + nip98: + enabled: false + allowedPubkeys: [] + maxSkewSeconds: 60 diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 8d503b43..38f8852c 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -1,5 +1,5 @@ -import { Pubkey, Secret } from './base' import { EventKinds } from '../constants/base' +import { Pubkey, Secret } from './base' import { MessageType } from './messages' import { SubscriptionFilter } from './subscription' @@ -331,10 +331,20 @@ export interface Nip05Settings { domainBlacklist?: string[] } +export interface AdminNip98Settings { + /** Accept NIP-98 Authorization headers on admin API routes. Defaults to false. */ + enabled: boolean + /** Hex pubkeys allowed to authenticate via NIP-98. Fail-closed when empty. */ + allowedPubkeys?: Pubkey[] + /** Max |now - created_at| in seconds. Defaults to 60. */ + maxSkewSeconds?: number +} + export interface AdminSettings { enabled: boolean passwordHash?: string sessionTtlSeconds?: number + nip98?: AdminNip98Settings } export interface WoTSettings { enabled: boolean diff --git a/src/handlers/request-handlers/admin-auth-middleware.ts b/src/handlers/request-handlers/admin-auth-middleware.ts index 99c4bd64..2081e820 100644 --- a/src/handlers/request-handlers/admin-auth-middleware.ts +++ b/src/handlers/request-handlers/admin-auth-middleware.ts @@ -1,19 +1,193 @@ -import { NextFunction, Request, Response } from 'express' +import { NextFunction, Response } from 'express' +import { IAdminAuthProvider } from '../../@types/admin' import { createAdminAuthProvider } from '../../factories/admin-auth-provider-factory' +import { createLogger } from '../../factories/logger-factory' +import { createSettings } from '../../factories/settings-factory' +import { getAbsoluteHttpRequestUrl } from '../../utils/http' +import { + DEFAULT_NIP98_MAX_AUTHORIZATION_HEADER_LENGTH, + DEFAULT_NIP98_MAX_SKEW_SECONDS, + verifyNip98Auth, +} from '../../utils/nip98' +import { claimNip98AuthEventId } from '../../utils/nip98-replay' +import { AdminRequest } from './admin-json-body-middleware' -const adminAuthProvider = createAdminAuthProvider() +const logger = createLogger('admin-auth-middleware') -export const adminAuthMiddleware = (request: Request, response: Response, next: NextFunction) => { +const adminAuthProvider: IAdminAuthProvider = createAdminAuthProvider() + +const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) + +export const isNostrAuthorizationHeader = (authorizationHeader: string | undefined): boolean => { + if (typeof authorizationHeader !== 'string') { + return false + } + + return /^Nostr\s+/i.test(authorizationHeader.trim()) +} + +const isAllowedNip98Pubkey = (pubkey: string, allowedPubkeys: string[] | undefined): boolean => { + if (!Array.isArray(allowedPubkeys) || allowedPubkeys.length === 0) { + return false + } + + const normalized = pubkey.toLowerCase() + return allowedPubkeys.some((allowed) => typeof allowed === 'string' && allowed.toLowerCase() === normalized) +} + +const resolveBodyForNip98 = (request: AdminRequest): Buffer | undefined | 'missing-raw-body' => { + if (request.rawBody !== undefined) { + return request.rawBody + } + + if (!METHODS_WITH_BODY.has(request.method.toUpperCase())) { + return undefined + } + + const contentLength = Number(request.headers['content-length'] ?? '0') + const transferEncodingHeader = request.headers['transfer-encoding'] + const transferEncoding = Array.isArray(transferEncodingHeader) + ? transferEncodingHeader.join(',') + : (transferEncodingHeader ?? '') + const hasChunkedBody = transferEncoding.toLowerCase().includes('chunked') + + if ((Number.isFinite(contentLength) && contentLength > 0) || hasChunkedBody) { + return 'missing-raw-body' + } + + return Buffer.alloc(0) +} + +const sendUnauthorized = (response: Response): void => { + response.status(401).setHeader('content-type', 'application/json').send({ error: 'Unauthorized' }) +} + +const resolveReplayTtlSeconds = (maxSkewSeconds: number | undefined): number => { + if (typeof maxSkewSeconds === 'number' && Number.isSafeInteger(maxSkewSeconds) && maxSkewSeconds > 0) { + return maxSkewSeconds + } + + return DEFAULT_NIP98_MAX_SKEW_SECONDS +} + +export const adminAuthGateMiddleware = async (request: AdminRequest, response: Response, next: NextFunction) => { try { - if (!adminAuthProvider.isRequestAuthenticated(request)) { - response.status(401).setHeader('content-type', 'application/json').send({ error: 'Unauthorized' }) + if (adminAuthProvider.isRequestAuthenticated(request)) { + next() + return + } + + const settings = createSettings() + const nip98Settings = settings.admin?.nip98 + const authorizationHeader = request.headers.authorization + + if (nip98Settings?.enabled !== true || !isNostrAuthorizationHeader(authorizationHeader)) { + sendUnauthorized(response) + return + } + + if (authorizationHeader.length > DEFAULT_NIP98_MAX_AUTHORIZATION_HEADER_LENGTH) { + logger('rejecting NIP-98 auth gate: authorization header too large') + sendUnauthorized(response) + return + } + + const absoluteUrl = getAbsoluteHttpRequestUrl(request, settings) + if (!absoluteUrl) { + logger('rejecting NIP-98 auth gate: unable to build absolute request URL') + sendUnauthorized(response) return } - } catch { + + const result = await verifyNip98Auth({ + authorizationHeader, + url: absoluteUrl, + method: request.method.toUpperCase(), + maxSkewSeconds: nip98Settings.maxSkewSeconds, + }) + + if (result.ok === false) { + logger('rejecting NIP-98 auth gate: %s', result.reason) + sendUnauthorized(response) + return + } + + if (!isAllowedNip98Pubkey(result.pubkey, nip98Settings.allowedPubkeys)) { + logger('rejecting NIP-98 auth gate: pubkey %s is not allowlisted', result.pubkey) + sendUnauthorized(response) + return + } + + next() + } catch (error) { + logger('admin auth gate error: %o', error) response.status(500).setHeader('content-type', 'application/json').send({ error: 'Internal Server Error' }) - return } +} - next() +export const adminAuthMiddleware = async (request: AdminRequest, response: Response, next: NextFunction) => { + try { + if (adminAuthProvider.isRequestAuthenticated(request)) { + next() + return + } + + const settings = createSettings() + const nip98Settings = settings.admin?.nip98 + const authorizationHeader = request.headers.authorization + + if (!nip98Settings?.enabled || !isNostrAuthorizationHeader(authorizationHeader)) { + sendUnauthorized(response) + return + } + + const absoluteUrl = getAbsoluteHttpRequestUrl(request, settings) + if (!absoluteUrl) { + logger('rejecting NIP-98 auth: unable to build absolute request URL') + sendUnauthorized(response) + return + } + + const body = resolveBodyForNip98(request) + if (body === 'missing-raw-body') { + logger('rejecting NIP-98 auth: request body present but rawBody was not captured') + sendUnauthorized(response) + return + } + + const result = await verifyNip98Auth({ + authorizationHeader, + url: absoluteUrl, + method: request.method.toUpperCase(), + body, + maxSkewSeconds: nip98Settings.maxSkewSeconds, + payloadPolicy: 'require-when-body', + }) + + if (result.ok === false) { + logger('rejecting NIP-98 auth: %s', result.reason) + sendUnauthorized(response) + return + } + + if (!isAllowedNip98Pubkey(result.pubkey, nip98Settings.allowedPubkeys)) { + logger('rejecting NIP-98 auth: pubkey %s is not allowlisted', result.pubkey) + sendUnauthorized(response) + return + } + + const claim = await claimNip98AuthEventId(result.event.id, resolveReplayTtlSeconds(nip98Settings.maxSkewSeconds)) + if (claim !== 'claimed') { + logger('rejecting NIP-98 auth: event %s replay protection result=%s', result.event.id, claim) + sendUnauthorized(response) + return + } + + request.nip98Pubkey = result.pubkey + next() + } catch (error) { + logger('admin auth middleware error: %o', error) + response.status(500).setHeader('content-type', 'application/json').send({ error: 'Internal Server Error' }) + } } diff --git a/src/handlers/request-handlers/admin-json-body-middleware.ts b/src/handlers/request-handlers/admin-json-body-middleware.ts new file mode 100644 index 00000000..6630a396 --- /dev/null +++ b/src/handlers/request-handlers/admin-json-body-middleware.ts @@ -0,0 +1,15 @@ +import { json, Request, RequestHandler } from 'express' + +export type AdminRequest = Request & { + rawBody?: Buffer + nip98Pubkey?: string +} + +const ADMIN_JSON_BODY_LIMIT = '1mb' + +export const adminJsonBodyMiddleware: RequestHandler = json({ + limit: ADMIN_JSON_BODY_LIMIT, + verify: (request: AdminRequest, _response, buffer) => { + request.rawBody = Buffer.from(buffer) + }, +}) diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 6879b826..46bf2e57 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -3,16 +3,17 @@ import express, { json, Router } from 'express' import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory' import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory' import { createGetAdminSessionController } from '../../factories/controllers/get-admin-session-controller-factory' -import { createGetAdminSettingsController } from '../../factories/controllers/get-admin-settings-controller-factory' import { createGetAdminSettingsBackupsController } from '../../factories/controllers/get-admin-settings-backups-controller-factory' +import { createGetAdminSettingsController } from '../../factories/controllers/get-admin-settings-controller-factory' import { createGetAdminSettingsSchemaController } from '../../factories/controllers/get-admin-settings-schema-controller-factory' import { createPatchAdminSettingsController } from '../../factories/controllers/patch-admin-settings-controller-factory' import { createPostAdminLoginController } from '../../factories/controllers/post-admin-login-controller-factory' import { createPostAdminLogoutController } from '../../factories/controllers/post-admin-logout-controller-factory' import { createPostAdminSettingsRestoreController } from '../../factories/controllers/post-admin-settings-restore-controller-factory' import { createPostAdminSettingsValidateController } from '../../factories/controllers/post-admin-settings-validate-controller-factory' -import { adminAuthMiddleware } from '../../handlers/request-handlers/admin-auth-middleware' +import { adminAuthGateMiddleware, adminAuthMiddleware } from '../../handlers/request-handlers/admin-auth-middleware' import { adminEnabledMiddleware } from '../../handlers/request-handlers/admin-enabled-middleware' +import { adminJsonBodyMiddleware } from '../../handlers/request-handlers/admin-json-body-middleware' import { adminLoginRateLimitMiddleware, adminRateLimitMiddleware, @@ -30,12 +31,37 @@ router.use(adminEnabledMiddleware) router.use('/assets', express.static('./resources/admin/assets')) router.get('/', getAdminDashboardRequestHandler) router.get('/dashboard', getAdminDashboardRequestHandler) -router.post('/login', adminLoginRateLimitMiddleware, json(), withAdminController(createPostAdminLoginController)) +router.post( + '/login', + adminLoginRateLimitMiddleware, + json({ limit: '100kb' }), + withAdminController(createPostAdminLoginController), +) router.post('/logout', adminRateLimitMiddleware, withAdminController(createPostAdminLogoutController)) -router.get('/session', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSessionController)) -router.get('/health', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminHealthController)) -router.get('/metrics', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminMetricsController)) -router.get('/settings', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSettingsController)) +router.get( + '/session', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminSessionController), +) +router.get( + '/health', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminHealthController), +) +router.get( + '/metrics', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminMetricsController), +) +router.get( + '/settings', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminSettingsController), +) router.get( '/settings/backups', adminRateLimitMiddleware, @@ -49,19 +75,29 @@ router.get( withAdminController(createGetAdminSettingsSchemaController), ) // codeql[js/missing-rate-limiting] - adminRateLimitMiddleware applies Redis-backed admin rate limits -router.patch('/settings', adminRateLimitMiddleware, adminAuthMiddleware, json(), withAdminController(createPatchAdminSettingsController)) +router.patch( + '/settings', + adminRateLimitMiddleware, + adminAuthGateMiddleware, + adminJsonBodyMiddleware, + adminAuthMiddleware, + withAdminController(createPatchAdminSettingsController), +) // codeql[js/missing-rate-limiting] - adminRateLimitMiddleware applies Redis-backed admin rate limits router.post( '/settings/validate', adminRateLimitMiddleware, + adminAuthGateMiddleware, + adminJsonBodyMiddleware, adminAuthMiddleware, withAdminController(createPostAdminSettingsValidateController), ) router.post( '/settings/restore', adminRateLimitMiddleware, + adminAuthGateMiddleware, + adminJsonBodyMiddleware, adminAuthMiddleware, - json(), withAdminController(createPostAdminSettingsRestoreController), ) diff --git a/src/utils/nip98-replay.ts b/src/utils/nip98-replay.ts new file mode 100644 index 00000000..9d42a030 --- /dev/null +++ b/src/utils/nip98-replay.ts @@ -0,0 +1,38 @@ +import { ICacheAdapter } from '../@types/adapters' +import { RedisAdapter } from '../adapters/redis-adapter' +import { getCacheClient } from '../cache/client' +import { createLogger } from '../factories/logger-factory' + +const logger = createLogger('nip98-replay') + +let cacheAdapter: ICacheAdapter | undefined + +const getCache = (): ICacheAdapter => { + if (!cacheAdapter) { + cacheAdapter = new RedisAdapter(getCacheClient()) + } + + return cacheAdapter +} + +export const nip98AuthReplayCacheKey = (eventId: string): string => `nip98:auth:${eventId}` + +export const claimNip98AuthEventId = async ( + eventId: string, + ttlSeconds: number, + cache: ICacheAdapter = getCache(), +): Promise<'claimed' | 'replay' | 'unavailable'> => { + const expirySeconds = Number.isSafeInteger(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : 1 + + try { + const created = await cache.setKeyIfNotExists(nip98AuthReplayCacheKey(eventId), '1', expirySeconds) + return created ? 'claimed' : 'replay' + } catch (error) { + logger('unable to claim NIP-98 auth event %s: %o', eventId, error) + return 'unavailable' + } +} + +export const resetNip98ReplayCacheAdapterForTests = (): void => { + cacheAdapter = undefined +} diff --git a/test/unit/handlers/request-handlers/admin-auth-middleware.spec.ts b/test/unit/handlers/request-handlers/admin-auth-middleware.spec.ts new file mode 100644 index 00000000..c061f56e --- /dev/null +++ b/test/unit/handlers/request-handlers/admin-auth-middleware.spec.ts @@ -0,0 +1,380 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' +import { Tag } from '../../../../src/@types/base' +import { PasswordAdminAuthProvider } from '../../../../src/admin/password-admin-auth-provider' +import { EventKinds, EventTags } from '../../../../src/constants/base' +import * as settingsFactory from '../../../../src/factories/settings-factory' +import { + adminAuthGateMiddleware, + adminAuthMiddleware, +} from '../../../../src/handlers/request-handlers/admin-auth-middleware' +import { AdminRequest } from '../../../../src/handlers/request-handlers/admin-json-body-middleware' +import { getPublicKey, identifyEvent, signEvent } from '../../../../src/utils/event' +import { hashNip98Payload } from '../../../../src/utils/nip98' +import * as nip98Replay from '../../../../src/utils/nip98-replay' + +chai.use(sinonChai) + +const { expect } = chai + +describe('adminAuthMiddleware', () => { + const privkey = 'a'.repeat(64) + const pubkey = getPublicKey(privkey) + const stranger = 'b'.repeat(64) + const now = 1_700_000_000 + // relay_url is wss → public HTTP scheme becomes https + const url = 'https://relay.example.com/admin/settings' + + let sandbox: Sinon.SinonSandbox + let isRequestAuthenticated: Sinon.SinonStub + let claimNip98AuthEventId: Sinon.SinonStub + let next: Sinon.SinonStub + let response: { + status: Sinon.SinonStub + setHeader: Sinon.SinonStub + send: Sinon.SinonStub + } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + isRequestAuthenticated = sandbox.stub(PasswordAdminAuthProvider.prototype, 'isRequestAuthenticated').returns(false) + claimNip98AuthEventId = sandbox.stub(nip98Replay, 'claimNip98AuthEventId').resolves('claimed') + next = sandbox.stub() + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + }) + + afterEach(() => { + sandbox.restore() + }) + + const mockRequest = (overrides: Partial & { headers?: Record } = {}): AdminRequest => { + const headers = overrides.headers ?? {} + return { + method: 'GET', + originalUrl: '/admin/settings', + headers, + get: (name: string) => { + if (name.toLowerCase() === 'host') { + return 'relay.example.com' + } + return headers[name] + }, + socket: { remoteAddress: '127.0.0.1' }, + ...overrides, + } as any + } + + async function createAuthHeader( + overrides: { url?: string; method?: string; payload?: string; created_at?: number } = {}, + ): Promise { + const tags: Tag[] = [ + [EventTags.Url, overrides.url ?? url], + [EventTags.Method, overrides.method ?? 'GET'], + ] + if (overrides.payload !== undefined) { + tags.push([EventTags.Payload, overrides.payload]) + } + + const identified = await identifyEvent({ + pubkey, + created_at: overrides.created_at ?? now, + kind: EventKinds.HTTP_AUTH, + tags, + content: '', + }) + const signed = await signEvent(privkey)(identified) + return `Nostr ${Buffer.from(JSON.stringify(signed), 'utf8').toString('base64')}` + } + + const enableNip98 = (allowedPubkeys: string[] = [pubkey]) => { + sandbox.stub(settingsFactory, 'createSettings').returns({ + info: { relay_url: 'wss://relay.example.com' }, + network: {}, + admin: { + enabled: true, + nip98: { + enabled: true, + allowedPubkeys, + maxSkewSeconds: 60, + }, + }, + } as any) + } + + describe('adminAuthGateMiddleware', () => { + it('continues for session-authenticated requests', async () => { + isRequestAuthenticated.returns(true) + const request = mockRequest() + + await adminAuthGateMiddleware(request, response as any, next) + + expect(next).to.have.been.calledOnce + expect(response.status).not.to.have.been.called + }) + + it('rejects anonymous requests before body parsing when NIP-98 is off', async () => { + sandbox.stub(settingsFactory, 'createSettings').returns({ + info: { relay_url: 'wss://relay.example.com' }, + network: {}, + admin: { enabled: true, nip98: { enabled: false } }, + } as any) + + await adminAuthGateMiddleware(mockRequest(), response as any, next) + + expect(next).not.to.have.been.called + expect(response.status).to.have.been.calledWith(401) + }) + + it('allows a cryptographically valid allowlisted NIP-98 header through', async () => { + enableNip98() + sandbox.stub(Date, 'now').returns(now * 1000) + + await adminAuthGateMiddleware( + mockRequest({ headers: { authorization: await createAuthHeader() } }), + response as any, + next, + ) + + expect(next).to.have.been.calledOnce + expect(response.status).not.to.have.been.called + }) + + it('rejects junk Nostr Authorization before body parsing', async () => { + enableNip98() + + await adminAuthGateMiddleware( + mockRequest({ headers: { authorization: 'Nostr not-valid-base64!!!' } }), + response as any, + next, + ) + + expect(next).not.to.have.been.called + expect(response.status).to.have.been.calledWith(401) + }) + + it('rejects Host-spoofed URLs because host is pinned to relay_url', async () => { + enableNip98() + sandbox.stub(Date, 'now').returns(now * 1000) + const authorization = await createAuthHeader({ url: 'https://evil.example/admin/settings' }) + + await adminAuthGateMiddleware( + mockRequest({ + headers: { authorization }, + get: (name: string) => (name.toLowerCase() === 'host' ? 'evil.example' : undefined), + } as any), + response as any, + next, + ) + + expect(next).not.to.have.been.called + expect(response.status).to.have.been.calledWith(401) + }) + }) + + it('allows cookie/session authenticated requests without NIP-98', async () => { + isRequestAuthenticated.returns(true) + const request = mockRequest() + + await adminAuthMiddleware(request, response as any, next) + + expect(next).to.have.been.calledOnce + expect(response.status).not.to.have.been.called + }) + + it('rejects unauthenticated requests when NIP-98 is disabled', async () => { + sandbox.stub(settingsFactory, 'createSettings').returns({ + info: { relay_url: 'wss://relay.example.com' }, + network: {}, + admin: { enabled: true, nip98: { enabled: false, allowedPubkeys: [pubkey] } }, + } as any) + + await adminAuthMiddleware( + mockRequest({ headers: { authorization: await createAuthHeader() } }), + response as any, + next, + ) + + expect(next).not.to.have.been.called + expect(response.status).to.have.been.calledWith(401) + }) + + it('accepts a valid allowlisted NIP-98 Authorization header', async () => { + enableNip98() + sandbox.stub(Date, 'now').returns(now * 1000) + + await adminAuthMiddleware( + mockRequest({ + headers: { authorization: await createAuthHeader() }, + }), + response as any, + next, + ) + + expect(next).to.have.been.calledOnce + expect(response.status).not.to.have.been.called + }) + + it('rejects a valid NIP-98 event from a non-allowlisted pubkey', async () => { + enableNip98([stranger]) + sandbox.stub(Date, 'now').returns(now * 1000) + + await adminAuthMiddleware( + mockRequest({ + headers: { authorization: await createAuthHeader() }, + }), + response as any, + next, + ) + + expect(next).not.to.have.been.called + expect(response.status).to.have.been.calledWith(401) + }) + + it('rejects when allowlist is empty even if NIP-98 is enabled', async () => { + enableNip98([]) + sandbox.stub(Date, 'now').returns(now * 1000) + + await adminAuthMiddleware( + mockRequest({ + headers: { authorization: await createAuthHeader() }, + }), + response as any, + next, + ) + + expect(next).not.to.have.been.called + expect(response.status).to.have.been.calledWith(401) + }) + + it('verifies payload hash for PATCH bodies using rawBody', async () => { + enableNip98() + sandbox.stub(Date, 'now').returns(now * 1000) + const body = '{"path":"info.name","value":"relay"}' + const authorization = await createAuthHeader({ + method: 'PATCH', + payload: hashNip98Payload(body), + }) + + await adminAuthMiddleware( + mockRequest({ + method: 'PATCH', + headers: { authorization }, + rawBody: Buffer.from(body, 'utf8'), + }), + response as any, + next, + ) + + expect(next).to.have.been.calledOnce + expect(claimNip98AuthEventId).to.have.been.calledOnce + }) + + it('rejects PATCH when payload hash does not match rawBody', async () => { + enableNip98() + sandbox.stub(Date, 'now').returns(now * 1000) + const body = '{"path":"info.name","value":"relay"}' + const authorization = await createAuthHeader({ + method: 'PATCH', + payload: hashNip98Payload('{"path":"info.name","value":"other"}'), + }) + + await adminAuthMiddleware( + mockRequest({ + method: 'PATCH', + headers: { authorization }, + rawBody: Buffer.from(body, 'utf8'), + }), + response as any, + next, + ) + + expect(next).not.to.have.been.called + expect(response.status).to.have.been.calledWith(401) + expect(claimNip98AuthEventId).not.to.have.been.called + }) + + it('rejects replayed NIP-98 auth event ids', async () => { + enableNip98() + sandbox.stub(Date, 'now').returns(now * 1000) + claimNip98AuthEventId.resolves('replay') + + await adminAuthMiddleware( + mockRequest({ + headers: { authorization: await createAuthHeader() }, + }), + response as any, + next, + ) + + expect(next).not.to.have.been.called + expect(response.status).to.have.been.calledWith(401) + }) + + it('rejects NIP-98 when replay cache is unavailable', async () => { + enableNip98() + sandbox.stub(Date, 'now').returns(now * 1000) + claimNip98AuthEventId.resolves('unavailable') + + await adminAuthMiddleware( + mockRequest({ + headers: { authorization: await createAuthHeader() }, + }), + response as any, + next, + ) + + expect(next).not.to.have.been.called + expect(response.status).to.have.been.calledWith(401) + }) + + it('rejects PATCH with a body when rawBody was not captured', async () => { + enableNip98() + sandbox.stub(Date, 'now').returns(now * 1000) + const body = '{"path":"info.name","value":"relay"}' + const authorization = await createAuthHeader({ + method: 'PATCH', + payload: hashNip98Payload(body), + }) + + await adminAuthMiddleware( + mockRequest({ + method: 'PATCH', + headers: { + authorization, + 'content-length': String(Buffer.byteLength(body)), + }, + }), + response as any, + next, + ) + + expect(next).not.to.have.been.called + expect(response.status).to.have.been.calledWith(401) + }) + + it('rejects PATCH with chunked transfer-encoding when rawBody was not captured', async () => { + enableNip98() + sandbox.stub(Date, 'now').returns(now * 1000) + const authorization = await createAuthHeader({ method: 'PATCH' }) + + await adminAuthMiddleware( + mockRequest({ + method: 'PATCH', + headers: { + authorization, + 'transfer-encoding': 'chunked', + }, + }), + response as any, + next, + ) + + expect(next).not.to.have.been.called + expect(response.status).to.have.been.calledWith(401) + }) +}) diff --git a/test/unit/utils/nip98-replay.spec.ts b/test/unit/utils/nip98-replay.spec.ts new file mode 100644 index 00000000..ed158059 --- /dev/null +++ b/test/unit/utils/nip98-replay.spec.ts @@ -0,0 +1,49 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' + +import { + claimNip98AuthEventId, + nip98AuthReplayCacheKey, + resetNip98ReplayCacheAdapterForTests, +} from '../../../src/utils/nip98-replay' + +chai.use(chaiAsPromised) + +const { expect } = chai + +describe('nip98-replay', () => { + afterEach(() => { + resetNip98ReplayCacheAdapterForTests() + Sinon.restore() + }) + + it('builds a stable cache key', () => { + expect(nip98AuthReplayCacheKey('abc')).to.equal('nip98:auth:abc') + }) + + it('claims a fresh event id', async () => { + const cache = { + setKeyIfNotExists: Sinon.stub().resolves(true), + } + + await expect(claimNip98AuthEventId('event-id', 60, cache as any)).to.eventually.equal('claimed') + expect(cache.setKeyIfNotExists).to.have.been.calledOnceWithExactly('nip98:auth:event-id', '1', 60) + }) + + it('detects replays when NX set fails', async () => { + const cache = { + setKeyIfNotExists: Sinon.stub().resolves(false), + } + + await expect(claimNip98AuthEventId('event-id', 60, cache as any)).to.eventually.equal('replay') + }) + + it('fails closed when redis throws', async () => { + const cache = { + setKeyIfNotExists: Sinon.stub().rejects(new Error('redis down')), + } + + await expect(claimNip98AuthEventId('event-id', 60, cache as any)).to.eventually.equal('unavailable') + }) +})