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:
@@ -1,3 +1,5 @@
|
|||||||
|
import { getDevBypassViolations } from "@capakraken/api/lib/runtime-security";
|
||||||
|
|
||||||
const DISALLOWED_PRODUCTION_SECRETS = new Set([
|
const DISALLOWED_PRODUCTION_SECRETS = new Set([
|
||||||
"dev-secret-change-in-production",
|
"dev-secret-change-in-production",
|
||||||
"changeme",
|
"changeme",
|
||||||
@@ -39,12 +41,12 @@ export function getRuntimeEnvViolations(env: RuntimeEnv = process.env): string[]
|
|||||||
if (!authSecret) {
|
if (!authSecret) {
|
||||||
violations.push("AUTH_SECRET or NEXTAUTH_SECRET must be set in production.");
|
violations.push("AUTH_SECRET or NEXTAUTH_SECRET must be set in production.");
|
||||||
} else if (DISALLOWED_PRODUCTION_SECRETS.has(authSecret)) {
|
} 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(...getDevBypassViolations(env));
|
||||||
violations.push("E2E_TEST_MODE must not be 'true' in production — it disables all rate limiting and session controls.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authUrl) {
|
if (!authUrl) {
|
||||||
violations.push("AUTH_URL or NEXTAUTH_URL must be set in production.");
|
violations.push("AUTH_URL or NEXTAUTH_URL must be set in production.");
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"./lib/audit": "./src/lib/audit.ts",
|
"./lib/audit": "./src/lib/audit.ts",
|
||||||
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
|
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
|
||||||
"./lib/logger": "./src/lib/logger.ts",
|
"./lib/logger": "./src/lib/logger.ts",
|
||||||
|
"./lib/runtime-security": "./src/lib/runtime-security.ts",
|
||||||
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
|
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(" ")}`);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { prisma, Prisma } from "@capakraken/db";
|
|||||||
import { resolvePermissions, PermissionKey, SystemRole } from "@capakraken/shared";
|
import { resolvePermissions, PermissionKey, SystemRole } from "@capakraken/shared";
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
import { assertNoDevBypassInProduction, isE2eBypassActive } from "./lib/runtime-security.js";
|
||||||
import { loggingMiddleware } from "./middleware/logging.js";
|
import { loggingMiddleware } from "./middleware/logging.js";
|
||||||
import { apiRateLimiter } from "./middleware/rate-limit.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
|
throw error; // re-throw non-Prisma errors unchanged
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const isE2eTestMode =
|
// Fail-fast if a dev-bypass flag is left on in a production build. A warning
|
||||||
process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] !== "production";
|
// is not enough — historically a refactor that drops an import can silently
|
||||||
if (process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] === "production") {
|
// re-enable the bypass. See packages/api/src/lib/runtime-security.ts.
|
||||||
// eslint-disable-next-line no-console
|
assertNoDevBypassInProduction();
|
||||||
console.warn("[SECURITY] E2E_TEST_MODE is set in production — rate limiting is NOT bypassed.");
|
const isE2eTestMode = isE2eBypassActive();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protected procedure — requires authenticated session AND a valid DB user record.
|
* Protected procedure — requires authenticated session AND a valid DB user record.
|
||||||
|
|||||||
Reference in New Issue
Block a user