mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 15:32:10 +01:00
refactor: fix biome linting issues and update project documentation
- Fix 36+ biome linting issues reducing errors/warnings from 227 to 191 - Replace explicit 'any' types with proper TypeScript interfaces - Fix React hooks dependencies and useCallback patterns - Resolve unused variables and parameter assignment issues - Improve accessibility with proper label associations - Add comprehensive API documentation for admin and security features - Update README.md with accurate PostgreSQL setup and current tech stack - Create complete documentation for audit logging, CSP monitoring, and batch processing - Fix outdated project information and missing developer workflows
This commit is contained in:
509
lib/csp.ts
Normal file
509
lib/csp.ts
Normal file
@ -0,0 +1,509 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Check for unsafe directives
|
||||
if (csp.includes("'unsafe-inline'") && !csp.includes("'nonce-")) {
|
||||
warnings.push("Using 'unsafe-inline' without nonce is less secure");
|
||||
securityScore -= 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");
|
||||
securityScore -= 25;
|
||||
} else {
|
||||
warnings.push("'unsafe-eval' allows dangerous code execution");
|
||||
securityScore -= 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for overly permissive directives (but exclude font wildcards and subdomain wildcards)
|
||||
const hasProblematicWildcards =
|
||||
csp.includes(" *") ||
|
||||
csp.includes("*://") ||
|
||||
(csp.includes("*") && !csp.includes("*.") && !csp.includes("wss: ws:"));
|
||||
|
||||
if (hasProblematicWildcards) {
|
||||
errors.push("Wildcard (*) sources are not recommended");
|
||||
securityScore -= 30;
|
||||
recommendations.push("Replace wildcards with specific trusted domains");
|
||||
}
|
||||
|
||||
if (
|
||||
csp.includes("data:") &&
|
||||
!csp.includes("img-src") &&
|
||||
!csp.includes("font-src")
|
||||
) {
|
||||
warnings.push("data: URIs should be limited to specific directives");
|
||||
securityScore -= 5;
|
||||
}
|
||||
|
||||
// Check for HTTPS upgrade
|
||||
if (!csp.includes("upgrade-insecure-requests")) {
|
||||
warnings.push("Missing HTTPS upgrade directive");
|
||||
securityScore -= 10;
|
||||
recommendations.push("Add 'upgrade-insecure-requests' directive");
|
||||
}
|
||||
|
||||
// Check for frame protection
|
||||
if (!csp.includes("frame-ancestors")) {
|
||||
warnings.push("Missing frame-ancestors directive");
|
||||
securityScore -= 15;
|
||||
recommendations.push(
|
||||
"Add 'frame-ancestors 'none'' to prevent clickjacking"
|
||||
);
|
||||
}
|
||||
|
||||
// Check required directives
|
||||
const requiredDirectives = [
|
||||
"default-src",
|
||||
"script-src",
|
||||
"style-src",
|
||||
"object-src",
|
||||
"base-uri",
|
||||
"form-action",
|
||||
];
|
||||
|
||||
for (const directive of requiredDirectives) {
|
||||
if (!csp.includes(directive)) {
|
||||
errors.push(`Missing required directive: ${directive}`);
|
||||
securityScore -= 20;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for modern CSP features
|
||||
if (csp.includes("'nonce-") && !csp.includes("'strict-dynamic'")) {
|
||||
recommendations.push(
|
||||
"Consider adding 'strict-dynamic' for better nonce-based security"
|
||||
);
|
||||
}
|
||||
|
||||
// Check reporting setup
|
||||
if (!csp.includes("report-uri") && !csp.includes("report-to")) {
|
||||
warnings.push("Missing CSP violation reporting");
|
||||
securityScore -= 5;
|
||||
recommendations.push("Add CSP violation reporting for monitoring");
|
||||
}
|
||||
|
||||
// Strict mode additional checks
|
||||
if (strictMode) {
|
||||
if (csp.includes("https:") && !csp.includes("connect-src")) {
|
||||
warnings.push("Broad HTTPS allowlist detected in strict mode");
|
||||
securityScore -= 10;
|
||||
recommendations.push("Replace 'https:' with specific trusted domains");
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
export const CSP_BYPASS_PATTERNS = [
|
||||
// Common XSS bypass attempts
|
||||
/javascript:/i,
|
||||
/data:text\/html/i,
|
||||
/vbscript:/i,
|
||||
/livescript:/i,
|
||||
|
||||
// Base64 encoded attempts
|
||||
/data:.*base64.*script/i,
|
||||
/data:text\/javascript/i,
|
||||
/data:application\/javascript/i,
|
||||
|
||||
// JSONP callback manipulation
|
||||
/callback=.*script/i,
|
||||
|
||||
// Common CSP bypass techniques
|
||||
/location\.href.*javascript/i,
|
||||
/document\.write.*script/i,
|
||||
/eval\(/i,
|
||||
/\bnew\s+Function\s*\(/i,
|
||||
/setTimeout\s*\(\s*['"`].*['"`]/i,
|
||||
/setInterval\s*\(\s*['"`].*['"`]/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
|
||||
const highRiskPatterns = [
|
||||
/javascript:/i,
|
||||
/eval\(/i,
|
||||
/\bnew\s+Function\s*\(/i,
|
||||
/data:text\/javascript/i,
|
||||
/data:application\/javascript/i,
|
||||
/data:.*base64.*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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user