From 68afb6fc6c17f675e29db27fca442f62164ad1de Mon Sep 17 00:00:00 2001 From: Leopere Date: Sat, 1 Mar 2025 18:33:46 -0500 Subject: [PATCH] Add comprehensive security headers support with testing framework --- README.md | 19 ++++- config.js | 7 +- docker-compose.yml | 41 ++++++++++- lib/csp.js | 174 +++++++++++++++++++++++++++------------------ server.js | 4 +- test-security.js | 26 ++++++- test-security.sh | 12 +++- 7 files changed, 205 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index d44113a..5b8ff5f 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ The `security` section in the configuration allows you to control various securi "hsts": false, // Enable HTTP Strict Transport Security "scriptSources": [], // Additional allowed script sources "bypassCSPInDev": false, // Use permissive CSP in development mode - "allowUnsafeHashes": true // Allow 'unsafe-hashes' in production for event handlers + "allowUnsafeHashes": true, // Allow 'unsafe-hashes' in production for event handlers + "enableCrossOriginIsolation": false // Enable strict Cross-Origin isolation headers } } ``` @@ -87,6 +88,7 @@ The `security` section in the configuration allows you to control various securi * `scriptSources` - Additional script sources to allow - comma-separated list in env vars * `bypassCSPInDev` - In development mode (NODE_ENV=development), use a more permissive CSP that includes 'unsafe-inline' (default: false) * `allowUnsafeHashes` - Allow 'unsafe-hashes' in production mode for DOM event handlers (default: true) +* `enableCrossOriginIsolation` - Enable strict Cross-Origin isolation headers (COEP, COOP, CORP) which enhance security but may break certain integrations (default: false) ### Environment Variables for Security Settings @@ -96,6 +98,7 @@ You can set these options through environment variables: * `HASTEBIN_SCRIPT_SOURCES` - Additional script sources (comma-separated) * `HASTEBIN_BYPASS_CSP_IN_DEV` - Allow unsafe-inline in development (true/false) * `HASTEBIN_ALLOW_UNSAFE_HASHES` - Allow unsafe-hashes in production (true/false) +* `HASTEBIN_ENABLE_CROSS_ORIGIN_ISOLATION` - Enable Cross-Origin isolation headers (true/false) ### CSP Implementation Details @@ -106,6 +109,20 @@ The Content Security Policy implementation in Hastebin uses nonces to secure inl 3. **Production Mode**: In production, the CSP is configured to use nonces for all scripts, with optional 'unsafe-hashes' for event handlers 4. **Templates**: The template system automatically injects nonces into script tags, so you don't need to manually add them to the HTML +### Additional Security Headers + +Besides CSP, Hastebin implements several other security headers: + +1. **X-Content-Type-Options**: `nosniff` - Prevents MIME-type sniffing +2. **X-Frame-Options**: `DENY` - Prevents clickjacking attacks +3. **X-XSS-Protection**: `1; mode=block` - An additional layer of XSS protection +4. **Referrer-Policy**: `strict-origin-when-cross-origin` - Controls referrer information +5. **Permissions-Policy**: Restricts browser features (camera, microphone, geolocation, etc.) +6. **Cross-Origin-Embedder-Policy**: `require-corp` - Enhances cross-origin isolation +7. **Cross-Origin-Resource-Policy**: `same-origin` - Protects resources from unauthorized requests +8. **Cross-Origin-Opener-Policy**: `same-origin` - Helps with cross-origin isolation +9. **Strict-Transport-Security**: `max-age=31536000; includeSubDomains; preload` - Ensures HTTPS usage (when enabled) + #### Running in Development Mode To run Hastebin with a more permissive CSP for development: diff --git a/config.js b/config.js index 43b90c5..7bfbfbc 100644 --- a/config.js +++ b/config.js @@ -35,7 +35,12 @@ const config = { // Allow unsafe-hashes in production for event handlers (default: true) // This adds 'unsafe-hashes' to the policy for DOM event handlers allowUnsafeHashes: process.env.HASTEBIN_ALLOW_UNSAFE_HASHES ? - (process.env.HASTEBIN_ALLOW_UNSAFE_HASHES.toLowerCase() === 'true') : true + (process.env.HASTEBIN_ALLOW_UNSAFE_HASHES.toLowerCase() === 'true') : true, + + // Enable Cross-Origin isolation headers (default: false) + // This adds COOP, COEP, and CORP headers - can break some integrations + enableCrossOriginIsolation: process.env.HASTEBIN_ENABLE_CROSS_ORIGIN_ISOLATION ? + (process.env.HASTEBIN_ENABLE_CROSS_ORIGIN_ISOLATION.toLowerCase() === 'true') : false }, // Logging configuration diff --git a/docker-compose.yml b/docker-compose.yml index 510b0ce..a7cf231 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,61 @@ services: redis: - image: eqalpha/keydb + image: git.nixc.us/colin/haste:production-redis volumes: - redis_data:/data networks: - default + deploy: + placement: + constraints: + - node.hostname == macmini3 + replicas: 1 + restart_policy: + condition: on-failure + haste: image: git.nixc.us/colin/haste:production-haste volumes: - public_system:/haste/public/system + environment: + - NODE_ENV=production + - HASTEBIN_ENABLE_CSP=true + - HASTEBIN_ENABLE_HSTS=true + - HASTEBIN_ALLOW_UNSAFE_HASHES=true + - HASTEBIN_SCRIPT_SOURCES= + - HASTEBIN_BYPASS_CSP_IN_DEV=false networks: - traefik - default + deploy: + placement: + constraints: + - node.hostname == macmini3 + labels: + homepage.group: apps + homepage.name: HasteBin + homepage.href: https://haste.nixc.us/ + homepage.description: HasteBin + us.nixc.autodeploy: "true" + traefik.enable: "true" + traefik.http.routers.production-haste_haste.rule: "Host(`haste.nixc.us`)" + traefik.http.routers.production-haste_haste.entrypoints: "websecure" + traefik.http.routers.production-haste_haste.tls: "true" + traefik.http.routers.production-haste_haste.tls.certresolver: "letsencryptresolver" + traefik.http.routers.production-haste_haste.service: "production-haste_haste" + traefik.http.services.production-haste_haste.loadbalancer.server.port: "7777" + traefik.docker.network: "traefik" + + replicas: 1 + restart_policy: + condition: on-failure + networks: traefik: external: true default: driver: overlay + volumes: public_system: driver: local diff --git a/lib/csp.js b/lib/csp.js index 95fae84..cf2b6ad 100644 --- a/lib/csp.js +++ b/lib/csp.js @@ -6,104 +6,138 @@ const crypto = require('crypto'); const winston = require('winston'); -// CSP middleware -function cspMiddleware(config) { +// Security headers middleware (renamed from CSP middleware as it now handles more headers) +function securityMiddleware(config) { // Default to enabled if not specified - const enabled = config.security && typeof config.security.csp !== 'undefined' ? + const cspEnabled = config.security && typeof config.security.csp !== 'undefined' ? config.security.csp : true; - // If CSP is disabled, return a no-op middleware - if (!enabled) { + // If security is entirely disabled, return a no-op middleware + // Note: This is different from just disabling CSP + if (config.security === false) { return function(req, res, next) { next(); }; } return function(req, res, next) { - // Only add CSP headers for HTML requests + // Only add security 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 - const scriptSources = ["'self'", `'nonce-${nonce}'`]; - - // Add any additional script sources from config - if (config.security && config.security.scriptSources && config.security.scriptSources.length > 0) { - config.security.scriptSources.forEach(source => { - if (source && !scriptSources.includes(source)) { - scriptSources.push(source); - } - }); - } - - // Add unsafe-hashes if configured (for event handlers) - if (config.security && config.security.allowUnsafeHashes) { - scriptSources.push("'unsafe-hashes'"); - } - - // 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; + // Apply CSP headers if enabled + if (cspEnabled) { + // Generate a unique nonce for this request + const nonce = crypto.randomBytes(16).toString('base64'); - 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'" - ]; + // Store nonce in request object for use in HTML template + req.cspNonce = nonce; + + // Build the base script sources list + const scriptSources = ["'self'", `'nonce-${nonce}'`]; + + // Add any additional script sources from config + if (config.security && config.security.scriptSources && config.security.scriptSources.length > 0) { + config.security.scriptSources.forEach(source => { + if (source && !scriptSources.includes(source)) { + scriptSources.push(source); + } + }); + } + + // Add unsafe-hashes if configured (for event handlers) + if (config.security && config.security.allowUnsafeHashes) { + scriptSources.push("'unsafe-hashes'"); + } + + // 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; - winston.debug('Using permissive development CSP policy with unsafe-inline (bypass enabled)'); + 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 ${scriptSources.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 { - // Standard development mode - still using nonces + // Production mode - strict policy with nonces cspDirectives = [ "default-src 'self'", `script-src ${scriptSources.join(' ')}`, "style-src 'self' 'unsafe-inline'", "img-src 'self' data:", "connect-src 'self'", - "font-src 'self'" + "font-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'none'", + "object-src 'none'" ]; - winston.debug('Using development CSP policy with nonces (bypass disabled)'); + winston.debug(`Using strict production CSP policy with nonces${config.security.allowUnsafeHashes ? " and unsafe-hashes" : ""}`); } - } else { - // Production mode - strict policy with nonces - cspDirectives = [ - "default-src 'self'", - `script-src ${scriptSources.join(' ')}`, - "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${config.security.allowUnsafeHashes ? " and unsafe-hashes" : ""}`); + // Set the CSP header with the properly formatted policy + res.setHeader('Content-Security-Policy', cspDirectives.join('; ')); + } else { + winston.debug('CSP is disabled by configuration'); } - // Set the CSP header with the properly formatted policy - res.setHeader('Content-Security-Policy', cspDirectives.join('; ')); + // Add other security headers - always applied regardless of CSP setting - // Add other security headers + // 1. X-Content-Type-Options - prevents MIME-type sniffing res.setHeader('X-Content-Type-Options', 'nosniff'); + + // 2. X-Frame-Options - prevents clickjacking res.setHeader('X-Frame-Options', 'DENY'); + + // 3. X-XSS-Protection - legacy header, still used by some browsers res.setHeader('X-XSS-Protection', '1; mode=block'); - res.setHeader('Referrer-Policy', 'no-referrer'); + + // 4. Referrer-Policy - controls how much referrer information is included + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // 5. Permissions-Policy - controls browser features + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), interest-cohort=()'); + + // Add Cross-Origin headers only if enabled in config + // These can be problematic for some applications, so we make them optional + const enableCrossOriginIsolation = config.security && config.security.enableCrossOriginIsolation; + + if (enableCrossOriginIsolation) { + // 6. Cross-Origin-Embedder-Policy - for cross-origin isolation + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); + + // 7. Cross-Origin-Resource-Policy - protects resources from unauthorized requests + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); + + // 8. Cross-Origin-Opener-Policy - helps with cross-origin isolation + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + + winston.debug('Added Cross-Origin isolation headers'); + } // If configured, add HSTS header if (config.security && config.security.hsts) { @@ -116,4 +150,4 @@ function cspMiddleware(config) { } // Export the middleware -module.exports = cspMiddleware; \ No newline at end of file +module.exports = securityMiddleware; \ No newline at end of file diff --git a/server.js b/server.js index 8f998d1..2131727 100644 --- a/server.js +++ b/server.js @@ -11,7 +11,7 @@ var connect_st = require('st'); var connect_rate_limit = require('connect-ratelimit'); var DocumentHandler = require('./lib/document_handler'); -var cspMiddleware = require('./lib/csp'); +var securityMiddleware = require('./lib/csp'); var templateHandler = require('./lib/template_handler'); // Load the configuration @@ -116,7 +116,7 @@ var documentHandler = new DocumentHandler({ var app = connect(); // Add CSP middleware early in the chain -app.use(cspMiddleware(config)); +app.use(securityMiddleware(config)); // Add CORS support app.use(function(req, res, next) { diff --git a/test-security.js b/test-security.js index 641e355..f5d809c 100755 --- a/test-security.js +++ b/test-security.js @@ -56,7 +56,13 @@ const TESTS = { name: 'Disabled CSP', env: { NODE_ENV: 'production', HASTEBIN_ENABLE_CSP: 'false' }, expectedHeaders: { - 'content-security-policy': false + 'content-security-policy': false, + // Even with CSP disabled, these headers should still be present + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + 'x-xss-protection': '1; mode=block', + 'referrer-policy': 'strict-origin-when-cross-origin', + 'permissions-policy': true } }, cors: { @@ -89,6 +95,24 @@ const TESTS = { expectedHeaders: { 'content-security-policy': value => value.includes("'unsafe-inline'") } + }, + combinedSecurity: { + name: 'Combined Security Settings', + env: { + NODE_ENV: 'production', + HASTEBIN_ENABLE_CSP: 'false', + HASTEBIN_ENABLE_CROSS_ORIGIN_ISOLATION: 'true', + HASTEBIN_ENABLE_HSTS: 'true' + }, + expectedHeaders: { + 'content-security-policy': false, + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + 'cross-origin-embedder-policy': 'require-corp', + 'cross-origin-resource-policy': 'same-origin', + 'cross-origin-opener-policy': 'same-origin', + 'strict-transport-security': true + } } }; diff --git a/test-security.sh b/test-security.sh index 1c2caf6..c8f7d35 100755 --- a/test-security.sh +++ b/test-security.sh @@ -229,7 +229,7 @@ run_tests() { fi if [[ -z "$test_filter" || "$test_filter" == *"noCsp"* ]]; then - if run_test "Disabled CSP" "NODE_ENV=production HASTEBIN_ENABLE_CSP=false" "content-security-policy:ABSENT"; then + if run_test "Disabled CSP" "NODE_ENV=production HASTEBIN_ENABLE_CSP=false" "content-security-policy:ABSENT,x-content-type-options:nosniff,x-frame-options:DENY"; then passed=$((passed+1)) else failed=$((failed+1)) @@ -268,6 +268,14 @@ run_tests() { fi fi + if [[ -z "$test_filter" || "$test_filter" == *"combined"* ]]; then + if run_test "Combined Security Settings" "NODE_ENV=production HASTEBIN_ENABLE_CSP=false HASTEBIN_ENABLE_CROSS_ORIGIN_ISOLATION=true HASTEBIN_ENABLE_HSTS=true" "content-security-policy:ABSENT,x-content-type-options:nosniff,x-frame-options:DENY,cross-origin-embedder-policy:require-corp,strict-transport-security:max-age"; then + passed=$((passed+1)) + else + failed=$((failed+1)) + fi + fi + # Cleanup any remaining server process kill_server @@ -285,7 +293,7 @@ run_tests() { # Show help if requested if [ $# -gt 0 ] && [ "$1" == "--help" ]; then echo "Usage: $0 [--test=test1,test2,...]" - echo "Available tests: basic, csp, noCsp, cors, hsts, devMode, devBypass" + echo "Available tests: basic, csp, noCsp, cors, hsts, devMode, devBypass, combined" exit 0 fi