163 lines
5.6 KiB
JavaScript
163 lines
5.6 KiB
JavaScript
/**
|
|
* Content Security Policy middleware
|
|
* Generates nonces for inline scripts and sets appropriate CSP headers
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const winston = require('winston');
|
|
|
|
// Keep a cache of file hashes
|
|
const fileHashes = {};
|
|
|
|
// Generate a SHA-256 hash for a file or string
|
|
function generateHash(content) {
|
|
const hash = crypto.createHash('sha256');
|
|
hash.update(content);
|
|
return `'sha256-${hash.digest('base64')}'`;
|
|
}
|
|
|
|
// Calculate hash for a file and cache it
|
|
function getFileHash(filePath) {
|
|
if (!fileHashes[filePath]) {
|
|
try {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
fileHashes[filePath] = generateHash(content);
|
|
} catch (err) {
|
|
console.error(`Error generating hash for ${filePath}:`, err);
|
|
return null;
|
|
}
|
|
}
|
|
return fileHashes[filePath];
|
|
}
|
|
|
|
// CSP middleware
|
|
function cspMiddleware(config) {
|
|
// Default to enabled if not specified
|
|
const enabled = config.security && typeof config.security.csp !== 'undefined' ?
|
|
config.security.csp : true;
|
|
|
|
// If CSP is disabled, return a no-op middleware
|
|
if (!enabled) {
|
|
return function(req, res, next) { next(); };
|
|
}
|
|
|
|
// Calculate hashes for static JS files once at startup
|
|
const staticDir = path.join(process.cwd(), 'static');
|
|
let appJsHash, highlightJsHash, jqueryJsHash;
|
|
|
|
try {
|
|
// Only generate hashes if the files exist
|
|
if (fs.existsSync(path.join(staticDir, 'application.min.js'))) {
|
|
appJsHash = getFileHash(path.join(staticDir, 'application.min.js'));
|
|
winston.debug('Generated hash for application.min.js');
|
|
}
|
|
|
|
if (fs.existsSync(path.join(staticDir, 'highlight.min.js'))) {
|
|
highlightJsHash = getFileHash(path.join(staticDir, 'highlight.min.js'));
|
|
winston.debug('Generated hash for highlight.min.js');
|
|
}
|
|
|
|
if (fs.existsSync(path.join(staticDir, 'jquery.min.js'))) {
|
|
jqueryJsHash = getFileHash(path.join(staticDir, 'jquery.min.js'));
|
|
winston.debug('Generated hash for jquery.min.js');
|
|
}
|
|
} catch (err) {
|
|
winston.error('Error generating file hashes:', err);
|
|
}
|
|
|
|
return function(req, res, next) {
|
|
// Only add CSP headers for HTML requests
|
|
const isHtmlRequest = req.url === '/' || req.url.match(/^\/[a-zA-Z0-9_-]+$/);
|
|
|
|
if (isHtmlRequest) {
|
|
// Generate a unique nonce for this request
|
|
const nonce = crypto.randomBytes(16).toString('base64');
|
|
|
|
// Store nonce in request object for use in HTML template
|
|
req.cspNonce = nonce;
|
|
|
|
// Build the base script sources list - now only self-hosted content
|
|
const baseScriptSources = ["'self'"];
|
|
|
|
// Add nonce
|
|
baseScriptSources.push(`'nonce-${nonce}'`);
|
|
|
|
// Add static file hashes if available
|
|
if (appJsHash) baseScriptSources.push(appJsHash);
|
|
if (highlightJsHash) baseScriptSources.push(highlightJsHash);
|
|
if (jqueryJsHash) baseScriptSources.push(jqueryJsHash);
|
|
|
|
// Create the policy - adjust based on environment
|
|
let cspDirectives;
|
|
|
|
// Development mode with potential bypass option
|
|
if (process.env.NODE_ENV === 'development') {
|
|
// Check if we should bypass strict CSP in development
|
|
const bypassCSPInDev = config.security && config.security.bypassCSPInDev;
|
|
|
|
if (bypassCSPInDev) {
|
|
// Very permissive policy with unsafe-inline for development testing
|
|
cspDirectives = [
|
|
"default-src 'self'",
|
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data:",
|
|
"connect-src 'self'",
|
|
"font-src 'self'"
|
|
];
|
|
|
|
winston.debug('Using permissive development CSP policy with unsafe-inline (bypass enabled)');
|
|
} else {
|
|
// Standard development mode - still using nonces
|
|
cspDirectives = [
|
|
"default-src 'self'",
|
|
`script-src ${baseScriptSources.join(' ')}`,
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data:",
|
|
"connect-src 'self'",
|
|
"font-src 'self'"
|
|
];
|
|
|
|
winston.debug('Using development CSP policy with nonces (bypass disabled)');
|
|
}
|
|
} else {
|
|
// Production mode - always strict policy with nonces and hashes
|
|
cspDirectives = [
|
|
"default-src 'self'",
|
|
`script-src ${baseScriptSources.join(' ')}${config.security.allowUnsafeHashes ? " 'unsafe-hashes'" : ""}`,
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data:",
|
|
"connect-src 'self'",
|
|
"font-src 'self'",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'none'",
|
|
"object-src 'none'"
|
|
];
|
|
|
|
winston.debug(`Using strict production CSP policy with nonces, hashes${config.security.allowUnsafeHashes ? ", and unsafe-hashes" : ""}`);
|
|
}
|
|
|
|
// Set the CSP header with the properly formatted policy
|
|
res.setHeader('Content-Security-Policy', cspDirectives.join('; '));
|
|
|
|
// Add other security headers
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
|
|
// If configured, add HSTS header
|
|
if (config.security && config.security.hsts) {
|
|
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
|
}
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
// Export the middleware
|
|
module.exports = cspMiddleware;
|