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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nip98-admin-middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat(admin): accept NIP-98 Authorization on protected admin API routes
1 change: 0 additions & 1 deletion .knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
"src/import-events.ts!",
"src/cli/index.ts!",
"src/scripts/benchmark-queries.ts!",
"src/utils/nip98.ts!",
"knexfile.js!"
],
"project": [
Expand Down
3 changes: 3 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 4 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,7 @@ limits:
admin:
enabled: false
sessionTtlSeconds: 86400
nip98:
enabled: false
allowedPubkeys: []
maxSkewSeconds: 60
12 changes: 11 additions & 1 deletion src/@types/settings.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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
Expand Down
184 changes: 176 additions & 8 deletions src/handlers/request-handlers/admin-auth-middleware.ts
Original file line number Diff line number Diff line change
@@ -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' })
}
}
15 changes: 15 additions & 0 deletions src/handlers/request-handlers/admin-json-body-middleware.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
54 changes: 45 additions & 9 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
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,
Expand All @@ -30,12 +31,37 @@
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',
Comment thread
Anshumancanrock marked this conversation as resolved.
Dismissed
adminRateLimitMiddleware,
adminAuthMiddleware,
Comment thread
Anshumancanrock marked this conversation as resolved.
Dismissed
withAdminController(createGetAdminSessionController),
)
router.get(
'/health',
Comment thread
Anshumancanrock marked this conversation as resolved.
Dismissed
adminRateLimitMiddleware,
adminAuthMiddleware,
Comment thread
Anshumancanrock marked this conversation as resolved.
Dismissed
withAdminController(createGetAdminHealthController),
)
router.get(
'/metrics',
adminRateLimitMiddleware,
adminAuthMiddleware,
Comment thread
Anshumancanrock marked this conversation as resolved.
Dismissed
withAdminController(createGetAdminMetricsController),
Comment thread
Anshumancanrock marked this conversation as resolved.
Dismissed
)
router.get(
'/settings',
adminRateLimitMiddleware,
adminAuthMiddleware,
Comment thread
Anshumancanrock marked this conversation as resolved.
Dismissed
withAdminController(createGetAdminSettingsController),
)
router.get(
'/settings/backups',
adminRateLimitMiddleware,
Expand All @@ -49,19 +75,29 @@
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,
Comment thread
Anshumancanrock marked this conversation as resolved.
Dismissed
adminJsonBodyMiddleware,
adminAuthMiddleware,
Comment thread
Anshumancanrock marked this conversation as resolved.
Dismissed
withAdminController(createPatchAdminSettingsController),
)
// codeql[js/missing-rate-limiting] - adminRateLimitMiddleware applies Redis-backed admin rate limits
router.post(
'/settings/validate',
adminRateLimitMiddleware,
adminAuthGateMiddleware,
Comment thread
Anshumancanrock marked this conversation as resolved.
Dismissed
adminJsonBodyMiddleware,
adminAuthMiddleware,
withAdminController(createPostAdminSettingsValidateController),
)
router.post(
'/settings/restore',
adminRateLimitMiddleware,
adminAuthGateMiddleware,
Comment thread
Anshumancanrock marked this conversation as resolved.
Dismissed
adminJsonBodyMiddleware,
adminAuthMiddleware,

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
json(),
withAdminController(createPostAdminSettingsRestoreController),
)

Expand Down
Loading
Loading