diff --git a/src/config/env.test.ts b/src/config/env.test.ts index de822d6e..6087101c 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -10,6 +10,37 @@ const baseEnv = { METRICS_API_KEY: "test-metrics-key", }; +describe("production secret strength", () => { + it("rejects weak and placeholder secrets in production", () => { + const result = envSchema.safeParse({ + ...baseEnv, + NODE_ENV: "production", + SOROBAN_BILLING_CONTRACT_ID: "contract", + UPSTREAM_HOST_ALLOWLIST: "api.example.com", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.filter((issue) => + ["JWT_SECRET", "ADMIN_API_KEY", "METRICS_API_KEY"].includes(String(issue.path[0])), + )).toHaveLength(3); + } + }); + + it("accepts 32-byte production secrets", () => { + const secret = "a".repeat(32); + const result = envSchema.safeParse({ + ...baseEnv, + JWT_SECRET: secret, + ADMIN_API_KEY: "b".repeat(32), + METRICS_API_KEY: "c".repeat(32), + NODE_ENV: "production", + SOROBAN_BILLING_CONTRACT_ID: "contract", + UPSTREAM_HOST_ALLOWLIST: "api.example.com", + }); + expect(result.success).toBe(true); + }); +}); + describe("env schema — Soroban billing", () => { it("requires a nonempty contract ID in production", () => { for (const contractId of [undefined, "", " "]) { @@ -30,6 +61,9 @@ describe("env schema — Soroban billing", () => { it("accepts a valid production billing configuration", () => { const result = envSchema.safeParse({ ...baseEnv, + JWT_SECRET: "j".repeat(32), + ADMIN_API_KEY: "a".repeat(32), + METRICS_API_KEY: "m".repeat(32), NODE_ENV: "production", SOROBAN_BILLING_CONTRACT_ID: "contract_123", SOROBAN_BILLING_RPC_URL: "https://soroban.example.com", diff --git a/src/config/env.ts b/src/config/env.ts index 95523758..6e57da2e 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -543,6 +543,30 @@ export const envSchema = z .default(345_600_000), // 96h = 4 days, the slow-burn window }) .superRefine((values, ctx) => { + const secretFields = [ + ["JWT_SECRET", values.JWT_SECRET, "JWT_SECRET"], + ["ADMIN_API_KEY", values.ADMIN_API_KEY, "ADMIN_API_KEY"], + ["METRICS_API_KEY", values.METRICS_API_KEY, "METRICS_API_KEY"], + ] as const; + const placeholders = new Set([ + "your-jwt-secret-here", + "your-admin-api-key-here", + "your-metrics-api-key-here", + ]); + for (const [path, value, name] of secretFields) { + const weak = new TextEncoder().encode(value).length < 32; + const placeholder = placeholders.has(value.trim().toLowerCase()); + if (values.NODE_ENV === "production" && (weak || placeholder)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [path], + message: `${name} must contain at least 32 bytes of entropy and must not use a placeholder value in production`, + }); + } else if (values.NODE_ENV === "development" && (weak || placeholder)) { + console.warn(`${name} is weak or uses a placeholder; use a random value of at least 32 bytes before production`); + } + } + if (values.NODE_ENV === "production" && !values.SOROBAN_BILLING_CONTRACT_ID) { ctx.addIssue({ code: z.ZodIssueCode.custom,