diff --git a/apps/web/src/app/api/cron/chargeability-alerts/route.ts b/apps/web/src/app/api/cron/chargeability-alerts/route.ts index 2e8000e..fb37ec5 100644 --- a/apps/web/src/app/api/cron/chargeability-alerts/route.ts +++ b/apps/web/src/app/api/cron/chargeability-alerts/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@capakraken/db"; import { checkChargeabilityAlerts } from "@capakraken/api"; import { logger } from "@capakraken/api/lib/logger"; +import { verifyCronSecret } from "~/lib/cron-auth.js"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -18,13 +19,8 @@ export const runtime = "nodejs"; * When set, requests must include `Authorization: Bearer `. */ export async function GET(request: Request) { - const cronSecret = process.env["CRON_SECRET"]; - if (cronSecret) { - const auth = request.headers.get("authorization"); - if (auth !== `Bearer ${cronSecret}`) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - } + const deny = verifyCronSecret(request); + if (deny) return deny; try { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/web/src/app/api/cron/estimate-reminders/route.ts b/apps/web/src/app/api/cron/estimate-reminders/route.ts index 5a1ce2a..71873a7 100644 --- a/apps/web/src/app/api/cron/estimate-reminders/route.ts +++ b/apps/web/src/app/api/cron/estimate-reminders/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@capakraken/db"; import { checkPendingEstimateReminders } from "@capakraken/api"; import { logger } from "@capakraken/api/lib/logger"; +import { verifyCronSecret } from "~/lib/cron-auth.js"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -20,13 +21,8 @@ export const runtime = "nodejs"; * `Authorization: Bearer `. */ export async function GET(request: Request) { - const cronSecret = process.env["CRON_SECRET"]; - if (cronSecret) { - const auth = request.headers.get("authorization"); - if (auth !== `Bearer ${cronSecret}`) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - } + const deny = verifyCronSecret(request); + if (deny) return deny; try { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/web/src/app/api/cron/health-check/route.ts b/apps/web/src/app/api/cron/health-check/route.ts index e7532b7..5437aef 100644 --- a/apps/web/src/app/api/cron/health-check/route.ts +++ b/apps/web/src/app/api/cron/health-check/route.ts @@ -3,6 +3,7 @@ import { prisma } from "@capakraken/db"; import { createNotificationsForUsers } from "@capakraken/api"; import { logger } from "@capakraken/api/lib/logger"; import { createConnection } from "net"; +import { verifyCronSecret } from "~/lib/cron-auth.js"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -68,13 +69,8 @@ async function checkRedis(): Promise<{ status: "ok" | "error"; latencyMs: number * When set, requests must include `Authorization: Bearer `. */ export async function GET(request: Request) { - const cronSecret = process.env["CRON_SECRET"]; - if (cronSecret) { - const auth = request.headers.get("authorization"); - if (auth !== `Bearer ${cronSecret}`) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - } + const deny = verifyCronSecret(request); + if (deny) return deny; try { const [postgres, redis] = await Promise.all([ diff --git a/apps/web/src/app/api/cron/security-audit/route.ts b/apps/web/src/app/api/cron/security-audit/route.ts index 353693d..6a3c0ca 100644 --- a/apps/web/src/app/api/cron/security-audit/route.ts +++ b/apps/web/src/app/api/cron/security-audit/route.ts @@ -4,6 +4,7 @@ import { createNotificationsForUsers } from "@capakraken/api"; import { logger } from "@capakraken/api/lib/logger"; import { readFileSync } from "fs"; import { join } from "path"; +import { verifyCronSecret } from "~/lib/cron-auth.js"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -104,13 +105,8 @@ function scanPackageJson(): Finding[] { * When set, requests must include `Authorization: Bearer `. */ export async function GET(request: Request) { - const cronSecret = process.env["CRON_SECRET"]; - if (cronSecret) { - const auth = request.headers.get("authorization"); - if (auth !== `Bearer ${cronSecret}`) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - } + const deny = verifyCronSecret(request); + if (deny) return deny; try { const findings = scanPackageJson(); diff --git a/apps/web/src/lib/cron-auth.ts b/apps/web/src/lib/cron-auth.ts new file mode 100644 index 0000000..8be4e5d --- /dev/null +++ b/apps/web/src/lib/cron-auth.ts @@ -0,0 +1,35 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextResponse } from "next/server"; + +/** + * Verify the `Authorization: Bearer ` header against CRON_SECRET. + * + * Security properties: + * - Fail-closed: returns 401 when CRON_SECRET is not configured (A05-3) + * - Timing-safe: uses crypto.timingSafeEqual to prevent timing attacks (A02-1) + * + * Usage: + * const deny = verifyCronSecret(request); + * if (deny) return deny; + */ +export function verifyCronSecret(request: Request): NextResponse | null { + const cronSecret = process.env["CRON_SECRET"]; + + // Fail-closed: if the secret is not configured, reject all requests. + if (!cronSecret) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const auth = request.headers.get("authorization") ?? ""; + const expected = `Bearer ${cronSecret}`; + + const expectedBuf = Buffer.from(expected, "utf8"); + const actualBuf = Buffer.from(auth, "utf8"); + + // Different lengths can be rejected without timing exposure (length itself is not secret). + if (actualBuf.length !== expectedBuf.length || !timingSafeEqual(expectedBuf, actualBuf)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return null; +} diff --git a/apps/web/src/server/runtime-env.ts b/apps/web/src/server/runtime-env.ts index 2d44a74..ced675e 100644 --- a/apps/web/src/server/runtime-env.ts +++ b/apps/web/src/server/runtime-env.ts @@ -42,6 +42,10 @@ export function getRuntimeEnvViolations(env: RuntimeEnv = process.env): string[] 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."); + } + if (!authUrl) { violations.push("AUTH_URL or NEXTAUTH_URL must be set in production."); } else { diff --git a/docker-compose.yml b/docker-compose.yml index 7390528..c4efebd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,9 +59,14 @@ services: REDIS_URL: redis://redis:6379 NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3100} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?set NEXTAUTH_SECRET} - # Bypass auth + API rate limiters so E2E test runs don't exhaust - # per-user quotas and don't pollute active_sessions for real users. - E2E_TEST_MODE: "true" + # Bypass auth + API rate limiters for E2E test runs only. + # MUST remain "false" in any production or staging deployment. + # Set E2E_TEST_MODE=true in the host environment before running E2E tests. + E2E_TEST_MODE: "${E2E_TEST_MODE:-false}" + # AI provider secrets — forwarded from host .env, not hardcoded + AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} depends_on: postgres: condition: service_healthy diff --git a/packages/api/src/lib/webhook-dispatcher.ts b/packages/api/src/lib/webhook-dispatcher.ts index 4f63782..76c9841 100644 --- a/packages/api/src/lib/webhook-dispatcher.ts +++ b/packages/api/src/lib/webhook-dispatcher.ts @@ -88,8 +88,10 @@ async function _sendToWebhook( try { await assertWebhookUrlAllowed(wh.url); - // Slack-specific path: use the Slack notification helper - if (wh.url.includes("hooks.slack.com")) { + // Slack-specific path: use the Slack notification helper. + // Use strict hostname match to prevent bypass via "hooks.slack.com.attacker.example.com". + const parsedUrl = new URL(wh.url); + if (parsedUrl.hostname === "hooks.slack.com") { const message = formatSlackMessage(event, payload); await sendSlackNotification(wh.url, message); return;