Repository navigation
fix(api): stop signing sessions with a public default secret #3129
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
Changes from all commits
1c17fa9
954a8bc
8479490
5360d26
6fdcb54
9189377
61d0644
4867b0a
4f1ebc4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| --- | ||
| '@hyperdx/api': minor | ||
| --- | ||
|
|
||
| The API no longer falls back to the public session secret `hyperdx is cool 👋`. | ||
| If `EXPRESS_SESSION_SECRET` is unset, the API generates a random secret and | ||
| reports it once at startup when authentication is enabled. Authenticated | ||
| deployments that explicitly set the old public value also get a random secret | ||
| and the same warning; the public value remains available only in the no-auth | ||
| local demo. Set it to a random string (`openssl rand -hex 32`) in any deployment | ||
| with authentication enabled — without it, restarting the API signs every user | ||
| out, and replicas do not share sessions. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,7 +22,10 @@ export CLICKHOUSE_PROMETHEUS_METRICS_ENDPOINT="${CLICKHOUSE_PROMETHEUS_METRICS_E | |
| # (IS_LOCAL_IMAGE / IS_ALL_IN_ONE_IMAGE). The auth variant must report | ||
| # "all-in-one-auth" so that the INGESTION_API_KEY bootstrap path is enabled. | ||
| export HYPERDX_IMAGE=$([[ "${IS_LOCAL_APP_MODE}" == "DANGEROUSLY_is_local_app_mode💀" ]] && echo "all-in-one-noauth" || echo "all-in-one-auth") | ||
| export EXPRESS_SESSION_SECRET="hyperdx is cool 👋" | ||
| # Keep the public demo default only when authentication is disabled. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Local-mode carve-out for the public secret protects nothing and cannot matter Remove the shell default and the Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| if [[ "${IS_LOCAL_APP_MODE}" == "DANGEROUSLY_is_local_app_mode💀" ]]; then | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 major — All-in-one auth image now signs users out on every restart, and the only warning goes to a file This script used to export the public secret for every all-in-one image. Now the auth variant ( Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond |
||
| export EXPRESS_SESSION_SECRET="${EXPRESS_SESSION_SECRET:-hyperdx is cool 👋}" | ||
| fi | ||
| # IS_LOCAL_APP_MODE should be set by the calling script | ||
| # Default to dangerous mode if not set | ||
| export IS_LOCAL_APP_MODE="${IS_LOCAL_APP_MODE}" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { IncomingMessage, ServerResponse } from 'http'; | ||
| import { Socket } from 'net'; | ||
|
|
||
| jest.mock('@/api-app', () => ({ __esModule: true, default: jest.fn() })); | ||
| jest.mock('@/controllers/team', () => ({ | ||
| LOCAL_APP_TEAM: { _id: { toString: () => 'local-team' } }, | ||
| })); | ||
| jest.mock('@/migrations', () => ({ runStartupMigrations: jest.fn() })); | ||
| jest.mock('@/models', () => ({ | ||
| connectDB: jest.fn().mockResolvedValue(undefined), | ||
| connectDBWithRetry: jest.fn().mockResolvedValue(undefined), | ||
| })); | ||
| jest.mock('@/opamp/app', () => ({ __esModule: true, default: jest.fn() })); | ||
| jest.mock('@/setupDefaults', () => ({ setupTeamDefaults: jest.fn() })); | ||
| jest.mock('@/utils/tokenEncryption', () => ({ | ||
| verifyTokenEncryption: jest.fn(), | ||
| })); | ||
| jest.mock('@/utils/logger', () => ({ | ||
| __esModule: true, | ||
| default: { error: jest.fn(), info: jest.fn() }, | ||
| })); | ||
| jest.mock('@/utils/instrumentation', () => ({ | ||
| getCounter: jest.fn(() => ({ add: jest.fn() })), | ||
| })); | ||
| jest.mock('http-graceful-shutdown', () => jest.fn()); | ||
| jest.mock('http', () => ({ | ||
| ...jest.requireActual('http'), | ||
| createServer: jest.fn(() => ({ listen: jest.fn() })), | ||
| })); | ||
|
|
||
| describe.each(['server', 'serverless'])( | ||
| 'session secret startup diagnostics (%s first)', | ||
| first => { | ||
| const originalEnv = { ...process.env }; | ||
|
|
||
| afterEach(() => { | ||
| process.env = { ...originalEnv }; | ||
| jest.resetModules(); | ||
| }); | ||
|
|
||
| it.each([ | ||
| [false, undefined, 1], | ||
| [false, '', 1], | ||
| [false, 'hyperdx is cool 👋', 1], | ||
| [false, 'startup-secret-sentinel', 0], | ||
| [true, undefined, 0], | ||
| [true, 'hyperdx is cool 👋', 0], | ||
| [true, 'startup-secret-sentinel', 0], | ||
| ])( | ||
| 'local=%s, secret=%s logs %s times across both entrypoints', | ||
| async (local, secret, count) => { | ||
| process.env.APP_TYPE = 'api'; | ||
| if (local) { | ||
| process.env.IS_LOCAL_APP_MODE = 'DANGEROUSLY_is_local_app_mode💀'; | ||
| } else { | ||
| delete process.env.IS_LOCAL_APP_MODE; | ||
| } | ||
| if (typeof secret === 'undefined') | ||
| delete process.env.EXPRESS_SESSION_SECRET; | ||
| else process.env.EXPRESS_SESSION_SECRET = secret; | ||
|
|
||
| const { default: logger } = | ||
| jest.requireMock<typeof import('@/utils/logger')>('@/utils/logger'); | ||
| const { getCounter } = jest.requireMock< | ||
| typeof import('@/utils/instrumentation') | ||
| >('@/utils/instrumentation'); | ||
| const { default: Server } = | ||
| jest.requireActual<typeof import('@/server')>('@/server'); | ||
| const { serverlessHandler } = | ||
| jest.requireActual<typeof import('@/serverless')>('@/serverless'); | ||
| const config = | ||
| jest.requireActual<typeof import('@/config')>('@/config'); | ||
| expect(logger.error).not.toHaveBeenCalled(); | ||
|
|
||
| const req = new IncomingMessage(new Socket()); | ||
| req.url = '/api/health'; | ||
| const res = new ServerResponse(req); | ||
| if (first === 'server') await new Server().start(); | ||
| await serverlessHandler(req, res); | ||
| await new Server().start(); | ||
| await serverlessHandler(req, res); | ||
|
|
||
| expect(logger.error).toHaveBeenCalledTimes(count); | ||
| const counter = jest.mocked(getCounter).mock.results[0].value; | ||
| expect(counter.add).toHaveBeenCalledTimes(count); | ||
| expect( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The no-leak assertion can never fail: every case either never logs or logs a fixed message
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| JSON.stringify(jest.mocked(logger.error).mock.calls), | ||
| ).not.toContain(config.EXPRESS_SESSION_SECRET); | ||
| }, | ||
| ); | ||
| }, | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,9 @@ | ||
| import crypto from 'crypto'; | ||
|
|
||
| const env = process.env; | ||
|
|
||
| // DEFAULTS | ||
| const DEFAULT_APP_TYPE = 'api'; | ||
| const DEFAULT_EXPRESS_SESSION = 'hyperdx is cool 👋'; | ||
| const DEFAULT_FRONTEND_URL = env.HYPERDX_APP_PORT | ||
| ? `http://localhost:${env.HYPERDX_APP_PORT}` | ||
| : ''; | ||
|
|
@@ -13,8 +14,20 @@ export const APP_TYPE = (env.APP_TYPE || DEFAULT_APP_TYPE) as | |
| | 'api' | ||
| | 'scheduled-task'; | ||
| export const CODE_VERSION = env.CODE_VERSION ?? ''; | ||
| export const EXPRESS_SESSION_SECRET = (env.EXPRESS_SESSION_SECRET || | ||
| DEFAULT_EXPRESS_SESSION) as string; | ||
| // Only for single container local deployments, disable authentication | ||
| export const IS_LOCAL_APP_MODE = | ||
| env.IS_LOCAL_APP_MODE === 'DANGEROUSLY_is_local_app_mode💀'; | ||
| const LEGACY_PUBLIC_SESSION_SECRET = 'hyperdx is cool 👋'; | ||
| // Authenticated deployments never use the public demo secret. Without a safe | ||
| // configured value, generate one per process (sessions end on restart). | ||
| export const IS_EXPRESS_SESSION_SECRET_GENERATED = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The no-auth exemption for the public secret has no effect, but keeps the public string in the code Drop the Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| !env.EXPRESS_SESSION_SECRET || | ||
| (!IS_LOCAL_APP_MODE && | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Keeping the public secret in no-auth mode changes nothing, but adds a branch in config, the entry script and two tests In local app mode, passport is never initialized ( Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| env.EXPRESS_SESSION_SECRET === LEGACY_PUBLIC_SESSION_SECRET); | ||
| export const EXPRESS_SESSION_SECRET = | ||
| !IS_EXPRESS_SESSION_SECRET_GENERATED && env.EXPRESS_SESSION_SECRET | ||
| ? env.EXPRESS_SESSION_SECRET | ||
| : crypto.randomBytes(32).toString('hex'); | ||
| export const FRONTEND_URL = (env.FRONTEND_URL || | ||
| DEFAULT_FRONTEND_URL) as string; | ||
| const HYPERDX_IMAGE = env.HYPERDX_IMAGE; | ||
|
|
@@ -52,10 +65,6 @@ export const RUN_SCHEDULED_TASKS_EXTERNALLY = | |
| // utils/tokenEncryption.ts. | ||
| export const TOKEN_ENCRYPTION_KEY = env.TOKEN_ENCRYPTION_KEY; | ||
|
|
||
| // Only for single container local deployments, disable authentication | ||
| export const IS_LOCAL_APP_MODE = | ||
| env.IS_LOCAL_APP_MODE === 'DANGEROUSLY_is_local_app_mode💀'; | ||
|
|
||
| // Only used to bootstrap empty instances | ||
| export const DEFAULT_CONNECTIONS = env.DEFAULT_CONNECTIONS; | ||
| export const DEFAULT_SOURCES = env.DEFAULT_SOURCES; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { | ||
| IS_EXPRESS_SESSION_SECRET_GENERATED, | ||
| IS_LOCAL_APP_MODE, | ||
| } from '@/config'; | ||
| import { getCounter } from '@/utils/instrumentation'; | ||
| import logger from '@/utils/logger'; | ||
|
|
||
| const generatedSecretCounter = getCounter('hyperdx.session_secret.generated', { | ||
| description: | ||
| 'API processes using a generated session secret with authentication enabled.', | ||
| }); | ||
|
|
||
| let reported = false; | ||
|
|
||
| export function verifySessionSecret(): void { | ||
| if (reported || IS_LOCAL_APP_MODE || !IS_EXPRESS_SESSION_SECRET_GENERATED) | ||
| return; | ||
| reported = true; | ||
| generatedSecretCounter.add(1); | ||
| logger.error( | ||
| 'EXPRESS_SESSION_SECRET is missing or uses the public legacy value: generated a random secret for this process. Users are signed out whenever it restarts, and replicas do not share sessions. Configure a random secret shared by all replicas to keep sessions.', | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 minor — README's all-in-one quickstart now signs users out on every restart and logs an error on every boot, with no guidance
Add
-e EXPRESS_SESSION_SECRET=$(openssl rand -hex 32)(or a note pointing to it) to thehyperdx-all-in-onecommand inREADME.md:52. That image runs the auth variant, andentry.local.base.shno longer exports a secret for it, so following the headline quickstart now hits the generated-secret path. The new docs only cover thehyperdximage and compose.Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond