Skip to content
Draft
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` as `https://<info.relay_url-host><public-path-prefix>/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. |
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
190 changes: 182 additions & 8 deletions src/handlers/request-handlers/admin-auth-middleware.ts
Original file line number Diff line number Diff line change
@@ -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' })
}
}
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',
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,
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,
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,

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