From 8399b244df50b91b16fb66d838cb608f47742e9e Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Thu, 17 Sep 2026 21:47:54 +0000 Subject: [PATCH] fix: Adds auth to admin and internal endpoints --- .env.sample | 2 + docker/docker-entrypoint.sh | 6 ++ packages/auth/.env.sample | 3 + packages/auth/src/Bootstrap/Container.ts | 13 +++++ packages/auth/src/Bootstrap/Types.ts | 2 + .../AnnotatedAdminController.ts | 2 +- .../AnnotatedInternalController.ts | 2 +- .../Middleware/AdminKeyMiddleware.spec.ts | 58 +++++++++++++++++++ .../Middleware/AdminKeyMiddleware.ts | 49 ++++++++++++++++ packages/home-server/.env.sample | 2 + 10 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 packages/auth/src/Infra/InversifyExpressUtils/Middleware/AdminKeyMiddleware.spec.ts create mode 100644 packages/auth/src/Infra/InversifyExpressUtils/Middleware/AdminKeyMiddleware.ts diff --git a/.env.sample b/.env.sample index 40eef32d9..4364402dc 100644 --- a/.env.sample +++ b/.env.sample @@ -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 diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 0e2b2875e..00c013623 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -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 diff --git a/packages/auth/.env.sample b/packages/auth/.env.sample index 58745fd34..7af075645 100644 --- a/packages/auth/.env.sample +++ b/packages/auth/.env.sample @@ -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-! diff --git a/packages/auth/src/Bootstrap/Container.ts b/packages/auth/src/Bootstrap/Container.ts index 6261c3303..a2176aaf1 100644 --- a/packages/auth/src/Bootstrap/Container.ts +++ b/packages/auth/src/Bootstrap/Container.ts @@ -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(TYPES.Auth_AdminKeyMiddleware).to(AdminKeyMiddleware) + // Middleware container.bind(TYPES.Auth_SessionMiddleware).to(SessionMiddleware) container.bind(TYPES.Auth_LockMiddleware).to(LockMiddleware) diff --git a/packages/auth/src/Bootstrap/Types.ts b/packages/auth/src/Bootstrap/Types.ts index e90c200e0..a93094b77 100644 --- a/packages/auth/src/Bootstrap/Types.ts +++ b/packages/auth/src/Bootstrap/Types.ts @@ -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'), diff --git a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAdminController.ts b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAdminController.ts index d1faf7f9d..097ca5a30 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAdminController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAdminController.ts @@ -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, diff --git a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedInternalController.ts b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedInternalController.ts index 35fbdbf39..c9f88226b 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedInternalController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedInternalController.ts @@ -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, diff --git a/packages/auth/src/Infra/InversifyExpressUtils/Middleware/AdminKeyMiddleware.spec.ts b/packages/auth/src/Infra/InversifyExpressUtils/Middleware/AdminKeyMiddleware.spec.ts new file mode 100644 index 000000000..6fc3e6484 --- /dev/null +++ b/packages/auth/src/Infra/InversifyExpressUtils/Middleware/AdminKeyMiddleware.spec.ts @@ -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 + response = { + status: jest.fn().mockReturnThis(), + send: jest.fn(), + } as unknown as jest.Mocked + 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() + }) +}) diff --git a/packages/auth/src/Infra/InversifyExpressUtils/Middleware/AdminKeyMiddleware.ts b/packages/auth/src/Infra/InversifyExpressUtils/Middleware/AdminKeyMiddleware.ts new file mode 100644 index 000000000..022dd87a6 --- /dev/null +++ b/packages/auth/src/Infra/InversifyExpressUtils/Middleware/AdminKeyMiddleware.ts @@ -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) + } +} diff --git a/packages/home-server/.env.sample b/packages/home-server/.env.sample index 480ac6e42..5512bee87 100644 --- a/packages/home-server/.env.sample +++ b/packages/home-server/.env.sample @@ -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=