security: fix 17 vulnerabilities from comprehensive pentest

Fixes identified from full security audit covering auth, crypto,
injection, infrastructure, and configuration security.

Critical:
- C1: Fail-closed on unrecognized NODE_ENV (prevent DEV_SECRET in staging)
- C3: Validate API token expires_at (reject invalid dates that bypass expiry)

High:
- H1: Refresh JWT role from DB on each session (reflect demotions immediately)
- H2: Docker socket proxy for l4-port-manager (restrict API surface)
- H5: Block dangerous WAF custom directives (SecRuleEngine, SecAuditEngine)
- H7: Require explicit NEXTAUTH_TRUST_HOST instead of always trusting Host
- H8: Semantic validation of sync payload (block metadata SSRF, size limits)

Medium:
- M3: Rate limit password change current-password verification
- M5: Parameterized SQL in log/waf parsers (replace template literals)
- M6: Nonce-based CSP replacing unsafe-inline for script-src
- M9: Strip Caddy placeholders from rewrite path_prefix
- M10: Sanitize authentik outpostDomain (path traversal, placeholders)
- M14: Deny access on missing JWT role instead of defaulting to "user"

Low:
- L1: Require Origin header on mutating session-authenticated requests
- L4: Enforce password complexity on user password changes
- L5: Time-limited legacy SHA-256 key fallback (grace period until 2026-06-01)
- L6: Escape LIKE metacharacters in audit log search
- L7: Runtime-validate WAF excluded_rule_ids as positive integers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-26 12:14:44 +01:00
parent 7a12ecf2fe
commit debd0d98fc
18 changed files with 339 additions and 77 deletions

View File

@@ -201,6 +201,57 @@ function isL4ProxyHost(value: unknown): value is NonNullable<SyncPayload["data"]
);
}
/**
* H8: Validate semantic content of proxy host fields to prevent
* config injection via compromised master or stolen sync token.
*/
function validateProxyHostContent(host: Record<string, unknown>): 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<string, unknown>);
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 = {

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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 (
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
<Providers nonce={nonce}>{children}</Providers>
</body>
</html>
);

View File

@@ -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 (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange nonce={nonce}>
<TooltipProvider>
{children}
</TooltipProvider>

View File

@@ -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}

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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",
};
}

View File

@@ -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 {

View File

@@ -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<string, unknown> = { handler: 'waf', directives: parts.join('\n') };

View File

@@ -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);

View File

@@ -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

View File

@@ -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 ───────────────────────────────────────────────────────────────

View File

@@ -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;
}
}

View File

@@ -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<number> {
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<AuditEvent[]> {
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()

View File

@@ -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());
}

View File

@@ -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 ────────────────────────────────────────────────────────────────