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 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 08:56:27 +02:00
parent c2d05b4b99
commit 93a7fbaa4c
5 changed files with 92 additions and 10 deletions
+6 -4
View File
@@ -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.");
+1
View File
@@ -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": {
@@ -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);
});
});
+38
View File
@@ -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<Record<string, string | undefined>>;
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(" ")}`);
}
+6 -6
View File
@@ -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.