feat(nginx): add security headers with per-domain CSP

- Add HSTS (6 months, includeSubDomains, preload-ready)
- Add X-Content-Type-Options: nosniff
- Add Permissions-Policy (disable camera/mic/geolocation)
- Add Cross-Origin-Resource-Policy: same-origin
- Add Cross-Origin-Opener-Policy: same-origin
- Add configurable Content-Security-Policy per domain

Per-service CSP tuning:
- SearXNG: null (handles its own CSP in settings.yml)
- Forgejo: relaxed (unsafe-inline/eval for code highlighting)
- Vaultwarden: relaxed (unsafe-eval for WebCrypto vault)

Fixes: missing CSP, HSTS, X-Content-Type-Options headers
This commit is contained in:
Franz Kafka 2026-03-19 13:42:41 +00:00
parent 6354a030f0
commit fbea02867e
4 changed files with 35 additions and 1 deletions

View file

@ -100,6 +100,8 @@ in
extraConfig = ''
client_max_body_size 512M;
'';
# Relaxed CSP for Forgejo — needs inline styles for code highlighting
contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' wss://${cfg.domain}; frame-ancestors 'self'";
};
# Open SSH port for Git

View file

@ -1,5 +1,5 @@
# Nginx Reverse Proxy Module
# Provides: Nginx with automatic Let's Encrypt certificates
# Provides: Nginx with automatic Let's Encrypt certificates and security headers
#
# Usage:
# myModules.nginx = {
@ -39,11 +39,19 @@ in
type = lib.types.port;
description = "Local port to proxy to";
};
extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Extra Nginx config for this location";
};
contentSecurityPolicy = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'";
description = "Content-Security-Policy header value. Set to null to omit.";
};
extraLocations = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
@ -96,6 +104,26 @@ in
enableACME = true;
forceSSL = true;
# Security headers applied per-vhost
extraConfig = ''
# Strict Transport Security — 6 months, include subdomains, preload-ready
add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always;
# Prevent MIME type sniffing
add_header X-Content-Type-Options "nosniff" always;
# Restrict browser features
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Cross-origin isolation
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
# Content Security Policy (configurable per-domain)
'' + lib.optionalString (opts.contentSecurityPolicy != null) ''
add_header Content-Security-Policy "${opts.contentSecurityPolicy}" always;
'';
locations = {
"/" = {
proxyPass = "http://127.0.0.1:${toString opts.port}";

View file

@ -114,6 +114,8 @@ in
extraConfig = ''
client_max_body_size 128M;
'';
# Relaxed CSP for Vaultwarden — needs unsafe-eval for WebCrypto vault
contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://haveibeenpwned.com; font-src 'self'; connect-src 'self' wss://${cfg.domain} https://api.bitwarden.com https://haveibeenpwned.com; frame-ancestors 'self'";
extraLocations."/notifications/hub" = {
proxyPass = "http://127.0.0.1:${toString cfg.websocketPort}";
extraConfig = ''