mirror of
https://github.com/standardnotes/server
synced 2026-10-16 18:13:09 -04:00
fix: Adds auth to admin and internal endpoints
This commit is contained in:
@@ -23,6 +23,8 @@ CACHE_TYPE=redis
|
||||
|
||||
AUTH_JWT_SECRET=
|
||||
AUTH_SERVER_ENCRYPTION_SERVER_KEY=
|
||||
# Optional. When unset, /admin and /internal stay unprotected. Set for a stable key across restarts.
|
||||
AUTH_SERVER_ADMIN_KEY=
|
||||
VALET_TOKEN_SECRET=
|
||||
|
||||
# (Optional) U2F Setup
|
||||
|
||||
@@ -175,6 +175,12 @@ if [ -z "$AUTH_SERVER_ENCRYPTION_SERVER_KEY" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
file_env 'AUTH_SERVER_ADMIN_KEY'
|
||||
if [ -z "$AUTH_SERVER_ADMIN_KEY" ]; then
|
||||
echo "AUTH_SERVER_ADMIN_KEY is not set. /admin and /internal endpoints are unprotected." >&2
|
||||
echo "To protect them, set AUTH_SERVER_ADMIN_KEY in your .env file (run 'openssl rand -hex 32' to generate a random string)." >&2
|
||||
fi
|
||||
|
||||
export AUTH_SERVER_SYNCING_SERVER_URL=http://localhost:$SYNCING_SERVER_PORT
|
||||
|
||||
# File Uploads
|
||||
|
||||
@@ -8,6 +8,9 @@ LEGACY_JWT_SECRET=legacy_jwt_secret
|
||||
AUTH_JWT_SECRET=auth_jwt_secret
|
||||
AUTH_JWT_TTL=60000
|
||||
|
||||
# Server-to-server auth for /admin and /internal endpoints. When unset, those routes stay unprotected.
|
||||
ADMIN_KEY=changeme
|
||||
|
||||
# Must be a hex string exactly 32 bytes long
|
||||
# e.g. feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308
|
||||
ENCRYPTION_SERVER_KEY=change-me-!
|
||||
|
||||
@@ -218,6 +218,7 @@ import { OfflineUserAuthMiddleware } from '../Infra/InversifyExpressUtils/Middle
|
||||
import { LockMiddleware } from '../Infra/InversifyExpressUtils/Middleware/LockMiddleware'
|
||||
import { RequiredCrossServiceTokenMiddleware } from '../Infra/InversifyExpressUtils/Middleware/RequiredCrossServiceTokenMiddleware'
|
||||
import { OptionalCrossServiceTokenMiddleware } from '../Infra/InversifyExpressUtils/Middleware/OptionalCrossServiceTokenMiddleware'
|
||||
import { AdminKeyMiddleware } from '../Infra/InversifyExpressUtils/Middleware/AdminKeyMiddleware'
|
||||
import { BaseSettingsController } from '../Infra/InversifyExpressUtils/Base/BaseSettingsController'
|
||||
import { BaseAdminController } from '../Infra/InversifyExpressUtils/Base/BaseAdminController'
|
||||
import { BaseAuthController } from '../Infra/InversifyExpressUtils/Base/BaseAuthController'
|
||||
@@ -350,6 +351,15 @@ export class ContainerConfigLoader {
|
||||
const isConfiguredForHomeServer = env.get('MODE', true) === 'home-server'
|
||||
const isConfiguredForSelfHosting = env.get('MODE', true) === 'self-hosted'
|
||||
const isConfiguredForHomeServerOrSelfHosting = isConfiguredForHomeServer || isConfiguredForSelfHosting
|
||||
|
||||
const adminKey = env.get('ADMIN_KEY', true) ?? ''
|
||||
|
||||
if (!adminKey) {
|
||||
logger.warn(
|
||||
'ADMIN_KEY is not set. /admin and /internal endpoints are unprotected until it is configured.',
|
||||
)
|
||||
}
|
||||
|
||||
const isConfiguredForInMemoryCache = env.get('CACHE_TYPE', true) === 'memory'
|
||||
const captchaServerUrl = env.get('CAPTCHA_SERVER_URL', true)
|
||||
const captchaUIUrl = env.get('CAPTCHA_UI_URL', true)
|
||||
@@ -993,6 +1003,9 @@ export class ContainerConfigLoader {
|
||||
),
|
||||
)
|
||||
|
||||
container.bind(TYPES.Auth_ADMIN_KEY).toConstantValue(adminKey)
|
||||
container.bind<AdminKeyMiddleware>(TYPES.Auth_AdminKeyMiddleware).to(AdminKeyMiddleware)
|
||||
|
||||
// Middleware
|
||||
container.bind<SessionMiddleware>(TYPES.Auth_SessionMiddleware).to(SessionMiddleware)
|
||||
container.bind<LockMiddleware>(TYPES.Auth_LockMiddleware).to(LockMiddleware)
|
||||
|
||||
@@ -65,6 +65,7 @@ const TYPES = {
|
||||
Auth_ApiGatewayOfflineAuthMiddleware: Symbol.for('Auth_ApiGatewayOfflineAuthMiddleware'),
|
||||
Auth_OfflineUserAuthMiddleware: Symbol.for('Auth_OfflineUserAuthMiddleware'),
|
||||
Auth_LockMiddleware: Symbol.for('Auth_LockMiddleware'),
|
||||
Auth_AdminKeyMiddleware: Symbol.for('Auth_AdminKeyMiddleware'),
|
||||
Auth_SessionMiddleware: Symbol.for('Auth_SessionMiddleware'),
|
||||
// Projectors
|
||||
Auth_SessionProjector: Symbol.for('Auth_SessionProjector'),
|
||||
@@ -102,6 +103,7 @@ const TYPES = {
|
||||
Auth_U2F_EXPECTED_ORIGIN: Symbol.for('Auth_U2F_EXPECTED_ORIGIN'),
|
||||
Auth_U2F_REQUIRE_USER_VERIFICATION: Symbol.for('Auth_U2F_REQUIRE_USER_VERIFICATION'),
|
||||
Auth_AUTHENTICATOR_CHALLENGE_MAX_AGE_SECONDS: Symbol.for('Auth_AUTHENTICATOR_CHALLENGE_MAX_AGE_SECONDS'),
|
||||
Auth_ADMIN_KEY: Symbol.for('Auth_ADMIN_KEY'),
|
||||
Auth_READONLY_USERS: Symbol.for('Auth_READONLY_USERS'),
|
||||
Auth_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING: Symbol.for('Auth_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING'),
|
||||
Auth_CAPTCHA_SERVER_URL: Symbol.for('Auth_CAPTCHA_SERVER_URL'),
|
||||
|
||||
@@ -17,7 +17,7 @@ import { DeleteSetting } from '../../Domain/UseCase/DeleteSetting/DeleteSetting'
|
||||
import { GetSetting } from './../../Domain/UseCase/GetSetting/GetSetting'
|
||||
import { UserRepositoryInterface } from '../../Domain/User/UserRepositoryInterface'
|
||||
|
||||
@controller('/admin')
|
||||
@controller('/admin', TYPES.Auth_AdminKeyMiddleware)
|
||||
export class AnnotatedAdminController extends BaseAdminController {
|
||||
constructor(
|
||||
@inject(TYPES.Auth_DeleteSetting) override doDeleteSetting: DeleteSetting,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { MapperInterface } from '@standardnotes/domain-core'
|
||||
import { Setting } from '../../Domain/Setting/Setting'
|
||||
import { SettingHttpRepresentation } from '../../Mapping/Http/SettingHttpRepresentation'
|
||||
|
||||
@controller('/internal')
|
||||
@controller('/internal', TYPES.Auth_AdminKeyMiddleware)
|
||||
export class AnnotatedInternalController extends BaseHttpController {
|
||||
constructor(
|
||||
@inject(TYPES.Auth_GetUserFeatures) private doGetUserFeatures: GetUserFeatures,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { AdminKeyMiddleware } from './AdminKeyMiddleware'
|
||||
|
||||
describe('AdminKeyMiddleware', () => {
|
||||
let request: Request
|
||||
let response: Response
|
||||
let next: NextFunction
|
||||
|
||||
const createMiddleware = (adminKey = 'correct-key') => new AdminKeyMiddleware(adminKey)
|
||||
|
||||
beforeEach(() => {
|
||||
request = {
|
||||
headers: {},
|
||||
} as jest.Mocked<Request>
|
||||
response = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
} as unknown as jest.Mocked<Response>
|
||||
next = jest.fn()
|
||||
})
|
||||
|
||||
it('should authorize when key is provided in x-admin-key header', () => {
|
||||
request.headers['x-admin-key'] = 'correct-key'
|
||||
|
||||
createMiddleware().handler(request, response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(response.status).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not authorize when key is missing', () => {
|
||||
createMiddleware().handler(request, response, next)
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(404)
|
||||
expect(response.send).toHaveBeenCalled()
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not authorize when key is incorrect', () => {
|
||||
request.headers['x-admin-key'] = 'wrong-key'
|
||||
|
||||
createMiddleware().handler(request, response, next)
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(404)
|
||||
expect(response.send).toHaveBeenCalled()
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should authorize when admin key is not configured', () => {
|
||||
createMiddleware('').handler(request, response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(response.status).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as crypto from 'crypto'
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { BaseMiddleware } from 'inversify-express-utils'
|
||||
|
||||
import TYPES from '../../../Bootstrap/Types'
|
||||
|
||||
@injectable()
|
||||
export class AdminKeyMiddleware extends BaseMiddleware {
|
||||
constructor(@inject(TYPES.Auth_ADMIN_KEY) private adminKey: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
handler(request: Request, response: Response, next: NextFunction): void {
|
||||
const incomingKey = this.getIncomingKey(request)
|
||||
|
||||
if (!this.isValidKey(incomingKey)) {
|
||||
response.status(404).send()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
private getIncomingKey(request: Request): string | undefined {
|
||||
const headerKey = request.headers['x-admin-key']
|
||||
if (typeof headerKey === 'string' && headerKey.length > 0) {
|
||||
return headerKey
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private isValidKey(incomingKey: string | undefined): boolean {
|
||||
if (!this.adminKey) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!incomingKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const incomingKeyDigest = crypto.createHash('sha256').update(incomingKey).digest()
|
||||
const adminKeyDigest = crypto.createHash('sha256').update(this.adminKey).digest()
|
||||
|
||||
return crypto.timingSafeEqual(incomingKeyDigest, adminKeyDigest)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ E2E_TESTING=false
|
||||
JWT_SECRET=
|
||||
AUTH_JWT_SECRET=
|
||||
ENCRYPTION_SERVER_KEY=
|
||||
# No effect in home-server today: admin/internal routes use Base* controllers via DirectCall, not AdminKeyMiddleware.
|
||||
ADMIN_KEY=
|
||||
PSEUDO_KEY_PARAMS_KEY=
|
||||
VALET_TOKEN_SECRET=
|
||||
|
||||
|
||||
Reference in New Issue
Block a user