From 93a7fbaa4c7d1732c771bf41b58ca670d74f2839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 17 Apr 2026 08:56:27 +0200 Subject: [PATCH] security: fail-fast dev-bypass flag in production (#42) Both auth.ts and trpc.ts now delegate the E2E_TEST_MODE-in-production check to a single shared helper (packages/api/src/lib/runtime-security.ts). trpc.ts used to only console.warn; it now throws at module load time, matching the behaviour already enforced by assertSecureRuntimeEnv on the auth side. A future refactor can no longer silently drop the guard on either side. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/server/runtime-env.ts | 10 +++-- packages/api/package.json | 1 + .../lib/__tests__/runtime-security.test.ts | 41 +++++++++++++++++++ packages/api/src/lib/runtime-security.ts | 38 +++++++++++++++++ packages/api/src/trpc.ts | 12 +++--- 5 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 packages/api/src/lib/__tests__/runtime-security.test.ts create mode 100644 packages/api/src/lib/runtime-security.ts diff --git a/apps/web/src/server/runtime-env.ts b/apps/web/src/server/runtime-env.ts index ced675e..e997de4 100644 --- a/apps/web/src/server/runtime-env.ts +++ b/apps/web/src/server/runtime-env.ts @@ -1,3 +1,5 @@ +import { getDevBypassViolations } from "@capakraken/api/lib/runtime-security"; + const DISALLOWED_PRODUCTION_SECRETS = new Set([ "dev-secret-change-in-production", "changeme", @@ -39,12 +41,12 @@ export function getRuntimeEnvViolations(env: RuntimeEnv = process.env): string[] if (!authSecret) { violations.push("AUTH_SECRET or NEXTAUTH_SECRET must be set in production."); } else if (DISALLOWED_PRODUCTION_SECRETS.has(authSecret)) { - violations.push("AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production."); + violations.push( + "AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production.", + ); } - if ((env.E2E_TEST_MODE ?? "").trim() === "true") { - violations.push("E2E_TEST_MODE must not be 'true' in production — it disables all rate limiting and session controls."); - } + violations.push(...getDevBypassViolations(env)); if (!authUrl) { violations.push("AUTH_URL or NEXTAUTH_URL must be set in production."); diff --git a/packages/api/package.json b/packages/api/package.json index 5773f9c..fa11e02 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -11,6 +11,7 @@ "./lib/audit": "./src/lib/audit.ts", "./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts", "./lib/logger": "./src/lib/logger.ts", + "./lib/runtime-security": "./src/lib/runtime-security.ts", "./middleware/rate-limit": "./src/middleware/rate-limit.ts" }, "scripts": { diff --git a/packages/api/src/lib/__tests__/runtime-security.test.ts b/packages/api/src/lib/__tests__/runtime-security.test.ts new file mode 100644 index 0000000..ba3de20 --- /dev/null +++ b/packages/api/src/lib/__tests__/runtime-security.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + assertNoDevBypassInProduction, + getDevBypassViolations, + isE2eBypassActive, +} from "../runtime-security.js"; + +describe("runtime-security — dev-bypass fail-fast", () => { + it("returns no violations when E2E_TEST_MODE unset", () => { + expect(getDevBypassViolations({ NODE_ENV: "production" })).toEqual([]); + }); + + it("returns no violations in non-production env even with E2E_TEST_MODE=true", () => { + expect(getDevBypassViolations({ NODE_ENV: "development", E2E_TEST_MODE: "true" })).toEqual([]); + }); + + it("flags a violation for E2E_TEST_MODE=true + NODE_ENV=production", () => { + const violations = getDevBypassViolations({ + NODE_ENV: "production", + E2E_TEST_MODE: "true", + }); + expect(violations.length).toBe(1); + expect(violations[0]).toMatch(/E2E_TEST_MODE/); + }); + + it("assertNoDevBypassInProduction throws on prod+E2E", () => { + expect(() => + assertNoDevBypassInProduction({ NODE_ENV: "production", E2E_TEST_MODE: "true" }), + ).toThrow(/E2E_TEST_MODE/); + }); + + it("assertNoDevBypassInProduction is a no-op when E2E disabled in prod", () => { + expect(() => assertNoDevBypassInProduction({ NODE_ENV: "production" })).not.toThrow(); + }); + + it("isE2eBypassActive only true in non-production", () => { + expect(isE2eBypassActive({ NODE_ENV: "development", E2E_TEST_MODE: "true" })).toBe(true); + expect(isE2eBypassActive({ NODE_ENV: "production", E2E_TEST_MODE: "true" })).toBe(false); + expect(isE2eBypassActive({ NODE_ENV: "development" })).toBe(false); + }); +}); diff --git a/packages/api/src/lib/runtime-security.ts b/packages/api/src/lib/runtime-security.ts new file mode 100644 index 0000000..77dfa8a --- /dev/null +++ b/packages/api/src/lib/runtime-security.ts @@ -0,0 +1,38 @@ +/** + * Shared fail-fast checks for dev-only bypass flags. + * + * Both `apps/web/src/server/runtime-env.ts` and `packages/api/src/trpc.ts` + * gate behaviour on `E2E_TEST_MODE`. Historically each had its own check + * (one throwing, one `console.warn`-ing), which meant a refactor that + * dropped one import silently re-enabled the bypass in production. This + * module is the single source of truth; both call sites delegate here. + * + * CapaKraken security ticket #42 / EAPPS 3.2.7.04. + */ + +type RuntimeEnv = Partial>; + +const DEV_BYPASS_FLAGS = ["E2E_TEST_MODE"] as const; + +export function isE2eBypassActive(env: RuntimeEnv = process.env): boolean { + return env["E2E_TEST_MODE"] === "true" && env["NODE_ENV"] !== "production"; +} + +export function getDevBypassViolations(env: RuntimeEnv = process.env): string[] { + if (env["NODE_ENV"] !== "production") return []; + const out: string[] = []; + for (const flag of DEV_BYPASS_FLAGS) { + if (env[flag] === "true") { + out.push( + `${flag} must not be 'true' in production — it disables rate limiting and session controls.`, + ); + } + } + return out; +} + +export function assertNoDevBypassInProduction(env: RuntimeEnv = process.env): void { + const violations = getDevBypassViolations(env); + if (violations.length === 0) return; + throw new Error(`[FATAL] Dev-bypass flag set in production: ${violations.join(" ")}`); +} diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 1bbad97..e6369bc 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -2,6 +2,7 @@ import { prisma, Prisma } from "@capakraken/db"; import { resolvePermissions, PermissionKey, SystemRole } from "@capakraken/shared"; import { initTRPC, TRPCError } from "@trpc/server"; import { ZodError } from "zod"; +import { assertNoDevBypassInProduction, isE2eBypassActive } from "./lib/runtime-security.js"; import { loggingMiddleware } from "./middleware/logging.js"; import { apiRateLimiter } from "./middleware/rate-limit.js"; @@ -139,12 +140,11 @@ const withPrismaErrors = t.middleware(async ({ next }) => { throw error; // re-throw non-Prisma errors unchanged } }); -const isE2eTestMode = - process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] !== "production"; -if (process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] === "production") { - // eslint-disable-next-line no-console - console.warn("[SECURITY] E2E_TEST_MODE is set in production — rate limiting is NOT bypassed."); -} +// Fail-fast if a dev-bypass flag is left on in a production build. A warning +// is not enough — historically a refactor that drops an import can silently +// re-enable the bypass. See packages/api/src/lib/runtime-security.ts. +assertNoDevBypassInProduction(); +const isE2eTestMode = isE2eBypassActive(); /** * Protected procedure — requires authenticated session AND a valid DB user record.