diff --git a/Dockerfile b/Dockerfile index cddde58f..6f3ef4e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ FROM nginx:1.31-alpine # Copy custom nginx config COPY nginx.conf /etc/nginx/nginx.conf +COPY security-headers.conf /etc/nginx/security-headers.conf # Copy built assets from builder COPY --from=builder /build/dist /usr/share/nginx/html diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 184b14c0..c7576453 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -93,15 +93,8 @@ server { ssl_session_cache shared:SSL:10m; ssl_session_tickets off; - # HSTS - add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; - - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.tile.openstreetmap.org; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; worker-src 'self' blob:; manifest-src 'self'" always; + # Security headers (shared include; see security-headers.conf) + include /etc/nginx/security-headers.conf; root /usr/share/nginx/html; index index.html; @@ -110,11 +103,13 @@ server { # these must be no-cache (the real filenames are sw.js/registerSW.js, # not service-worker.js). location = /sw.js { + include /etc/nginx/security-headers.conf; add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; add_header Expires 0; } location = /registerSW.js { + include /etc/nginx/security-headers.conf; add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; add_header Expires 0; @@ -122,12 +117,14 @@ server { # Static assets with long cache location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)\$ { + include /etc/nginx/security-headers.conf; expires 1y; add_header Cache-Control "public, immutable"; } # Health check location /health { + include /etc/nginx/security-headers.conf; access_log off; default_type text/plain; return 200 "healthy\n"; diff --git a/nginx.conf b/nginx.conf index c283f127..6a1a8dfe 100644 --- a/nginx.conf +++ b/nginx.conf @@ -108,11 +108,7 @@ http { index index.html; # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.tile.openstreetmap.org; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; worker-src 'self' blob:; manifest-src 'self'" always; + include /etc/nginx/security-headers.conf; # Service worker + its registration bootstrap. vite-plugin-pwa's # generateSW mode emits sw.js and registerSW.js (not the @@ -128,11 +124,13 @@ http { # content-hashed, so a new build simply ships a differently-named # file rather than requiring cache invalidation of the old one. location = /sw.js { + include /etc/nginx/security-headers.conf; add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; add_header Expires 0; } location = /registerSW.js { + include /etc/nginx/security-headers.conf; add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; add_header Expires 0; @@ -140,6 +138,7 @@ http { # Runtime env config — must never be cached (changes per deployment) location = /env-config.js { + include /etc/nginx/security-headers.conf; add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; add_header Expires 0; @@ -147,12 +146,14 @@ http { # Static assets with long cache location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + include /etc/nginx/security-headers.conf; expires 1y; add_header Cache-Control "public, immutable"; } # Health check endpoint location /health { + include /etc/nginx/security-headers.conf; access_log off; return 200 "healthy\n"; add_header Content-Type text/plain; diff --git a/security-headers.conf b/security-headers.conf new file mode 100644 index 00000000..e131d4d7 --- /dev/null +++ b/security-headers.conf @@ -0,0 +1,16 @@ +# Security headers shared by every location that serves content. +# +# nginx's add_header is inherited from an outer block ONLY when the current +# level defines no add_header of its own -- and the `always` flag does not +# change that. Any location that sets its own header (e.g. Cache-Control) +# therefore silently drops the entire server-level set. Every such location +# includes this file so the headers survive. +add_header X-Frame-Options "SAMEORIGIN" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-XSS-Protection "1; mode=block" always; +add_header Referrer-Policy "no-referrer-when-downgrade" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.tile.openstreetmap.org; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; worker-src 'self' blob:; manifest-src 'self'" always; +# Ignored by user agents when received over plain HTTP (per RFC 6797), so it is +# safe to emit unconditionally and keeps the TLS server block from needing its +# own add_header -- which would re-trigger the inheritance problem above. +add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;