From 115a5ea288e1633909d9c0ba193a3b46d8366d86 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Wed, 12 Aug 2026 13:07:40 +0530 Subject: [PATCH 1/4] feat(admin): accept NIP-98 auth on admin API routes --- .changeset/nip98-admin-middleware.md | 5 + .knip.json | 1 - CONFIGURATION.md | 3 + resources/default-settings.yaml | 4 + src/@types/settings.ts | 12 +- .../request-handlers/admin-auth-middleware.ts | 184 +++++++- .../admin-json-body-middleware.ts | 15 + src/routes/admin/index.ts | 54 ++- src/utils/nip98-replay.ts | 69 +++ .../admin-auth-middleware.spec.ts | 401 ++++++++++++++++++ test/unit/utils/nip98-replay.spec.ts | 76 ++++ 11 files changed, 805 insertions(+), 19 deletions(-) create mode 100644 .changeset/nip98-admin-middleware.md create mode 100644 src/handlers/request-handlers/admin-json-body-middleware.ts create mode 100644 src/utils/nip98-replay.ts create mode 100644 test/unit/handlers/request-handlers/admin-auth-middleware.spec.ts create mode 100644 test/unit/utils/nip98-replay.spec.ts 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..32177440 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` using the HTTP(S) scheme and host derived from `info.relay_url` (`ws`/`http` → `http`, `wss`/`https` → `https`; never the request Host), plus the public path prefix and `/admin/...`. Successful auth events are one-time through `created_at + maxSkewSeconds` (Redis). | +| admin.nip98.maxSkewSeconds | Max skew in seconds between now and the auth event `created_at`. Replay claims last until that window ends (inclusive). 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..60787b8f 100644 --- a/src/handlers/request-handlers/admin-auth-middleware.ts +++ b/src/handlers/request-handlers/admin-auth-middleware.ts @@ -1,19 +1,187 @@ -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, + verifyNip98Auth, +} from '../../utils/nip98' +import { claimNip98AuthEventId, resolveNip98ReplayTtlSeconds } 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' }) +} + +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 + } + + 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 } - } catch { + + 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, + resolveNip98ReplayTtlSeconds(result.event.created_at, 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..4d8b539f --- /dev/null +++ b/src/utils/nip98-replay.ts @@ -0,0 +1,69 @@ +import { ICacheAdapter } from '../@types/adapters' +import { RedisAdapter } from '../adapters/redis-adapter' +import { getCacheClient } from '../cache/client' +import { createLogger } from '../factories/logger-factory' +import { DEFAULT_NIP98_MAX_SKEW_SECONDS } from './nip98' + +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}` + +const resolveMaxSkewSeconds = (maxSkewSeconds: number | undefined): number => { + if (typeof maxSkewSeconds === 'number' && Number.isSafeInteger(maxSkewSeconds) && maxSkewSeconds > 0) { + return maxSkewSeconds + } + + return DEFAULT_NIP98_MAX_SKEW_SECONDS +} + +/** + * Keep the replay claim until the event leaves the verifier's skew window. + * `verifyNip98Auth` accepts |now - created_at| <= maxSkew, including future timestamps, + * so a TTL of only maxSkew from first use can expire while the event is still valid. + */ +export const resolveNip98ReplayTtlSeconds = ( + createdAt: number, + maxSkewSeconds?: number, + nowSeconds: number = Math.floor(Date.now() / 1000), +): number => { + const maxSkew = resolveMaxSkewSeconds(maxSkewSeconds) + // Inclusive last valid second is created_at + maxSkew. Redis EX drops the key after + // ttl seconds, so +1 keeps the claim through that boundary. + const remainingInclusive = createdAt + maxSkew - nowSeconds + 1 + if (!Number.isSafeInteger(remainingInclusive) || remainingInclusive < 1) { + return 1 + } + + const maxTtl = 2 * maxSkew + 1 + return remainingInclusive > maxTtl ? maxTtl : remainingInclusive +} + +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..5f2031d5 --- /dev/null +++ b/test/unit/handlers/request-handlers/admin-auth-middleware.spec.ts @@ -0,0 +1,401 @@ +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 + expect(claimNip98AuthEventId).to.have.been.calledOnce + expect(claimNip98AuthEventId.firstCall.args[1]).to.equal(61) + }) + + it('claims a future-dated NIP-98 event until created_at + maxSkew', async () => { + enableNip98() + sandbox.stub(Date, 'now').returns(now * 1000) + const createdAt = now + 60 + + await adminAuthMiddleware( + mockRequest({ + headers: { authorization: await createAuthHeader({ created_at: createdAt }) }, + }), + response as any, + next, + ) + + expect(next).to.have.been.calledOnce + expect(claimNip98AuthEventId).to.have.been.calledOnce + expect(claimNip98AuthEventId.firstCall.args[1]).to.equal(121) + }) + + 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 + expect(claimNip98AuthEventId.firstCall.args[1]).to.equal(61) + }) + + 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..40a3cd78 --- /dev/null +++ b/test/unit/utils/nip98-replay.spec.ts @@ -0,0 +1,76 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' + +import { + claimNip98AuthEventId, + nip98AuthReplayCacheKey, + resetNip98ReplayCacheAdapterForTests, + resolveNip98ReplayTtlSeconds, +} 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') + }) + + describe('resolveNip98ReplayTtlSeconds', () => { + const now = 1_700_000_000 + const skew = 60 + + it('keeps the claim through created_at + maxSkew inclusive', () => { + expect(resolveNip98ReplayTtlSeconds(now, skew, now)).to.equal(skew + 1) + }) + + it('covers a future-dated event until it leaves the skew window', () => { + expect(resolveNip98ReplayTtlSeconds(now + skew, skew, now)).to.equal(2 * skew + 1) + }) + + it('uses a 1s ttl at the last valid second', () => { + expect(resolveNip98ReplayTtlSeconds(now - skew, skew, now)).to.equal(1) + }) + + it('clamps expired or invalid remaining windows to 1s', () => { + expect(resolveNip98ReplayTtlSeconds(now - skew - 1, skew, now)).to.equal(1) + expect(resolveNip98ReplayTtlSeconds(Number.NaN, skew, now)).to.equal(1) + }) + + it('falls back to the default skew when maxSkewSeconds is missing', () => { + expect(resolveNip98ReplayTtlSeconds(now, undefined, now)).to.equal(61) + }) + }) +}) From 1bea7154eaec1122302aa5b1d325c78cee06ef5a Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sun, 16 Aug 2026 23:30:42 +0530 Subject: [PATCH 2/4] fix: tighten NIP-98 request handling --- .../request-handlers/admin-auth-middleware.ts | 45 +++---------------- .../admin-json-body-middleware.ts | 11 ++++- src/routes/admin/index.ts | 1 - src/utils/nip98.ts | 10 +++-- 4 files changed, 23 insertions(+), 44 deletions(-) diff --git a/src/handlers/request-handlers/admin-auth-middleware.ts b/src/handlers/request-handlers/admin-auth-middleware.ts index 60787b8f..f7ea9f5b 100644 --- a/src/handlers/request-handlers/admin-auth-middleware.ts +++ b/src/handlers/request-handlers/admin-auth-middleware.ts @@ -7,6 +7,7 @@ import { createSettings } from '../../factories/settings-factory' import { getAbsoluteHttpRequestUrl } from '../../utils/http' import { DEFAULT_NIP98_MAX_AUTHORIZATION_HEADER_LENGTH, + isNostrAuthorizationHeader, verifyNip98Auth, } from '../../utils/nip98' import { claimNip98AuthEventId, resolveNip98ReplayTtlSeconds } from '../../utils/nip98-replay' @@ -18,44 +19,17 @@ 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 isAllowedNip98Pubkey = (pubkey: string, allowedPubkeys: string[] = []): boolean => { const normalized = pubkey.toLowerCase() - return allowedPubkeys.some((allowed) => typeof allowed === 'string' && allowed.toLowerCase() === normalized) + return allowedPubkeys.some((allowed) => allowed.toLowerCase() === normalized) } -const resolveBodyForNip98 = (request: AdminRequest): Buffer | undefined | 'missing-raw-body' => { - if (request.rawBody !== undefined) { - return request.rawBody - } - +const resolveBodyForNip98 = (request: AdminRequest): Buffer | undefined => { 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) + return request.rawBody ?? Buffer.alloc(0) } const sendUnauthorized = (response: Response): void => { @@ -140,18 +114,11 @@ export const adminAuthMiddleware = async (request: AdminRequest, response: Respo 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, + body: resolveBodyForNip98(request), maxSkewSeconds: nip98Settings.maxSkewSeconds, payloadPolicy: 'require-when-body', }) diff --git a/src/handlers/request-handlers/admin-json-body-middleware.ts b/src/handlers/request-handlers/admin-json-body-middleware.ts index 6630a396..a66a1d0c 100644 --- a/src/handlers/request-handlers/admin-json-body-middleware.ts +++ b/src/handlers/request-handlers/admin-json-body-middleware.ts @@ -7,9 +7,18 @@ export type AdminRequest = Request & { const ADMIN_JSON_BODY_LIMIT = '1mb' -export const adminJsonBodyMiddleware: RequestHandler = json({ +const parseAdminJsonBody = json({ limit: ADMIN_JSON_BODY_LIMIT, verify: (request: AdminRequest, _response, buffer) => { request.rawBody = Buffer.from(buffer) }, }) + +export const adminJsonBodyMiddleware: RequestHandler = (request, response, next) => { + if (!request.is('application/json')) { + response.status(415).setHeader('content-type', 'application/json').send({ error: 'Unsupported Media Type' }) + return + } + + parseAdminJsonBody(request, response, next) +} diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 46bf2e57..dbfb9617 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -88,7 +88,6 @@ router.post( '/settings/validate', adminRateLimitMiddleware, adminAuthGateMiddleware, - adminJsonBodyMiddleware, adminAuthMiddleware, withAdminController(createPostAdminSettingsValidateController), ) diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts index ee40fd64..c3f12734 100644 --- a/src/utils/nip98.ts +++ b/src/utils/nip98.ts @@ -16,6 +16,11 @@ export const DEFAULT_NIP98_MAX_AUTHORIZATION_HEADER_LENGTH = 2048 const NOSTR_AUTHORIZATION_HEADER = /^Nostr [A-Za-z0-9+/]+={0,2}$/i const LOWER_HEX_64 = /^[0-9a-f]{64}$/ +export const isNostrAuthorizationHeader = ( + authorizationHeader: string | undefined | null, +): authorizationHeader is string => + typeof authorizationHeader === 'string' && NOSTR_AUTHORIZATION_HEADER.test(authorizationHeader) + // Lean NIP-01 shape only — avoids eventSchema superRefine (reactions, geohash, etc.). const nip98EventSchema = z .object({ @@ -150,13 +155,12 @@ const decodeAuthorizationHeader = ( return fail('invalid authorization header') } - const trimmed = authorizationHeader.trim() - if (!NOSTR_AUTHORIZATION_HEADER.test(trimmed)) { + if (!isNostrAuthorizationHeader(authorizationHeader)) { return fail('invalid authorization header') } // Safe after the regex match: scheme, single space, base64 token. - const token = trimmed.split(' ')[1] + const token = authorizationHeader.split(' ')[1] const remainder = token.length % 4 const hasPadding = token.endsWith('=') if (remainder === 1 || (hasPadding && remainder !== 0)) { From cf09ca07aef9e1867830f49ce4c259d234b3c910 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sun, 16 Aug 2026 23:31:38 +0530 Subject: [PATCH 3/4] test: cover NIP-98 request handling --- .../admin-auth-middleware.spec.ts | 59 +++-------- .../admin-json-body-middleware.spec.ts | 97 +++++++++++++++++++ test/unit/routes/admin.spec.ts | 52 +++++++++- test/unit/utils/nip98.spec.ts | 16 +++ 4 files changed, 176 insertions(+), 48 deletions(-) create mode 100644 test/unit/handlers/request-handlers/admin-json-body-middleware.spec.ts diff --git a/test/unit/handlers/request-handlers/admin-auth-middleware.spec.ts b/test/unit/handlers/request-handlers/admin-auth-middleware.spec.ts index 5f2031d5..f4f46365 100644 --- a/test/unit/handlers/request-handlers/admin-auth-middleware.spec.ts +++ b/test/unit/handlers/request-handlers/admin-auth-middleware.spec.ts @@ -11,7 +11,7 @@ import { } 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 { hashNip98Payload, isNostrAuthorizationHeader } from '../../../../src/utils/nip98' import * as nip98Replay from '../../../../src/utils/nip98-replay' chai.use(sinonChai) @@ -106,6 +106,17 @@ describe('adminAuthMiddleware', () => { } as any) } + describe('isNostrAuthorizationHeader', () => { + it('accepts only a complete Nostr scheme and base64 token', () => { + expect(isNostrAuthorizationHeader('Nostr YQ==')).to.be.true + expect(isNostrAuthorizationHeader('nostr YQ==')).to.be.true + expect(isNostrAuthorizationHeader(' Nostr YQ==')).to.be.false + expect(isNostrAuthorizationHeader('Nostr YQ== ')).to.be.false + expect(isNostrAuthorizationHeader('Nostr YQ==')).to.be.false + expect(isNostrAuthorizationHeader('Nostr not-valid-base64!!!')).to.be.false + }) + }) + describe('adminAuthGateMiddleware', () => { it('continues for session-authenticated requests', async () => { isRequestAuthenticated.returns(true) @@ -352,50 +363,4 @@ describe('adminAuthMiddleware', () => { 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/handlers/request-handlers/admin-json-body-middleware.spec.ts b/test/unit/handlers/request-handlers/admin-json-body-middleware.spec.ts new file mode 100644 index 00000000..c23a104d --- /dev/null +++ b/test/unit/handlers/request-handlers/admin-json-body-middleware.spec.ts @@ -0,0 +1,97 @@ +import axios from 'axios' +import { expect } from 'chai' +import express from 'express' +import { request as httpRequest } from 'http' +import { AddressInfo } from 'net' + +import { + AdminRequest, + adminJsonBodyMiddleware, +} from '../../../../src/handlers/request-handlers/admin-json-body-middleware' + +describe('adminJsonBodyMiddleware', () => { + let server: ReturnType['listen']> + + afterEach(async () => { + if (server) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + } + }) + + const startServer = async (): Promise => { + const app = express() + app.patch('/', adminJsonBodyMiddleware, (request: AdminRequest, response) => { + response.status(200).send({ + body: request.body, + rawBody: request.rawBody?.toString('utf8'), + }) + }) + + server = await new Promise((resolve) => { + const listeningServer = app.listen(0, () => resolve(listeningServer)) + }) + + return `http://127.0.0.1:${(server.address() as AddressInfo).port}` + } + + it('rejects non-JSON media types', async () => { + const url = await startServer() + const rawBody = '{\n "path": "info.name",\n "value": "relay"\n}' + + const result = await axios.patch(url, rawBody, { + headers: { 'content-type': 'text/plain' }, + validateStatus: () => true, + }) + + expect(result.status).to.equal(415) + expect(result.data).to.deep.equal({ error: 'Unsupported Media Type' }) + }) + + it('captures the actual bytes from a chunked request', async () => { + const url = await startServer() + const rawBody = '{"path":"info.name","value":"relay"}' + + const result = await new Promise<{ body: Record; status: number }>((resolve, reject) => { + const request = httpRequest( + url, + { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + 'transfer-encoding': 'chunked', + }, + }, + (response) => { + let responseBody = '' + response.setEncoding('utf8') + response.on('data', (chunk) => { + responseBody += chunk + }) + response.on('end', () => { + resolve({ + body: JSON.parse(responseBody), + status: response.statusCode ?? 0, + }) + }) + }, + ) + + request.on('error', reject) + request.write(rawBody.slice(0, 12)) + request.end(rawBody.slice(12)) + }) + + expect(result.status).to.equal(200) + expect(result.body.rawBody).to.equal(rawBody) + expect(result.body.body).to.deep.equal({ path: 'info.name', value: 'relay' }) + }) +}) diff --git a/test/unit/routes/admin.spec.ts b/test/unit/routes/admin.spec.ts index f4933c15..50ee6add 100644 --- a/test/unit/routes/admin.spec.ts +++ b/test/unit/routes/admin.spec.ts @@ -3,12 +3,16 @@ import { expect } from 'chai' import express from 'express' import Sinon from 'sinon' +import { Tag } from '../../../src/@types/base' +import { EventKinds, EventTags } from '../../../src/constants/base' import * as getAdminHealthControllerFactory from '../../../src/factories/controllers/get-admin-health-controller-factory' import * as getAdminMetricsControllerFactory from '../../../src/factories/controllers/get-admin-metrics-controller-factory' -import { hashAdminPassword } from '../../../src/utils/admin-password' import * as adminRateLimitMiddleware from '../../../src/handlers/request-handlers/admin-rate-limit-middleware' import * as rateLimiterMiddleware from '../../../src/handlers/request-handlers/rate-limiter-middleware' import * as settingsFactory from '../../../src/factories/settings-factory' +import { hashAdminPassword } from '../../../src/utils/admin-password' +import { getPublicKey, identifyEvent, signEvent } from '../../../src/utils/event' +import * as nip98Replay from '../../../src/utils/nip98-replay' describe('admin router', () => { const originalSecret = process.env.SECRET @@ -19,6 +23,7 @@ describe('admin router', () => { let rateLimiterMiddlewareStub: Sinon.SinonStub let adminRateLimitMiddlewareStub: Sinon.SinonStub let adminLoginRateLimitMiddlewareStub: Sinon.SinonStub + let claimNip98AuthEventIdStub: Sinon.SinonStub | undefined let server: any const loadAdminRouter = () => { @@ -95,6 +100,8 @@ describe('admin router', () => { rateLimiterMiddlewareStub?.restore() adminRateLimitMiddlewareStub?.restore() adminLoginRateLimitMiddlewareStub?.restore() + claimNip98AuthEventIdStub?.restore() + claimNip98AuthEventIdStub = undefined delete require.cache[require.resolve('../../../src/routes/admin/index')] delete require.cache[require.resolve('../../../src/routes/admin')] @@ -175,6 +182,49 @@ describe('admin router', () => { expect(rateLimiterMiddlewareStub.callCount).to.equal(3) }) + it('authenticates a protected route with a signed NIP-98 event', async () => { + const privkey = 'a'.repeat(64) + const pubkey = getPublicKey(privkey) + const settings = { + info: { relay_url: 'http://placeholder.invalid' }, + network: {}, + admin: { + enabled: true, + nip98: { + enabled: true, + allowedPubkeys: [pubkey], + maxSkewSeconds: 60, + }, + }, + } + claimNip98AuthEventIdStub = Sinon.stub(nip98Replay, 'claimNip98AuthEventId').resolves('claimed') + const baseUrl = await startServer(settings) + const requestUrl = `${baseUrl}/session` + settings.info.relay_url = baseUrl.slice(0, -'/admin'.length) + const tags: Tag[] = [ + [EventTags.Url, requestUrl], + [EventTags.Method, 'GET'], + ] + const identified = await identifyEvent({ + pubkey, + created_at: Math.floor(Date.now() / 1000), + kind: EventKinds.HTTP_AUTH, + tags, + content: '', + }) + const signed = await signEvent(privkey)(identified) + const authorization = `Nostr ${Buffer.from(JSON.stringify(signed), 'utf8').toString('base64')}` + + const response = await axios.get(requestUrl, { + headers: { authorization }, + validateStatus: () => true, + }) + + expect(response.status, JSON.stringify(response.data)).to.equal(200) + expect(response.data.authenticated).to.equal(true) + expect(claimNip98AuthEventIdStub.calledOnce).to.be.true + }) + it('rejects invalid login credentials', async () => { process.env.ADMIN_PASSWORD = 'correct-password' const baseUrl = await startServer({ admin: { enabled: true } }) diff --git a/test/unit/utils/nip98.spec.ts b/test/unit/utils/nip98.spec.ts index 01218df0..d9864578 100644 --- a/test/unit/utils/nip98.spec.ts +++ b/test/unit/utils/nip98.spec.ts @@ -189,6 +189,22 @@ describe('nip98', () => { expect(result).to.deep.equal({ ok: false, reason: 'invalid authorization header' }) }) + it('rejects authorization headers with surrounding whitespace', async () => { + const event = await createAuthEvent() + const authorizationHeader = toAuthorizationHeader(event) + + for (const value of [` ${authorizationHeader}`, `${authorizationHeader} `]) { + const result = await verifyNip98Auth({ + authorizationHeader: value, + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ ok: false, reason: 'invalid authorization header' }) + } + }) + it('rejects invalid event json', async () => { const result = await verifyNip98Auth({ authorizationHeader: `Nostr ${Buffer.from('{not-json', 'utf8').toString('base64')}`, From 0dea4a65954c06495d3ca5c37bc6008b00e64e1d Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 17 Aug 2026 00:06:30 +0530 Subject: [PATCH 4/4] fix: align NIP-98 skew handling --- src/handlers/request-handlers/admin-auth-middleware.ts | 2 +- src/utils/nip98-replay.ts | 2 +- test/unit/utils/nip98-replay.spec.ts | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/handlers/request-handlers/admin-auth-middleware.ts b/src/handlers/request-handlers/admin-auth-middleware.ts index f7ea9f5b..e13d8c4f 100644 --- a/src/handlers/request-handlers/admin-auth-middleware.ts +++ b/src/handlers/request-handlers/admin-auth-middleware.ts @@ -102,7 +102,7 @@ export const adminAuthMiddleware = async (request: AdminRequest, response: Respo const nip98Settings = settings.admin?.nip98 const authorizationHeader = request.headers.authorization - if (!nip98Settings?.enabled || !isNostrAuthorizationHeader(authorizationHeader)) { + if (nip98Settings?.enabled !== true || !isNostrAuthorizationHeader(authorizationHeader)) { sendUnauthorized(response) return } diff --git a/src/utils/nip98-replay.ts b/src/utils/nip98-replay.ts index 4d8b539f..fe5eff7a 100644 --- a/src/utils/nip98-replay.ts +++ b/src/utils/nip98-replay.ts @@ -19,7 +19,7 @@ const getCache = (): ICacheAdapter => { export const nip98AuthReplayCacheKey = (eventId: string): string => `nip98:auth:${eventId}` const resolveMaxSkewSeconds = (maxSkewSeconds: number | undefined): number => { - if (typeof maxSkewSeconds === 'number' && Number.isSafeInteger(maxSkewSeconds) && maxSkewSeconds > 0) { + if (typeof maxSkewSeconds === 'number' && Number.isSafeInteger(maxSkewSeconds) && maxSkewSeconds >= 0) { return maxSkewSeconds } diff --git a/test/unit/utils/nip98-replay.spec.ts b/test/unit/utils/nip98-replay.spec.ts index 40a3cd78..d5d89de3 100644 --- a/test/unit/utils/nip98-replay.spec.ts +++ b/test/unit/utils/nip98-replay.spec.ts @@ -64,6 +64,10 @@ describe('nip98-replay', () => { expect(resolveNip98ReplayTtlSeconds(now - skew, skew, now)).to.equal(1) }) + it('uses a 1s ttl when maxSkewSeconds is zero', () => { + expect(resolveNip98ReplayTtlSeconds(now, 0, now)).to.equal(1) + }) + it('clamps expired or invalid remaining windows to 1s', () => { expect(resolveNip98ReplayTtlSeconds(now - skew - 1, skew, now)).to.equal(1) expect(resolveNip98ReplayTtlSeconds(Number.NaN, skew, now)).to.equal(1)