fix: Adds auth to admin and internal endpoints

This commit is contained in:
Antonella Sgarlatta
2026-10-05 14:33:22 -03:00
committed by Antonella Sgarlatta
parent e1e9d1555d
commit 8399b244df
10 changed files with 137 additions and 2 deletions
+2
View File
@@ -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
+6
View File
@@ -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
+3
View File
@@ -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-!
+13
View File
@@ -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)
+2
View File
@@ -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)
}
}
+2
View File
@@ -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=