From ba13d697a50e3955c267efe23f1ba422b3d6522a Mon Sep 17 00:00:00 2001 From: Bastien Chanot Date: Sun, 5 Jul 2026 14:10:55 +0200 Subject: [PATCH] fix(security): unprivileged nginx + security headers on every location - base image -> nginxinc/nginx-unprivileged:1.28-alpine, digest-pinned (BREAKING for the docker path: container port 80 -> 8080; compose mapping/healthcheck updated in the same change, cap_add dropped) - nginx add_header inheritance fix: shared snippets file re-included in every location that sets Cache-Control -- previously ALL security headers were dropped on real responses (verified live before/after) - server_tokens off; set_real_ip_from restricted to 127.0.0.1 - expires directives removed (duplicated Cache-Control); gzip_types text/html redundancy removed (nginx -t warn) --- Dockerfile | 18 +++++++++-------- docker-compose.yml | 12 +++--------- nginx-security-headers.conf | 12 ++++++++++++ nginx.conf | 39 +++++++++++++++++-------------------- 4 files changed, 43 insertions(+), 38 deletions(-) create mode 100644 nginx-security-headers.conf diff --git a/Dockerfile b/Dockerfile index cd4d815..93c7cf1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,29 @@ # Static site for bchanot.fr -# nginx:alpine serves index.html + CV (HTML + PDF). +# nginx-unprivileged serves index.html + CV (HTML + PDF) as uid 101 — +# no root master process in the container (tag + digest pinned). -FROM nginx:1.27-alpine +FROM nginxinc/nginx-unprivileged:1.28-alpine@sha256:209331cfcaec00da781f5b8a38e0d1c0abd00cb2b51e6ad385a30abbbdb04e15 # Custom nginx config (gzip, cache, security headers). COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY nginx-security-headers.conf /etc/nginx/snippets/security-headers.conf -# Site assets. +# Site assets — clean the default content, then copy ours. WORKDIR /usr/share/nginx/html +USER root RUN rm -rf ./* +USER nginx COPY index.html ./ COPY CV_Bastien_Chanot.html ./ COPY CV_Bastien_Chanot.pdf ./ COPY favicon.svg favicon-32.png favicon.ico apple-touch-icon.png ./ -# Non-root hardening: nginx:alpine already drops privileges to "nginx" user -# for worker processes. Master runs as root only to bind port 80 inside -# the container — fine because the host port is the one exposed. -EXPOSE 80 +# nginx-unprivileged listens on 8080 (>1024, no NET_BIND_SERVICE needed). +EXPOSE 8080 # Basic healthcheck: nginx must serve index.html. HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 + CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1 CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.yml b/docker-compose.yml index cfe5bb6..dac3a3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,24 +18,18 @@ services: container_name: bchanot-web restart: unless-stopped ports: - - "127.0.0.1:${PORT:-8080}:80" + - "127.0.0.1:${PORT:-8080}:8080" healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/"] interval: 30s timeout: 3s retries: 3 start_period: 5s read_only: true tmpfs: - - /var/cache/nginx - - /var/run + # nginx-unprivileged writes pid + temp files under /tmp only. - /tmp security_opt: - no-new-privileges:true cap_drop: - ALL - cap_add: - - CHOWN - - SETGID - - SETUID - - NET_BIND_SERVICE diff --git a/nginx-security-headers.conf b/nginx-security-headers.conf new file mode 100644 index 0000000..73c1c53 --- /dev/null +++ b/nginx-security-headers.conf @@ -0,0 +1,12 @@ +# Security headers for bchanot.fr — included at server level AND in every +# location that declares its own add_header: nginx add_header inheritance +# is all-or-nothing (one add_header in a location drops ALL inherited +# headers), so each such location must re-include this file. +# HSTS is intentionally NOT here — it belongs to the TLS-terminating proxy. + +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always; +# CSP: inline CSS + JS are allowed (project convention), fonts from Google. +add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" always; diff --git a/nginx.conf b/nginx.conf index 1927383..3cd7d90 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,29 +1,27 @@ # nginx server block for bchanot.fr static site. -# Container listens on port 80; host port is configured via docker-compose -# (PORT env var). A host-level reverse proxy (nginx, Traefik, Caddy) should -# terminate TLS and proxy_pass to http://127.0.0.1:${PORT}. +# Container (nginx-unprivileged) listens on 8080; host port is configured via +# docker-compose (PORT env var). A host-level reverse proxy (nginx, Traefik, +# Caddy) should terminate TLS and proxy_pass to http://127.0.0.1:${PORT}. server { - listen 80; - listen [::]:80; + listen 8080; + listen [::]:8080; server_name _; root /usr/share/nginx/html; index index.html; - # Security headers. HSTS is intentionally NOT set here — leave it to the - # outer reverse proxy that terminates TLS, otherwise it may be sent over - # plain HTTP between proxy and container. - add_header X-Content-Type-Options "nosniff" always; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always; - # CSP: inline CSS + JS are allowed (project convention), fonts from Google. - add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" always; + # Don't advertise the nginx version. + server_tokens off; - # Forwarded headers — trust the upstream reverse proxy. + # Security headers — shared snippet. Re-included in every location that + # sets its own add_header (inheritance is all-or-nothing in nginx). + include /etc/nginx/snippets/security-headers.conf; + + # Forwarded headers — trust only the local reverse proxy (the container + # port is bound to 127.0.0.1 in docker-compose). real_ip_header X-Forwarded-For; - set_real_ip_from 0.0.0.0/0; + set_real_ip_from 127.0.0.1; # Compression. gzip on; @@ -34,7 +32,6 @@ server { gzip_types text/plain text/css - text/html text/javascript application/javascript application/json @@ -44,24 +41,24 @@ server { # Long cache for the PDF (regenerated rarely, content-hash not used). location ~* \.pdf$ { - expires 7d; add_header Cache-Control "public, max-age=604800"; + include /etc/nginx/snippets/security-headers.conf; } # Short cache for HTML so content updates land fast. location ~* \.html$ { - expires 1h; add_header Cache-Control "public, max-age=3600, must-revalidate"; + include /etc/nginx/snippets/security-headers.conf; } # Long cache for favicon + image assets (rarely change). location ~* \.(?:ico|svg|png|jpg|jpeg|gif|webp)$ { - expires 30d; add_header Cache-Control "public, max-age=2592000, immutable"; + include /etc/nginx/snippets/security-headers.conf; access_log off; } - # Logs to stdout/stderr (default in nginx:alpine). + # Logs to stdout/stderr (default in nginx images). access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log warn;