From 1c17fa9fe8708f425084c29a748b8ca7eef2bf77 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:18:05 +0800 Subject: [PATCH 1/5] fix(api): stop signing sessions with a public default secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EXPRESS_SESSION_SECRET` fell back to the literal `hyperdx is cool πŸ‘‹`, so anyone able to read the source could sign a `connect.sid` for a session id of their choosing. Generate a random 32-byte secret per process instead, and warn at startup, because a generated secret means every user is signed out when the process restarts and replicas no longer share sessions. A configured secret is still honoured, except for the public default older releases shipped β€” that is replaced like an unset one, so existing deployments stop signing with it without having to notice. The local all-in-one entry script no longer exports the placeholder for the same reason. Refs #3089 --- .../express-session-secret-no-default.md | 9 +++ DEPLOY.md | 10 ++-- agent_docs/development.md | 2 +- docker/hyperdx/entry.local.base.sh | 3 +- packages/api/.env.development | 2 +- packages/api/.env.test | 2 +- packages/api/src/__tests__/config.test.ts | 56 +++++++++++++++++++ packages/api/src/api-app.ts | 6 ++ packages/api/src/config.ts | 11 +++- 9 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 .changeset/express-session-secret-no-default.md diff --git a/.changeset/express-session-secret-no-default.md b/.changeset/express-session-secret-no-default.md new file mode 100644 index 0000000000..aaccfb8a25 --- /dev/null +++ b/.changeset/express-session-secret-no-default.md @@ -0,0 +1,9 @@ +--- +'@hyperdx/api': patch +--- + +Session cookies are no longer signed with the public default +`hyperdx is cool πŸ‘‹`. If `EXPRESS_SESSION_SECRET` is unset, the API generates a +random secret at startup and logs a warning. 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. diff --git a/DEPLOY.md b/DEPLOY.md index d399049227..5afa6f747e 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -27,8 +27,9 @@ You'll need to set the `MONGO_URI` environment variable to the URI of your MongoDB instance. Afterwards, you'll want to visit http://localhost:8080 to set up your connection with ClickHouse. -Before deploying into production, you'll want to set the -`EXPRESS_SESSION_SECRET` environment variable to a random string. +Before deploying into production, set the `EXPRESS_SESSION_SECRET` environment +variable to a random string (e.g. `openssl rand -hex 32`). Without it the API +generates one per process, which signs users out on every restart. To customize the frontend URL, set the `FRONTEND_URL` environment variable to the URL your HyperDX instance is hosted on. @@ -55,8 +56,9 @@ such as ufw. See the [Docker docs](https://docs.docker.com/engine/network/packet-filtering-firewalls/#docker-and-ufw) for more information. -Additionally, you'll want to set the `EXPRESS_SESSION_SECRET` environment -variable to a random string. +Additionally, set the `EXPRESS_SESSION_SECRET` environment variable to a random +string (e.g. `openssl rand -hex 32`). Without it the API generates one per +process, which signs users out on every restart. ## Local Development Mode diff --git a/agent_docs/development.md b/agent_docs/development.md index 0ec88a7c4f..bf605a9ccf 100644 --- a/agent_docs/development.md +++ b/agent_docs/development.md @@ -292,7 +292,7 @@ deployment serves both the app and the API. |---|---| | `HDX_PREVIEW_INLINE_API` | `true` β€” turns on the inline path | | `MONGO_URI` | Preview MongoDB connection string | -| `EXPRESS_SESSION_SECRET` | Random 32+ char string | +| `EXPRESS_SESSION_SECRET` | Random 32+ char string. Unset means each serverless instance generates its own, so a session from one instance is rejected by the next | | `DISABLED_AUTH_METHODS` | `google,saml` (avoids per-preview OAuth callback URL config) | | `AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL_NAME` | Optional, only if AI features should work | | `FRONTEND_URL` | Optional. Leave unset to use host-only cookies on the preview URL | diff --git a/docker/hyperdx/entry.local.base.sh b/docker/hyperdx/entry.local.base.sh index c0ad13b416..6aed46eb60 100644 --- a/docker/hyperdx/entry.local.base.sh +++ b/docker/hyperdx/entry.local.base.sh @@ -22,7 +22,8 @@ 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 πŸ‘‹" +# EXPRESS_SESSION_SECRET is intentionally unset so images don't ship a public +# one; the API generates a random secret per process when it is missing. # 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}" diff --git a/packages/api/.env.development b/packages/api/.env.development index 8ba0a6c0c8..3b83a1fd9d 100644 --- a/packages/api/.env.development +++ b/packages/api/.env.development @@ -1,6 +1,6 @@ # Ports come from scripts/dev-env.sh (worktree isolation) or root .env (defaults) HYPERDX_LOG_LEVEL=debug -EXPRESS_SESSION_SECRET="hyperdx is cool πŸ‘‹" +# EXPRESS_SESSION_SECRET is intentionally unset; see packages/api/src/config.ts FRONTEND_URL="http://localhost:${HYPERDX_APP_PORT}" HDX_NODE_ADVANCED_NETWORK_CAPTURE=1 HDX_NODE_BETA_MODE=1 diff --git a/packages/api/.env.test b/packages/api/.env.test index 0b67f560d1..fb5ec15f91 100644 --- a/packages/api/.env.test +++ b/packages/api/.env.test @@ -6,7 +6,7 @@ CLICKHOUSE_HOST=http://localhost:${HDX_CI_CH_PORT:-18123} CLICKHOUSE_PASSWORD=api CLICKHOUSE_USER=api RUN_SCHEDULED_TASKS_EXTERNALLY=true -EXPRESS_SESSION_SECRET="hyperdx is cool πŸ‘‹" +# EXPRESS_SESSION_SECRET is intentionally unset; see packages/api/src/config.ts FRONTEND_URL=http://app:8080 MONGO_URI=mongodb://localhost:${HDX_CI_MONGO_PORT:-39999}/hyperdx-test NODE_ENV=test diff --git a/packages/api/src/__tests__/config.test.ts b/packages/api/src/__tests__/config.test.ts index fedf486e0d..7928150b01 100644 --- a/packages/api/src/__tests__/config.test.ts +++ b/packages/api/src/__tests__/config.test.ts @@ -59,4 +59,60 @@ describe('config', () => { }); }); }); + + describe('EXPRESS_SESSION_SECRET', () => { + const ORIGINAL_SECRET = process.env.EXPRESS_SESSION_SECRET; + + afterEach(() => { + if (ORIGINAL_SECRET === undefined) { + delete process.env.EXPRESS_SESSION_SECRET; + } else { + process.env.EXPRESS_SESSION_SECRET = ORIGINAL_SECRET; + } + jest.resetModules(); + }); + + // The `jest` object's isolateModules returns `jest` itself rather than the + // callback's value, so the module has to be captured from inside it. + const loadConfig = () => { + let loaded!: typeof import('@/config'); + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, n/no-missing-require + loaded = require('@/config'); + }); + return loaded; + }; + + it('uses a configured secret verbatim', () => { + process.env.EXPRESS_SESSION_SECRET = 'a-deployment-specific-secret'; + + const config = loadConfig(); + expect(config.EXPRESS_SESSION_SECRET).toBe( + 'a-deployment-specific-secret', + ); + expect(config.IS_EXPRESS_SESSION_SECRET_GENERATED).toBe(false); + }); + + it('generates a secret when unset or empty', () => { + for (const value of [undefined, '']) { + if (value === undefined) { + delete process.env.EXPRESS_SESSION_SECRET; + } else { + process.env.EXPRESS_SESSION_SECRET = value; + } + + const config = loadConfig(); + expect(config.EXPRESS_SESSION_SECRET).toMatch(/^[0-9a-f]{64}$/); + expect(config.IS_EXPRESS_SESSION_SECRET_GENERATED).toBe(true); + } + }); + + it('generates a different secret on each load', () => { + delete process.env.EXPRESS_SESSION_SECRET; + + expect(loadConfig().EXPRESS_SESSION_SECRET).not.toBe( + loadConfig().EXPRESS_SESSION_SECRET, + ); + }); + }); }); diff --git a/packages/api/src/api-app.ts b/packages/api/src/api-app.ts index 5e336d0d8d..44b2ddcd92 100644 --- a/packages/api/src/api-app.ts +++ b/packages/api/src/api-app.ts @@ -42,6 +42,12 @@ const sess: session.SessionOptions & { cookie: session.CookieOptions } = { store: new MongoStore({ mongoUrl: config.MONGO_URI }), }; +if (config.IS_EXPRESS_SESSION_SECRET_GENERATED) { + logger.warn( + 'EXPRESS_SESSION_SECRET is not set: generated a random secret for this process. Users are signed out whenever it restarts, and replicas do not share sessions. Set it to a random string to keep sessions.', + ); +} + app.set('trust proxy', 1); if (!config.IS_CI && config.FRONTEND_URL) { const feUrl = new URL(config.FRONTEND_URL); diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index cdb50bd3bf..8b58112716 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -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,12 @@ 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; +// There is deliberately no fallback: a secret committed to this repo lets +// anyone who can read it sign a `connect.sid` for any session id. Without one +// we generate it per process, which signs every user out when that restarts. +export const IS_EXPRESS_SESSION_SECRET_GENERATED = !env.EXPRESS_SESSION_SECRET; +export const 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; From 954a8bcf072bc35bc37ece5d0054e4d14b7dd900 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:47:04 +0800 Subject: [PATCH 2/5] Update DEPLOY.md Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- DEPLOY.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DEPLOY.md b/DEPLOY.md index 5afa6f747e..b6e9136f46 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -29,7 +29,8 @@ up your connection with ClickHouse. Before deploying into production, set the `EXPRESS_SESSION_SECRET` environment variable to a random string (e.g. `openssl rand -hex 32`). Without it the API -generates one per process, which signs users out on every restart. +generates one per process, which signs users out on every restart and prevents +replicas from sharing sessions. To customize the frontend URL, set the `FRONTEND_URL` environment variable to the URL your HyperDX instance is hosted on. From 5360d26a667c4c67c4fa7d0cce6a067f43d759fb Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:42:06 +0800 Subject: [PATCH 3/5] fix(api): reject public session secret in authenticated mode --- .../express-session-secret-no-default.md | 14 ++++--- DEPLOY.md | 5 ++- docker/hyperdx/entry.local.base.sh | 7 ++-- packages/api/src/__tests__/config.test.ts | 42 ++++++++++++------- .../__tests__/sessionSecretStartup.test.ts | 2 + packages/api/src/config.ts | 22 ++++++---- packages/api/src/utils/sessionSecret.ts | 2 +- 7 files changed, 57 insertions(+), 37 deletions(-) diff --git a/.changeset/express-session-secret-no-default.md b/.changeset/express-session-secret-no-default.md index abb146abb5..7f12b91da4 100644 --- a/.changeset/express-session-secret-no-default.md +++ b/.changeset/express-session-secret-no-default.md @@ -2,9 +2,11 @@ '@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. -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. +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. diff --git a/DEPLOY.md b/DEPLOY.md index cf70f2d44b..5e8fef1105 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -30,7 +30,8 @@ up your connection with ClickHouse. Before deploying into production, set the `EXPRESS_SESSION_SECRET` environment variable to a random string (e.g. `openssl rand -hex 32`). Without it the API generates one per process, which signs users out on every restart and prevents -replicas from sharing sessions. +replicas from sharing sessions. The old public value `hyperdx is cool πŸ‘‹` is +also replaced with a per-process secret when authentication is enabled. To customize the frontend URL, set the `FRONTEND_URL` environment variable to the URL your HyperDX instance is hosted on. @@ -68,7 +69,7 @@ docker compose --env-file .env --env-file .env.local up -d Reuse the same secret across restarts and all replicas. Without it the API generates one per process, which signs users out on every restart and prevents -replicas from sharing sessions. +replicas from sharing sessions. The old public value is not a production secret. ## Local Development Mode diff --git a/docker/hyperdx/entry.local.base.sh b/docker/hyperdx/entry.local.base.sh index c7931c3fe8..b46fad6b96 100644 --- a/docker/hyperdx/entry.local.base.sh +++ b/docker/hyperdx/entry.local.base.sh @@ -22,9 +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") -# Public demo default keeps playground sessions stable across restarts. -# Production deployments must supply their own random secret, shared by replicas. -export EXPRESS_SESSION_SECRET="${EXPRESS_SESSION_SECRET:-hyperdx is cool πŸ‘‹}" +# Keep the public demo default only when authentication is disabled. +if [[ "${IS_LOCAL_APP_MODE}" == "DANGEROUSLY_is_local_app_modeπŸ’€" ]]; then + 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}" diff --git a/packages/api/src/__tests__/config.test.ts b/packages/api/src/__tests__/config.test.ts index 788c6192d1..b95febb4a4 100644 --- a/packages/api/src/__tests__/config.test.ts +++ b/packages/api/src/__tests__/config.test.ts @@ -126,6 +126,11 @@ describe('config', () => { describe('EXPRESS_SESSION_SECRET', () => { const ORIGINAL_SECRET = process.env.EXPRESS_SESSION_SECRET; + const ORIGINAL_LOCAL_APP_MODE = process.env.IS_LOCAL_APP_MODE; + + beforeEach(() => { + delete process.env.IS_LOCAL_APP_MODE; + }); afterEach(() => { if (ORIGINAL_SECRET === undefined) { @@ -133,6 +138,11 @@ describe('config', () => { } else { process.env.EXPRESS_SESSION_SECRET = ORIGINAL_SECRET; } + if (ORIGINAL_LOCAL_APP_MODE === undefined) { + delete process.env.IS_LOCAL_APP_MODE; + } else { + process.env.IS_LOCAL_APP_MODE = ORIGINAL_LOCAL_APP_MODE; + } jest.resetModules(); }); @@ -157,24 +167,15 @@ describe('config', () => { expect(config.IS_EXPRESS_SESSION_SECRET_GENERATED).toBe(false); }); - it.each([' verbatim secret πŸ‘‹ ', 'hyperdx is cool πŸ‘‹'])( - 'preserves explicitly configured values: %s', - secret => { - process.env.EXPRESS_SESSION_SECRET = secret; - expect(loadConfig().EXPRESS_SESSION_SECRET).toBe(secret); - expect(loadConfig().IS_EXPRESS_SESSION_SECRET_GENERATED).toBe(false); - }, - ); - - it('keeps a configured secret across process reloads and replicas', () => { - process.env.EXPRESS_SESSION_SECRET = 'stable-session-sentinel'; - expect(loadConfig().EXPRESS_SESSION_SECRET).toBe( - loadConfig().EXPRESS_SESSION_SECRET, - ); + it('preserves a configured secret verbatim', () => { + process.env.EXPRESS_SESSION_SECRET = ' verbatim secret πŸ‘‹ '; + const config = loadConfig(); + expect(config.EXPRESS_SESSION_SECRET).toBe(' verbatim secret πŸ‘‹ '); + expect(config.IS_EXPRESS_SESSION_SECRET_GENERATED).toBe(false); }); - it('generates a secret when unset or empty', () => { - for (const value of [undefined, '']) { + it('generates a secret when unset, empty, or publicly known', () => { + for (const value of [undefined, '', 'hyperdx is cool πŸ‘‹']) { if (value === undefined) { delete process.env.EXPRESS_SESSION_SECRET; } else { @@ -187,6 +188,15 @@ describe('config', () => { } }); + it('keeps the public demo secret only without authentication', () => { + process.env.IS_LOCAL_APP_MODE = 'DANGEROUSLY_is_local_app_modeπŸ’€'; + process.env.EXPRESS_SESSION_SECRET = 'hyperdx is cool πŸ‘‹'; + + const config = loadConfig(); + expect(config.EXPRESS_SESSION_SECRET).toBe('hyperdx is cool πŸ‘‹'); + expect(config.IS_EXPRESS_SESSION_SECRET_GENERATED).toBe(false); + }); + it('generates a different secret on each load', () => { delete process.env.EXPRESS_SESSION_SECRET; diff --git a/packages/api/src/__tests__/sessionSecretStartup.test.ts b/packages/api/src/__tests__/sessionSecretStartup.test.ts index 739283db9a..24379c6bab 100644 --- a/packages/api/src/__tests__/sessionSecretStartup.test.ts +++ b/packages/api/src/__tests__/sessionSecretStartup.test.ts @@ -41,8 +41,10 @@ describe.each(['server', 'serverless'])( 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', diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index f36a562dd1..fb920c9159 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -14,12 +14,20 @@ export const APP_TYPE = (env.APP_TYPE || DEFAULT_APP_TYPE) as | 'api' | 'scheduled-task'; export const CODE_VERSION = env.CODE_VERSION ?? ''; -// There is deliberately no fallback: a secret committed to this repo lets -// anyone who can read it sign a `connect.sid` for any session id. Without one -// we generate it per process, which signs every user out when that restarts. -export const IS_EXPRESS_SESSION_SECRET_GENERATED = !env.EXPRESS_SESSION_SECRET; +// 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 = + !env.EXPRESS_SESSION_SECRET || + (!IS_LOCAL_APP_MODE && + env.EXPRESS_SESSION_SECRET === LEGACY_PUBLIC_SESSION_SECRET); export const EXPRESS_SESSION_SECRET = - env.EXPRESS_SESSION_SECRET || crypto.randomBytes(32).toString('hex'); + !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; @@ -57,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; diff --git a/packages/api/src/utils/sessionSecret.ts b/packages/api/src/utils/sessionSecret.ts index 31f6ec5295..794d57b560 100644 --- a/packages/api/src/utils/sessionSecret.ts +++ b/packages/api/src/utils/sessionSecret.ts @@ -18,6 +18,6 @@ export function verifySessionSecret(): void { reported = true; generatedSecretCounter.add(1); logger.error( - 'EXPRESS_SESSION_SECRET is not set: 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.', + '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.', ); } From 6fdcb5422b9d33fd9d63120ac8aaa919336c3542 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:33:55 +0800 Subject: [PATCH 4/5] test(api): avoid lint escape hatches in session secret tests --- packages/api/src/__tests__/config.test.ts | 3 +-- .../__tests__/sessionSecretStartup.test.ts | 26 ++++++++----------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/api/src/__tests__/config.test.ts b/packages/api/src/__tests__/config.test.ts index b95febb4a4..88f17a9d71 100644 --- a/packages/api/src/__tests__/config.test.ts +++ b/packages/api/src/__tests__/config.test.ts @@ -151,8 +151,7 @@ describe('config', () => { const loadConfig = () => { let loaded!: typeof import('@/config'); jest.isolateModules(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports, n/no-missing-require - loaded = require('@/config'); + loaded = jest.requireActual('@/config'); }); return loaded; }; diff --git a/packages/api/src/__tests__/sessionSecretStartup.test.ts b/packages/api/src/__tests__/sessionSecretStartup.test.ts index 24379c6bab..9f17c1864e 100644 --- a/packages/api/src/__tests__/sessionSecretStartup.test.ts +++ b/packages/api/src/__tests__/sessionSecretStartup.test.ts @@ -59,21 +59,17 @@ describe.each(['server', 'serverless'])( delete process.env.EXPRESS_SESSION_SECRET; else process.env.EXPRESS_SESSION_SECRET = secret; - /* eslint-disable @typescript-eslint/no-require-imports, n/no-missing-require */ - const { - default: logger, - }: typeof import('@/utils/logger') = require('@/utils/logger'); - const { - getCounter, - }: typeof import('@/utils/instrumentation') = require('@/utils/instrumentation'); - const { - default: Server, - }: typeof import('@/server') = require('@/server'); - const { - serverlessHandler, - }: typeof import('@/serverless') = require('@/serverless'); - const config: typeof import('@/config') = require('@/config'); - /* eslint-enable @typescript-eslint/no-require-imports, n/no-missing-require */ + const { default: logger } = + jest.requireMock('@/utils/logger'); + const { getCounter } = jest.requireMock< + typeof import('@/utils/instrumentation') + >('@/utils/instrumentation'); + const { default: Server } = + jest.requireActual('@/server'); + const { serverlessHandler } = + jest.requireActual('@/serverless'); + const config = + jest.requireActual('@/config'); expect(logger.error).not.toHaveBeenCalled(); const req = new IncomingMessage(new Socket()); From 9189377e989184684b502da5b08e1e02db813474 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:44:30 +0800 Subject: [PATCH 5/5] docs: keep deployment session secret out of tracked env --- DEPLOY.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 5e8fef1105..52735ada6f 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -59,9 +59,9 @@ such as ufw. See the for more information. Generate a secret once with `openssl rand -hex 32`, then set -`EXPRESS_SESSION_SECRET=` in the root `.env` or `.env.local`. -Compose forwards this value to the app container. To load `.env.local` and -override the defaults in `.env`, run: +`EXPRESS_SESSION_SECRET=` in `.env.local`, which Git ignores. +Do not put the secret in the tracked root `.env`. Compose forwards this value to +the app container when you load `.env.local` after `.env`: ```bash docker compose --env-file .env --env-file .env.local up -d