Files
livedash-node/lib/csp.ts
Kaj Kowalski 7a3eabccd9 feat: enhance security, performance, and stability
This commit introduces a range of improvements across the application:

- **Security:**
  - Adds authentication to the CSP metrics endpoint.
  - Hardens CSP bypass detection regex to prevent ReDoS attacks.
  - Improves CORS headers for the CSP metrics API.
  - Adds filtering for acknowledged alerts in security monitoring.

- **Performance:**
  - Optimizes database connection pooling for NeonDB.
  - Improves session fetching with abort controller.

- **Stability:**
  - Adds error handling to the tRPC demo component.
  - Fixes type inconsistencies in session data mapping.

- **Docs & DX:**
  - Ignores  files in git.
  - Fixes a token placeholder in the documentation.
2025-07-12 01:03:52 +02:00

580 lines
15 KiB
TypeScript

import crypto from "node:crypto";
import { type NextRequest, NextResponse } from "next/server";
export interface CSPConfig {
nonce?: string;
isDevelopment?: boolean;
reportUri?: string;
enforceMode?: boolean;
strictMode?: boolean;
allowedExternalDomains?: string[];
reportingLevel?: "none" | "violations" | "all";
}
export interface CSPViolationReport {
"csp-report": {
"document-uri": string;
referrer: string;
"violated-directive": string;
"original-policy": string;
"blocked-uri": string;
"source-file"?: string;
"line-number"?: number;
"column-number"?: number;
"script-sample"?: string;
};
}
/**
* Generate a cryptographically secure nonce for CSP
*/
export function generateNonce(): string {
return crypto.randomBytes(16).toString("base64");
}
/**
* Build Content Security Policy header value based on configuration
*/
export function buildCSP(config: CSPConfig = {}): string {
const {
nonce,
isDevelopment = false,
reportUri,
_enforceMode = true,
strictMode = false,
allowedExternalDomains = [],
_reportingLevel = "violations",
} = config;
// Base directives for all environments
const baseDirectives = {
"default-src": ["'self'"],
"base-uri": ["'self'"],
"form-action": ["'self'"],
"frame-ancestors": ["'none'"],
"object-src": ["'none'"],
"upgrade-insecure-requests": true,
};
// Script sources - more restrictive in production
const scriptSrc = isDevelopment
? ["'self'", "'unsafe-eval'", "'unsafe-inline'"]
: nonce
? ["'self'", `'nonce-${nonce}'`, "'strict-dynamic'"]
: ["'self'"];
// Style sources - use nonce in production when available
const styleSrc = nonce
? ["'self'", `'nonce-${nonce}'`]
: ["'self'", "'unsafe-inline'"]; // Fallback for TailwindCSS
// Image sources - allow self, data URIs, and specific trusted domains
const imgSrc = [
"'self'",
"data:",
"https://schema.org", // For structured data images
"https://livedash.notso.ai", // Application domain
"https://*.basemaps.cartocdn.com", // Leaflet map tiles
"https://*.openstreetmap.org", // OpenStreetMap tiles
...allowedExternalDomains
.filter((domain) => domain.startsWith("https://"))
.map((domain) => domain),
].filter(Boolean);
// Font sources - restrict to self and data URIs
const fontSrc = ["'self'", "data:"];
// Connect sources - API endpoints and trusted domains
const connectSrc = isDevelopment
? ["'self'", "https:", "wss:", "ws:"] // Allow broader sources in dev for HMR
: strictMode
? [
"'self'",
"https://api.openai.com", // OpenAI API
"https://livedash.notso.ai", // Application API
...allowedExternalDomains.filter(
(domain) =>
domain.startsWith("https://") || domain.startsWith("wss://")
),
].filter(Boolean)
: [
"'self'",
"https://api.openai.com", // OpenAI API
"https://livedash.notso.ai", // Application API
"https:", // Allow all HTTPS in non-strict mode
];
// Media sources - restrict to self
const mediaSrc = ["'self'"];
// Worker sources - restrict to self
const workerSrc = ["'self'"];
// Child sources - restrict to self
const childSrc = ["'self'"];
// Manifest sources - restrict to self
const manifestSrc = ["'self'"];
// Build the directive object
const directives = {
...baseDirectives,
"script-src": scriptSrc,
"style-src": styleSrc,
"img-src": imgSrc,
"font-src": fontSrc,
"connect-src": connectSrc,
"media-src": mediaSrc,
"worker-src": workerSrc,
"child-src": childSrc,
"manifest-src": manifestSrc,
};
// Add report URI if provided
if (reportUri) {
directives["report-uri"] = [reportUri];
directives["report-to"] = ["csp-endpoint"];
}
// Convert directives to CSP string
const cspString = Object.entries(directives)
.map(([directive, value]) => {
if (value === true) return directive;
if (Array.isArray(value)) return `${directive} ${value.join(" ")}`;
return `${directive} ${value}`;
})
.join("; ");
return cspString;
}
/**
* Create CSP middleware for Next.js
*/
export function createCSPMiddleware(config: CSPConfig = {}) {
return (_request: NextRequest) => {
const nonce = generateNonce();
const isDevelopment = process.env.NODE_ENV === "development";
const csp = buildCSP({
...config,
nonce,
isDevelopment,
});
const response = NextResponse.next();
// Set CSP header
response.headers.set("Content-Security-Policy", csp);
// Store nonce for use in components
response.headers.set("X-Nonce", nonce);
return response;
};
}
/**
* Helper function to check unsafe directives
*/
function checkUnsafeDirectives(
csp: string,
strictMode: boolean,
warnings: string[],
errors: string[],
recommendations: string[]
): number {
let scorePenalty = 0;
if (csp.includes("'unsafe-inline'") && !csp.includes("'nonce-")) {
warnings.push("Using 'unsafe-inline' without nonce is less secure");
scorePenalty += 15;
recommendations.push(
"Implement nonce-based CSP for inline scripts and styles"
);
}
if (csp.includes("'unsafe-eval'")) {
if (strictMode) {
errors.push("'unsafe-eval' is not allowed in strict mode");
scorePenalty += 25;
} else {
warnings.push("'unsafe-eval' allows dangerous code execution");
scorePenalty += 10;
}
}
return scorePenalty;
}
/**
* Helper function to check wildcard usage
*/
function checkWildcardUsage(
csp: string,
errors: string[],
recommendations: string[]
): number {
const hasProblematicWildcards =
csp.includes(" *") ||
csp.includes("*://") ||
(csp.includes("*") && !csp.includes("*.") && !csp.includes("wss: ws:"));
if (hasProblematicWildcards) {
errors.push("Wildcard (*) sources are not recommended");
recommendations.push("Replace wildcards with specific trusted domains");
return 30;
}
return 0;
}
/**
* Helper function to check security features
*/
function checkSecurityFeatures(
csp: string,
warnings: string[],
recommendations: string[]
): number {
let scorePenalty = 0;
if (
csp.includes("data:") &&
!csp.includes("img-src") &&
!csp.includes("font-src")
) {
warnings.push("data: URIs should be limited to specific directives");
scorePenalty += 5;
}
if (!csp.includes("upgrade-insecure-requests")) {
warnings.push("Missing HTTPS upgrade directive");
scorePenalty += 10;
recommendations.push("Add 'upgrade-insecure-requests' directive");
}
if (!csp.includes("frame-ancestors")) {
warnings.push("Missing frame-ancestors directive");
scorePenalty += 15;
recommendations.push(
"Add 'frame-ancestors 'none'' to prevent clickjacking"
);
}
return scorePenalty;
}
/**
* Helper function to check required directives
*/
function checkRequiredDirectives(csp: string, errors: string[]): number {
const requiredDirectives = [
"default-src",
"script-src",
"style-src",
"object-src",
"base-uri",
"form-action",
];
let scorePenalty = 0;
for (const directive of requiredDirectives) {
if (!csp.includes(directive)) {
errors.push(`Missing required directive: ${directive}`);
scorePenalty += 20;
}
}
return scorePenalty;
}
/**
* Helper function to check additional features
*/
function checkAdditionalFeatures(
csp: string,
strictMode: boolean,
warnings: string[],
recommendations: string[]
): number {
let scorePenalty = 0;
if (csp.includes("'nonce-") && !csp.includes("'strict-dynamic'")) {
recommendations.push(
"Consider adding 'strict-dynamic' for better nonce-based security"
);
}
if (!csp.includes("report-uri") && !csp.includes("report-to")) {
warnings.push("Missing CSP violation reporting");
scorePenalty += 5;
recommendations.push("Add CSP violation reporting for monitoring");
}
if (strictMode) {
if (csp.includes("https:") && !csp.includes("connect-src")) {
warnings.push("Broad HTTPS allowlist detected in strict mode");
scorePenalty += 10;
recommendations.push("Replace 'https:' with specific trusted domains");
}
}
return scorePenalty;
}
/**
* Enhanced CSP validation with security best practices
*/
export function validateCSP(
csp: string,
options: { strictMode?: boolean } = {}
): {
isValid: boolean;
warnings: string[];
errors: string[];
securityScore: number;
recommendations: string[];
} {
const warnings: string[] = [];
const errors: string[] = [];
const recommendations: string[] = [];
const { strictMode = false } = options;
let securityScore = 100;
securityScore -= checkUnsafeDirectives(
csp,
strictMode,
warnings,
errors,
recommendations
);
securityScore -= checkWildcardUsage(csp, errors, recommendations);
securityScore -= checkSecurityFeatures(csp, warnings, recommendations);
securityScore -= checkRequiredDirectives(csp, errors);
securityScore -= checkAdditionalFeatures(
csp,
strictMode,
warnings,
recommendations
);
return {
isValid: errors.length === 0,
warnings,
errors,
securityScore: Math.max(0, securityScore),
recommendations,
};
}
/**
* Parse CSP violation report
*/
export function parseCSPViolation(report: CSPViolationReport): {
directive: string;
blockedUri: string;
sourceFile?: string;
lineNumber?: number;
isInlineViolation: boolean;
isCritical: boolean;
} {
const cspReport = report["csp-report"];
const isInlineViolation =
cspReport["blocked-uri"] === "inline" ||
cspReport["blocked-uri"] === "eval";
const isCritical =
cspReport["violated-directive"].startsWith("script-src") ||
cspReport["violated-directive"].startsWith("object-src");
return {
directive: cspReport["violated-directive"],
blockedUri: cspReport["blocked-uri"],
sourceFile: cspReport["source-file"],
lineNumber: cspReport["line-number"],
isInlineViolation,
isCritical,
};
}
/**
* CSP bypass detection patterns - optimized to prevent ReDoS attacks
*/
export const CSP_BYPASS_PATTERNS = [
// Common XSS bypass attempts (exact matches to prevent ReDoS)
/^javascript:/i,
/^data:text\/html/i,
/^vbscript:/i,
/^livescript:/i,
// Base64 encoded attempts (limited quantifiers to prevent ReDoS)
/^data:[^;]{0,50};base64[^,]{0,100},.*script/i,
/^data:text\/javascript/i,
/^data:application\/javascript/i,
// JSONP callback manipulation (limited lookahead)
/callback=[^&]{0,200}script/i,
// Common CSP bypass techniques (limited quantifiers)
/location\.href[^;]{0,100}javascript/i,
/document\.write[^;]{0,100}script/i,
/\beval\s*\(/i,
/\bnew\s+Function\s*\(/i,
/setTimeout\s*\(\s*['"`][^'"`]{0,500}['"`]/i,
/setInterval\s*\(\s*['"`][^'"`]{0,500}['"`]/i,
];
/**
* Test CSP implementation with common scenarios
*/
export function testCSPImplementation(csp: string): {
testResults: Array<{
name: string;
passed: boolean;
description: string;
recommendation?: string;
}>;
overallScore: number;
} {
const testResults = [];
// Test 1: Script injection protection
testResults.push({
name: "Script Injection Protection",
passed: !csp.includes("'unsafe-inline'") || csp.includes("'nonce-"),
description: "Checks if inline scripts are properly controlled",
recommendation:
csp.includes("'unsafe-inline'") && !csp.includes("'nonce-")
? "Use nonce-based CSP instead of 'unsafe-inline'"
: undefined,
});
// Test 2: Eval protection
testResults.push({
name: "Eval Protection",
passed: !csp.includes("'unsafe-eval'"),
description: "Ensures eval() and similar functions are blocked",
recommendation: csp.includes("'unsafe-eval'")
? "Remove 'unsafe-eval' to prevent code injection"
: undefined,
});
// Test 3: Object blocking
testResults.push({
name: "Object Blocking",
passed: csp.includes("object-src 'none'"),
description: "Blocks dangerous object, embed, and applet elements",
recommendation: !csp.includes("object-src 'none'")
? "Add 'object-src 'none'' to block plugins"
: undefined,
});
// Test 4: Frame protection
testResults.push({
name: "Frame Protection",
passed:
csp.includes("frame-ancestors 'none'") ||
csp.includes("frame-ancestors 'self'"),
description: "Prevents clickjacking attacks",
recommendation: !csp.includes("frame-ancestors")
? "Add 'frame-ancestors 'none'' for clickjacking protection"
: undefined,
});
// Test 5: HTTPS enforcement
testResults.push({
name: "HTTPS Enforcement",
passed: csp.includes("upgrade-insecure-requests"),
description: "Automatically upgrades HTTP requests to HTTPS",
recommendation: !csp.includes("upgrade-insecure-requests")
? "Add 'upgrade-insecure-requests' for automatic HTTPS"
: undefined,
});
// Test 6: Base URI restriction
testResults.push({
name: "Base URI Restriction",
passed: csp.includes("base-uri 'self'") || csp.includes("base-uri 'none'"),
description: "Prevents base tag injection attacks",
recommendation: !csp.includes("base-uri")
? "Add 'base-uri 'self'' to prevent base tag attacks"
: undefined,
});
// Test 7: Form action restriction
testResults.push({
name: "Form Action Restriction",
passed: csp.includes("form-action 'self'") || csp.includes("form-action"),
description: "Controls where forms can be submitted",
recommendation: !csp.includes("form-action")
? "Add 'form-action 'self'' to control form submissions"
: undefined,
});
// Test 8: Reporting configuration
testResults.push({
name: "Violation Reporting",
passed: csp.includes("report-uri") || csp.includes("report-to"),
description: "Enables CSP violation monitoring",
recommendation:
!csp.includes("report-uri") && !csp.includes("report-to")
? "Add 'report-uri' for violation monitoring"
: undefined,
});
const passedTests = testResults.filter((test) => test.passed).length;
const overallScore = Math.round((passedTests / testResults.length) * 100);
return {
testResults,
overallScore,
};
}
/**
* Detect potential CSP bypass attempts
*/
export function detectCSPBypass(content: string): {
isDetected: boolean;
patterns: string[];
riskLevel: "low" | "medium" | "high";
} {
const detectedPatterns: string[] = [];
for (const pattern of CSP_BYPASS_PATTERNS) {
if (pattern.test(content)) {
detectedPatterns.push(pattern.source);
}
}
// Determine risk level based on pattern types (ReDoS-safe patterns)
const highRiskPatterns = [
/^javascript:/i,
/\beval\s*\(/i,
/\bnew\s+Function\s*\(/i,
/^data:text\/javascript/i,
/^data:application\/javascript/i,
/^data:[^;]{0,50};base64[^,]{0,100},.*script/i,
];
const hasHighRiskPattern = detectedPatterns.some((pattern) =>
highRiskPatterns.some((highRisk) => highRisk.source === pattern)
);
const riskLevel =
hasHighRiskPattern || detectedPatterns.length >= 3
? "high"
: detectedPatterns.length >= 1
? "medium"
: "low";
return {
isDetected: detectedPatterns.length > 0,
patterns: detectedPatterns,
riskLevel,
};
}