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:
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
46
proxy.ts
46
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 = {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user