- Notifications
You must be signed in to change notification settings - Fork 234
feat(admin): accept NIP-98 auth on admin API routes#730
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
115a5ea1bea715cf09ca0bd3d3e80dea4a68bf9996e643772File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -284,3 +284,7 @@ limits: | ||
| admin: | ||
| enabled: false | ||
| sessionTtlSeconds: 86400 | ||
| nip98: | ||
| enabled: false | ||
| allowedPubkeys: [] | ||
| maxSkewSeconds: 60 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,154 @@ | ||
| 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, | ||
| isNostrAuthorizationHeader, | ||
| 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']) | ||
| const isAllowedNip98Pubkey = (pubkey: string, allowedPubkeys: string[] = []): boolean => { | ||
| const normalized = pubkey.toLowerCase() | ||
| return allowedPubkeys.some((allowed) => allowed.toLowerCase() === normalized) | ||
| } | ||
| const resolveBodyForNip98 = (request: AdminRequest): Buffer | undefined => { | ||
| if (!METHODS_WITH_BODY.has(request.method.toUpperCase())) { | ||
| return undefined | ||
| } | ||
| return request.rawBody ?? 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 | ||
| } | ||
| } | ||
| 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 !== true || !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 result = await verifyNip98Auth({ | ||
| authorizationHeader, | ||
| url: absoluteUrl, | ||
| method: request.method.toUpperCase(), | ||
| body: resolveBodyForNip98(request), | ||
| maxSkewSeconds: nip98Settings.maxSkewSeconds, | ||
| payloadPolicy: 'require-when-body', | ||
| }) | ||
| next() | ||
| 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' }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { json, Request, RequestHandler } from 'express' | ||
| export type AdminRequest = Request & { | ||
| rawBody?: Buffer | ||
| nip98Pubkey?: string | ||
| } | ||
| const ADMIN_JSON_BODY_LIMIT = '1mb' | ||
| 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) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| @@ -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, | ||
Anshumancanrock marked this conversation as resolved.
Dismissed
Uh oh!There was an error while loading. Please reload this page. | ||
| withAdminController(createGetAdminSessionController), | ||
| ) | ||
| router.get( | ||
| '/health', | ||
Anshumancanrock marked this conversation as resolved.
Dismissed
Uh oh!There was an error while loading. Please reload this page. | ||
| adminRateLimitMiddleware, | ||
| adminAuthMiddleware, | ||
Anshumancanrock marked this conversation as resolved.
Dismissed
Uh oh!There was an error while loading. Please reload this page. | ||
| withAdminController(createGetAdminHealthController), | ||
| ) | ||
| router.get( | ||
| '/metrics', | ||
| adminRateLimitMiddleware, | ||
| adminAuthMiddleware, | ||
Anshumancanrock marked this conversation as resolved.
Dismissed
Uh oh!There was an error while loading. Please reload this page. | ||
| withAdminController(createGetAdminMetricsController), | ||
Anshumancanrock marked this conversation as resolved.
Dismissed
Uh oh!There was an error while loading. Please reload this page. | ||
| ) | ||
| router.get( | ||
| '/settings', | ||
| adminRateLimitMiddleware, | ||
| adminAuthMiddleware, | ||
Anshumancanrock marked this conversation as resolved.
Dismissed
Uh oh!There was an error while loading. Please reload this page. | ||
| withAdminController(createGetAdminSettingsController), | ||
| ) | ||
| router.get( | ||
| '/settings/backups', | ||
| adminRateLimitMiddleware, | ||
| @@ -49,20 +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, | ||
Anshumancanrock marked this conversation as resolved.
Dismissed
Uh oh!There was an error while loading. Please reload this page. | ||
| adminJsonBodyMiddleware, | ||
| adminAuthMiddleware, | ||
Anshumancanrock marked this conversation as resolved.
Dismissed
Uh oh!There was an error while loading. Please reload this page. | ||
| withAdminController(createPatchAdminSettingsController), | ||
| ) | ||
| // codeql[js/missing-rate-limiting] - adminRateLimitMiddleware applies Redis-backed admin rate limits | ||
| router.post( | ||
| '/settings/validate', | ||
| adminRateLimitMiddleware, | ||
| adminAuthGateMiddleware, | ||
Anshumancanrock marked this conversation as resolved.
Dismissed
Uh oh!There was an error while loading. Please reload this page. Anshumancanrock marked this conversation as resolved.
Dismissed
Uh oh!There was an error while loading. Please reload this page. | ||
| adminAuthMiddleware, | ||
| withAdminController(createPostAdminSettingsValidateController), | ||
| ) | ||
| router.post( | ||
| '/settings/restore', | ||
| adminRateLimitMiddleware, | ||
| adminAuthGateMiddleware, | ||
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Uh oh!There was an error while loading. Please reload this page. | ||
| adminJsonBodyMiddleware, | ||
| adminAuthMiddleware, | ||
| json(), | ||
| withAdminController(createPostAdminSettingsRestoreController), | ||
Check failureCode scanning / CodeQL Missing rate limiting High
This route handler performs authorization Error loading related location LoadingUh oh!There was an error while loading. Please reload this page. This route handler performs authorization Error loading related location LoadingUh oh!There was an error while loading. Please reload this page. This route handler performs authorization Error loading related location LoadingUh oh!There was an error while loading. Please reload this page. This route handler performs authorization Error loading related location LoadingUh oh!There was an error while loading. Please reload this page. | ||
| ) | ||
| export default router | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.