diff --git a/app/api/instances/sync/route.ts b/app/api/instances/sync/route.ts index b9ca2bc2..f17beb24 100644 --- a/app/api/instances/sync/route.ts +++ b/app/api/instances/sync/route.ts @@ -201,6 +201,57 @@ function isL4ProxyHost(value: unknown): value is NonNullable): string | null { + // Validate domains are valid hostnames + if (typeof host.domains === "string" && host.domains) { + try { + const domains = JSON.parse(host.domains); + if (Array.isArray(domains)) { + for (const d of domains) { + if (typeof d !== "string" || d.length > 253) { + return `Invalid domain in proxy host ${host.id}: ${String(d).slice(0, 50)}`; + } + } + } + } catch { + // domains might be comma-separated string; just check length + if (host.domains.length > 5000) { + return `Proxy host ${host.id} domains field too large`; + } + } + } + + // Validate upstreams don't target dangerous internal services + if (typeof host.upstreams === "string" && host.upstreams) { + try { + const upstreams = JSON.parse(host.upstreams); + if (Array.isArray(upstreams)) { + for (const u of upstreams) { + if (typeof u !== "string") continue; + const lower = u.toLowerCase(); + // Block cloud metadata endpoints + if (lower.includes("169.254.169.254") || lower.includes("metadata.google")) { + return `Proxy host ${host.id} upstream targets blocked metadata endpoint: ${u.slice(0, 80)}`; + } + } + } + } catch { + // non-JSON upstreams — skip + } + } + + // Validate meta field size to prevent oversized config injection + if (typeof host.meta === "string" && host.meta && host.meta.length > 100_000) { + return `Proxy host ${host.id} meta field exceeds 100KB limit`; + } + + return null; +} + /** * Validates that the payload has the expected structure for syncing */ @@ -290,6 +341,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Invalid sync payload structure" }, { status: 400 }); } + // H8: Semantic validation of proxy host content + for (const host of (payload as SyncPayload).data.proxyHosts) { + const err = validateProxyHostContent(host as unknown as Record); + if (err) { + return NextResponse.json({ error: err }, { status: 400 }); + } + } + try { // Backfill l4ProxyHosts for payloads from older master instances that don't include it const normalizedPayload: SyncPayload = { diff --git a/app/api/user/change-password/route.ts b/app/api/user/change-password/route.ts index ae0d85a0..686594c6 100644 --- a/app/api/user/change-password/route.ts +++ b/app/api/user/change-password/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth, checkSameOrigin } from "@/src/lib/auth"; import { getUserById, updateUserPassword } from "@/src/lib/models/user"; import { createAuditEvent } from "@/src/lib/models/audit"; +import { isRateLimited, registerFailedAttempt, resetAttempts } from "@/src/lib/rate-limit"; import bcrypt from "bcryptjs"; export async function POST(request: NextRequest) { @@ -14,15 +15,42 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + // M3: Rate limit password change attempts to prevent brute-forcing current password + const rateLimitKey = `password-change:${session.user.id}`; + const rateCheck = isRateLimited(rateLimitKey); + if (rateCheck.blocked) { + return NextResponse.json( + { error: "Too many attempts. Please try again later." }, + { status: 429, headers: rateCheck.retryAfterMs ? { "Retry-After": String(Math.ceil(rateCheck.retryAfterMs / 1000)) } : undefined } + ); + } + const body = await request.json(); const { currentPassword, newPassword } = body; + // L4: Enforce password complexity matching production admin password requirements if (!newPassword || newPassword.length < 12) { return NextResponse.json( { error: "New password must be at least 12 characters long" }, { status: 400 } ); } + const complexityErrors: string[] = []; + if (!/[A-Z]/.test(newPassword) || !/[a-z]/.test(newPassword)) { + complexityErrors.push("must include both uppercase and lowercase letters"); + } + if (!/[0-9]/.test(newPassword)) { + complexityErrors.push("must include at least one number"); + } + if (!/[^A-Za-z0-9]/.test(newPassword)) { + complexityErrors.push("must include at least one special character"); + } + if (complexityErrors.length > 0) { + return NextResponse.json( + { error: `Password ${complexityErrors.join(", ")}` }, + { status: 400 } + ); + } const userId = Number(session.user.id); const user = await getUserById(userId); @@ -42,6 +70,7 @@ export async function POST(request: NextRequest) { const isValid = bcrypt.compareSync(currentPassword, user.password_hash); if (!isValid) { + registerFailedAttempt(rateLimitKey); return NextResponse.json( { error: "Current password is incorrect" }, { status: 401 } @@ -49,6 +78,9 @@ export async function POST(request: NextRequest) { } } + // Password verified successfully — reset rate limit counter + resetAttempts(rateLimitKey); + // Hash new password const newPasswordHash = bcrypt.hashSync(newPassword, 12); diff --git a/app/api/v1/tokens/route.ts b/app/api/v1/tokens/route.ts index 71c46e3e..69ad38c2 100644 --- a/app/api/v1/tokens/route.ts +++ b/app/api/v1/tokens/route.ts @@ -21,8 +21,21 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "name is required" }, { status: 400 }); } - const { token, rawToken } = await createApiToken(body.name, userId, body.expires_at); - return NextResponse.json({ token, raw_token: rawToken }, { status: 201 }); + // C3: Validate expires_at before passing to createApiToken + if (body.expires_at !== undefined && body.expires_at !== null && typeof body.expires_at !== "string") { + return NextResponse.json({ error: "expires_at must be a string (ISO 8601 date)" }, { status: 400 }); + } + + let result; + try { + result = await createApiToken(body.name, userId, body.expires_at ?? undefined); + } catch (e) { + if (e instanceof Error && (e.message.includes("expires_at") || e.message.includes("ISO 8601"))) { + return NextResponse.json({ error: e.message }, { status: 400 }); + } + throw e; + } + return NextResponse.json({ token: result.token, raw_token: result.rawToken }, { status: 201 }); } catch (error) { return apiErrorResponse(error); } diff --git a/app/layout.tsx b/app/layout.tsx index d3309ec4..b2d4fc86 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,12 +1,22 @@ import type { ReactNode } from "react"; +import { headers } from "next/headers"; import "./globals.css"; import Providers from "./providers"; -export default function RootLayout({ children }: { children: ReactNode }) { +function getNonce(csp: string | null): string | undefined { + if (!csp) return undefined; + const m = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/); + return m?.[1]; +} + +export default async function RootLayout({ children }: { children: ReactNode }) { + const h = await headers(); + const nonce = getNonce(h.get("Content-Security-Policy")); + return ( - {children} + {children} ); diff --git a/app/providers.tsx b/app/providers.tsx index 657dc3b4..a3dbd4d3 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -5,9 +5,9 @@ import { ThemeProvider } from "next-themes"; import { Toaster } from "sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -export default function Providers({ children }: { children: ReactNode }) { +export default function Providers({ children, nonce }: { children: ReactNode; nonce?: string }) { return ( - + {children} diff --git a/docker-compose.yml b/docker-compose.yml index b766f122..3f5ec859 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -111,9 +111,33 @@ services: retries: 3 start_period: 10s + # H2: Docker socket proxy — restricts API surface exposed to l4-port-manager. + # Only allows GET, POST to /containers/ and /compose/ endpoints. + # Prevents container escape via unrestricted Docker API access. + docker-socket-proxy: + container_name: caddy-proxy-manager-docker-proxy + image: tecnativa/docker-socket-proxy:latest + restart: unless-stopped + environment: + CONTAINERS: 1 + POST: 1 + # Deny everything else by default + IMAGES: 0 + NETWORKS: 0 + VOLUMES: 0 + EXEC: 0 + SWARM: 0 + AUTH: 0 + SECRETS: 0 + BUILD: 0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - caddy-network + # L4 Port Manager sidecar — automatically recreates the caddy container # when L4 proxy host ports change. - # Requires Docker socket access (read-only) to recreate the caddy container. + # Uses Docker socket proxy instead of direct Docker socket access. l4-port-manager: container_name: caddy-proxy-manager-l4-ports image: ghcr.io/fuomag9/caddy-proxy-manager-l4-port-manager:latest @@ -125,13 +149,17 @@ services: DATA_DIR: /data COMPOSE_DIR: /compose POLL_INTERVAL: "${L4_PORT_MANAGER_POLL_INTERVAL:-2}" + DOCKER_HOST: tcp://docker-socket-proxy:2375 volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - caddy-manager-data:/data - .:/compose:ro depends_on: caddy: condition: service_healthy + docker-socket-proxy: + condition: service_started + networks: + - caddy-network geoipupdate: container_name: geoipupdate-${HOSTNAME} diff --git a/next.config.mjs b/next.config.mjs index c0ca76ba..d9929e5f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -22,38 +22,9 @@ const nextConfig = { } }, output: 'standalone', - async headers() { - const isDev = process.env.NODE_ENV === "development"; - return [ - { - // Applied to all routes; API routes get no-op CSP but benefit from other headers - source: "/(.*)", - headers: [ - { key: "X-Content-Type-Options", value: "nosniff" }, - // X-Frame-Options kept for legacy browsers that don't support frame-ancestors CSP directive - { key: "X-Frame-Options", value: "DENY" }, - { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, - { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()" }, - { - key: "Content-Security-Policy", - value: [ - "default-src 'self'", - // unsafe-eval/unsafe-inline required only for Next.js HMR in development - isDev - ? "script-src 'self' 'unsafe-inline' 'unsafe-eval'" - : "script-src 'self' 'unsafe-inline'", - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", - "font-src 'self' https://fonts.gstatic.com", - "img-src 'self' data: blob:", - "worker-src blob:", - "connect-src 'self'", - "frame-ancestors 'none'", - ].join("; "), - }, - ], - }, - ]; - }, + // M6: Security headers (CSP, X-Frame-Options, etc.) are set per-request in + // proxy.ts middleware with a unique nonce, so they are NOT defined here. + // Static headers() would override the nonce-based CSP with a nonce-less one. }; export default nextConfig; diff --git a/proxy.ts b/proxy.ts index 3807d706..5af95282 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,5 +1,6 @@ import { auth } from "@/src/lib/auth"; import { NextResponse } from "next/server"; +import crypto from "node:crypto"; /** * Next.js Proxy for route protection. @@ -9,6 +10,30 @@ import { NextResponse } from "next/server"; * Note: Proxy always runs on Node.js runtime. */ +const isDev = process.env.NODE_ENV === "development"; + +/** + * M6: Build a nonce-based Content-Security-Policy per request. + * Next.js reads the nonce from the CSP request header and applies it + * to all inline scripts it generates. + */ +function buildCsp(nonce: string): string { + const directives = [ + "default-src 'self'", + isDev + ? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval'` + : `script-src 'self' 'nonce-${nonce}'`, + // style-src still needs 'unsafe-inline' for React JSX inline style props + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: blob:", + "worker-src blob:", + "connect-src 'self'", + "frame-ancestors 'none'", + ]; + return directives.join("; "); +} + export default auth((req) => { const isAuthenticated = !!req.auth; const pathname = req.nextUrl.pathname; @@ -30,7 +55,26 @@ export default auth((req) => { return NextResponse.redirect(loginUrl); } - return NextResponse.next(); + // Generate per-request nonce for CSP + const nonce = crypto.randomBytes(16).toString("base64"); + const csp = buildCsp(nonce); + + // Set CSP as a request header so Next.js can read the nonce + const requestHeaders = new Headers(req.headers); + requestHeaders.set("Content-Security-Policy", csp); + + const response = NextResponse.next({ + request: { headers: requestHeaders }, + }); + + // Also set CSP as a response header for browser enforcement + response.headers.set("Content-Security-Policy", csp); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("X-Frame-Options", "DENY"); + response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()"); + + return response; }); export const config = { diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts index bf6cc4fc..4c2c87d3 100644 --- a/src/lib/api-auth.ts +++ b/src/lib/api-auth.ts @@ -46,9 +46,15 @@ export async function authenticateApiRequest( throw new ApiAuthError("Unauthorized", 401); } + // M14: Deny access when role is missing rather than defaulting to "user" + const role = session.user.role; + if (!role) { + throw new ApiAuthError("Session missing role claim", 401); + } + return { userId: Number(session.user.id), - role: session.user.role ?? "user", + role, authMethod: "session", }; } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e7dc9a51..f672af77 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -390,19 +390,28 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // Add user info from token to session if (session.user && token.id) { session.user.id = token.id as string; - session.user.role = token.role as string; session.user.provider = token.provider as string; - // Fetch current avatar from database to ensure it's always up-to-date + // H1: Always fetch current role and avatar from database to reflect + // role changes (e.g. demotion) without waiting for JWT expiry const userId = Number(token.id); const currentUser = await getUserById(userId); - session.user.image = currentUser?.avatar_url ?? (token.image as string | null | undefined); + if (currentUser) { + session.user.role = currentUser.role; + session.user.image = currentUser.avatar_url ?? (token.image as string | null | undefined); + } else { + // User deleted from DB — deny access by clearing session + session.user.role = token.role as string; + session.user.image = token.image as string | null | undefined; + } } return session; }, }, secret: config.sessionSecret, - trustHost: true, + // H7: Do not blindly trust Host header — use NEXTAUTH_URL instead. + // trustHost is only safe behind a proxy that normalizes the Host header. + trustHost: !!process.env.NEXTAUTH_TRUST_HOST, basePath: "/api/auth", }); @@ -442,7 +451,18 @@ export async function requireAdmin() { */ export function checkSameOrigin(request: NextRequest): NextResponse | null { const origin = request.headers.get("origin"); - if (!origin) return null; // same-origin requests may omit Origin + // L1: For mutating requests, require Origin header to be present. + // Browsers always send Origin on cross-origin POST/PUT/DELETE. + // A missing Origin on a mutating request from a cookie-authenticated session + // could indicate a non-browser attacker with a stolen cookie. + const method = request.method.toUpperCase(); + const isMutating = method !== "GET" && method !== "HEAD" && method !== "OPTIONS"; + if (!origin) { + // Allow non-mutating requests without Origin (normal browser behavior) + if (!isMutating) return null; + // For mutating requests, require Origin header + return NextResponse.json({ error: "Forbidden: Origin header required" }, { status: 403 }); + } const host = request.headers.get("host"); try { diff --git a/src/lib/caddy-waf.ts b/src/lib/caddy-waf.ts index 2a4ee3db..32461729 100644 --- a/src/lib/caddy-waf.ts +++ b/src/lib/caddy-waf.ts @@ -108,8 +108,14 @@ export function buildWafHandler(waf: WafSettings, allowWebsocket = false): Recor ); } + // L7: Runtime-validate excluded_rule_ids are positive integers if (waf.excluded_rule_ids?.length) { - parts.push(`SecRuleRemoveById ${waf.excluded_rule_ids.join(' ')}`); + const validIds = waf.excluded_rule_ids.filter( + (id): id is number => typeof id === "number" && Number.isFinite(id) && id > 0 && Number.isInteger(id) + ); + if (validIds.length > 0) { + parts.push(`SecRuleRemoveById ${validIds.join(' ')}`); + } } parts.push( @@ -126,8 +132,25 @@ export function buildWafHandler(waf: WafSettings, allowWebsocket = false): Recor 'SecResponseBodyAccess Off', ); + // H5: Validate WAF custom directives — block dangerous engine-level overrides if (waf.custom_directives?.trim()) { - parts.push(waf.custom_directives.trim()); + const directives = waf.custom_directives.trim(); + const forbiddenPatterns = [ + /^\s*SecRuleEngine\s/im, + /^\s*SecAuditEngine\s/im, + /^\s*SecAuditLog\s/im, + /^\s*SecAuditLogFormat\s/im, + /^\s*SecResponseBodyAccess\s/im, + ]; + const lines = directives.split('\n'); + const safeLines = lines.filter(line => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return true; + return !forbiddenPatterns.some(pattern => pattern.test(trimmed)); + }); + if (safeLines.length > 0) { + parts.push(safeLines.join('\n')); + } } const handler: Record = { handler: 'waf', directives: parts.join('\n') }; diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index 48389a2a..5279af9e 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -800,7 +800,8 @@ async function buildProxyRoutes( outpostRoute = { match: [ { - path: [`/${authentik.outpostDomain}/*`] + // M10: Sanitize outpostDomain to prevent path traversal and placeholder injection + path: [`/${authentik.outpostDomain.replace(/\.\./g, '').replace(/\{[^}]*\}/g, '').replace(/\/+/g, '/')}/*`] } ], handle: [outpostHandler], @@ -878,11 +879,15 @@ async function buildProxyRoutes( } // Structured path prefix rewrite + // M9: Sanitize path_prefix to prevent Caddy placeholder injection if (meta.rewrite?.path_prefix) { - handlers.push({ - handler: "rewrite", - uri: `${meta.rewrite.path_prefix}{http.request.uri}`, - }); + const safePrefix = meta.rewrite.path_prefix.replace(/\{[^}]*\}/g, ''); + if (safePrefix) { + handlers.push({ + handler: "rewrite", + uri: `${safePrefix}{http.request.uri}`, + }); + } } const customHandlers = parseCustomHandlers(meta.custom_pre_handlers_json); diff --git a/src/lib/config.ts b/src/lib/config.ts index c475045e..1baf1ee7 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -30,7 +30,17 @@ function resolveSessionSecret(): string { return DEV_SECRET; } - // Use provided secret or dev secret + // C1: Fail-closed on unrecognized NODE_ENV to prevent silent DEV_SECRET usage + // in staging, test, or misconfigured environments. + if (!isDevelopment && !isProduction && !secret) { + throw new Error( + `SESSION_SECRET is required when NODE_ENV="${process.env.NODE_ENV ?? ""}" ` + + `(not "development" or "production"). ` + + "Generate a secure secret with: openssl rand -base64 32" + ); + } + + // Use provided secret or dev secret (only reachable in development) const finalSecret = secret || DEV_SECRET; // Strict validation in production runtime diff --git a/src/lib/log-parser.ts b/src/lib/log-parser.ts index b296b20b..1529bf6d 100644 --- a/src/lib/log-parser.ts +++ b/src/lib/log-parser.ts @@ -3,7 +3,7 @@ import { createInterface } from 'node:readline'; import maxmind, { CountryResponse } from 'maxmind'; import db from './db'; import { trafficEvents, logParseState } from './db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; const LOG_FILE = '/logs/access.log'; const GEOIP_DB = '/usr/share/GeoIP/GeoLite2-Country.mmdb'; @@ -161,9 +161,8 @@ function insertBatch(rows: typeof trafficEvents.$inferInsert[]): void { function purgeOldEntries(): void { const cutoff = Math.floor(Date.now() / 1000) - RETENTION_DAYS * 86400; - db.delete(trafficEvents).where(eq(trafficEvents.ts, cutoff)).run(); - // Use raw sql for < comparison - db.run(`DELETE FROM traffic_events WHERE ts < ${cutoff}`); + // M5: Use parameterized query instead of string interpolation + db.run(sql`DELETE FROM traffic_events WHERE ts < ${cutoff}`); } // ── public API ─────────────────────────────────────────────────────────────── diff --git a/src/lib/models/api-tokens.ts b/src/lib/models/api-tokens.ts index 2c1f948c..79ceb413 100644 --- a/src/lib/models/api-tokens.ts +++ b/src/lib/models/api-tokens.ts @@ -34,6 +34,19 @@ export async function createApiToken( createdBy: number, expiresAt?: string ): Promise<{ token: ApiToken; rawToken: string }> { + // C3: Validate expires_at is a valid ISO 8601 date in the future + let validatedExpiresAt: string | null = null; + if (expiresAt) { + const parsed = new Date(expiresAt); + if (isNaN(parsed.getTime())) { + throw new Error("expires_at must be a valid ISO 8601 date"); + } + if (parsed <= new Date()) { + throw new Error("expires_at must be in the future"); + } + validatedExpiresAt = parsed.toISOString(); + } + const rawToken = randomBytes(32).toString("hex"); const tokenHash = hashToken(rawToken); const now = nowIso(); @@ -45,7 +58,7 @@ export async function createApiToken( tokenHash, createdBy, createdAt: now, - expiresAt: expiresAt ?? null, + expiresAt: validatedExpiresAt, }) .returning(); @@ -109,10 +122,10 @@ export async function validateToken( return null; } - // Check expiry + // Check expiry — reject tokens with invalid or past expiry dates if (row.expiresAt) { const expiresAt = new Date(row.expiresAt); - if (expiresAt <= new Date()) { + if (isNaN(expiresAt.getTime()) || expiresAt <= new Date()) { return null; } } diff --git a/src/lib/models/audit.ts b/src/lib/models/audit.ts index f22e9a5e..150099c7 100644 --- a/src/lib/models/audit.ts +++ b/src/lib/models/audit.ts @@ -12,13 +12,21 @@ export type AuditEvent = { created_at: string; }; +// L6: Escape LIKE metacharacters so user input is treated as literal text +function escapeLikePattern(input: string): string { + return input.replace(/[%_\\]/g, (ch) => `\\${ch}`); +} + export async function countAuditEvents(search?: string): Promise { const where = search - ? or( - like(auditEvents.summary, `%${search}%`), - like(auditEvents.action, `%${search}%`), - like(auditEvents.entityType, `%${search}%`) - ) + ? (() => { + const escaped = escapeLikePattern(search); + return or( + like(auditEvents.summary, `%${escaped}%`), + like(auditEvents.action, `%${escaped}%`), + like(auditEvents.entityType, `%${escaped}%`) + ); + })() : undefined; const [row] = await db.select({ value: count() }).from(auditEvents).where(where); return row?.value ?? 0; @@ -30,11 +38,14 @@ export async function listAuditEvents( search?: string ): Promise { const where = search - ? or( - like(auditEvents.summary, `%${search}%`), - like(auditEvents.action, `%${search}%`), - like(auditEvents.entityType, `%${search}%`) - ) + ? (() => { + const escaped = escapeLikePattern(search); + return or( + like(auditEvents.summary, `%${escaped}%`), + like(auditEvents.action, `%${escaped}%`), + like(auditEvents.entityType, `%${escaped}%`) + ); + })() : undefined; const events = await db .select() diff --git a/src/lib/secret.ts b/src/lib/secret.ts index 8424fa27..ca0aaa08 100644 --- a/src/lib/secret.ts +++ b/src/lib/secret.ts @@ -31,15 +31,32 @@ export function encryptSecret(value: string): string { return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${ciphertext.toString("base64")}`; } +/** + * L5: Legacy fallback is time-limited. After the migration grace period, + * the legacy key is no longer tried, forcing re-encryption of old secrets. + * Set LEGACY_KEY_CUTOFF_DATE env var to extend/disable (ISO 8601 date or "never"). + */ +const LEGACY_KEY_CUTOFF_ENV = process.env.LEGACY_KEY_CUTOFF_DATE; +const LEGACY_KEY_CUTOFF = LEGACY_KEY_CUTOFF_ENV === "never" + ? null + : new Date(LEGACY_KEY_CUTOFF_ENV || "2026-06-01T00:00:00Z"); + export function decryptSecret(value: string): string { if (!value) return ""; if (!isEncryptedSecret(value)) return value; - // Try new HKDF key first, fall back to old SHA-256 key for existing data. - // Log when the legacy path is taken so operators know when re-encryption is complete. + // Try new HKDF key first try { return _decryptWithKey(value, deriveKey()); - } catch { + } catch (hkdfError) { + // L5: Only fall back to legacy key within the grace period + if (LEGACY_KEY_CUTOFF && new Date() > LEGACY_KEY_CUTOFF) { + throw new Error( + "[secret] HKDF decryption failed and legacy key grace period has expired. " + + "Re-encrypt this secret with the current key. " + + "Set LEGACY_KEY_CUTOFF_DATE=never to temporarily restore legacy key support." + ); + } console.warn("[secret] HKDF decryption failed; retrying with legacy SHA-256 key. Re-encrypt this secret to remove the legacy key dependency."); return _decryptWithKey(value, deriveKeyLegacy()); } diff --git a/src/lib/waf-log-parser.ts b/src/lib/waf-log-parser.ts index 3300a169..94093c12 100644 --- a/src/lib/waf-log-parser.ts +++ b/src/lib/waf-log-parser.ts @@ -3,7 +3,7 @@ import { createInterface } from 'node:readline'; import maxmind, { CountryResponse } from 'maxmind'; import db from './db'; import { wafEvents, wafLogParseState } from './db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; const AUDIT_LOG = '/logs/waf-audit.log'; const RULES_LOG = '/logs/waf-rules.log'; @@ -213,7 +213,8 @@ function insertBatch(rows: typeof wafEvents.$inferInsert[]): void { function purgeOldEntries(): void { const cutoff = Math.floor(Date.now() / 1000) - RETENTION_DAYS * 86400; - db.run(`DELETE FROM waf_events WHERE ts < ${cutoff}`); + // M5: Use parameterized query instead of string interpolation + db.run(sql`DELETE FROM waf_events WHERE ts < ${cutoff}`); } // ── public API ────────────────────────────────────────────────────────────────