From 4941160550ffeebb54b2081b1ea63481b78a177e Mon Sep 17 00:00:00 2001 From: dokdev Date: Fri, 17 Jul 2026 22:48:31 +0300 Subject: [PATCH] feat(lab11): hardened nginx + WAF sidecar --- labs/lab11/reverse-proxy/nginx.conf | 58 ++++-- labs/lab11/waf/docker-compose.override.yml | 43 +++++ submissions/lab11.md | 208 +++++++++++++++++++++ 3 files changed, 292 insertions(+), 17 deletions(-) create mode 100644 labs/lab11/waf/docker-compose.override.yml create mode 100644 submissions/lab11.md diff --git a/labs/lab11/reverse-proxy/nginx.conf b/labs/lab11/reverse-proxy/nginx.conf index dff91b265..c179d7f7f 100644 --- a/labs/lab11/reverse-proxy/nginx.conf +++ b/labs/lab11/reverse-proxy/nginx.conf @@ -26,10 +26,17 @@ http { keepalive 32; } - # Rate limit zone for login - # ~10 req/min per IP, burst of 5 - limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m; + # ---- Task 2: rate limiting + connection limiting ---- + # ~10 req/min per IP on the login endpoint, burst of 5 + limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m; limit_req_status 429; + # Cap concurrent connections per client IP (slowloris / connection-exhaustion defense) + limit_conn_zone $binary_remote_addr zone=conn:10m; + + # ---- Task 2: fail-closed request-side timeouts (GLOBAL: apply to :80 and :443) ---- + client_body_timeout 10s; + client_header_timeout 10s; + send_timeout 10s; map $http_upgrade $connection_upgrade { default upgrade; '' close; } @@ -43,6 +50,7 @@ http { proxy_set_header Upgrade $http_upgrade; # Prevent upstream TLS BREACH vector by disabling compression from upstream proxy_set_header Accept-Encoding ""; + # ---- Task 2: fail-closed timeouts ---- proxy_read_timeout 30s; proxy_send_timeout 30s; proxy_connect_timeout 5s; @@ -58,7 +66,7 @@ http { proxy_hide_header Content-Security-Policy-Report-Only; proxy_hide_header Access-Control-Allow-Origin; - # HTTP server (redirect to HTTPS) + # ================= HTTP server (redirect to HTTPS) ================= server { listen 80; listen [::]:80; @@ -68,7 +76,7 @@ http { add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Resource-Policy "same-origin" always; add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" always; @@ -76,7 +84,7 @@ http { return 308 https://$host$request_uri; } - # HTTPS server + # ================= HTTPS server ================= server { listen 443 ssl; listen [::]:443 ssl; @@ -85,35 +93,51 @@ http { ssl_certificate /etc/nginx/certs/localhost.crt; ssl_certificate_key /etc/nginx/certs/localhost.key; - ssl_session_timeout 10m; + + # ---- Task 1: TLS 1.3 ONLY ---- + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers off; # TLS 1.3 ignores this anyway + + # ---- Task 2: cipher + curve hardening (Mozilla "Modern") ---- + # NOTE: TLS 1.3 suites are NOT settable via `ssl_ciphers` (that API is TLS 1.2-only). + # The correct directive for TLS 1.3 ciphersuites is `ssl_conf_command Ciphersuites`. + ssl_conf_command Ciphersuites TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256; + ssl_ecdh_curve X25519:secp384r1; + + # ---- Task 2: session resumption ---- ssl_session_cache shared:SSL:10m; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:EECDH+AESGCM:EDH+AESGCM"; - ssl_prefer_server_ciphers on; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # ---- OCSP stapling: no effect on a self-signed cert (documentation-only in lab). + # Mandatory in production with a publicly-trusted cert. ---- ssl_stapling off; - # If using a publicly-trusted certificate, you may enable OCSP stapling: # ssl_stapling on; # ssl_stapling_verify on; - # resolver 1.1.1.1 8.8.8.8 valid=300s; + # resolver 8.8.8.8 1.1.1.1 valid=300s; # resolver_timeout 5s; # ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + # ---- Task 2: request-side fail-closed timeouts + body cap + conn limit ---- client_max_body_size 2m; client_body_timeout 10s; client_header_timeout 10s; keepalive_timeout 10s; send_timeout 10s; + limit_conn conn 50; - # Security headers (include HSTS here only) - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; - add_header X-Frame-Options "DENY" always; + # ---- Task 1: the six required security headers (all with `always`) ---- + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" always; + # Extra defense-in-depth headers (not required, but good posture) add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Resource-Policy "same-origin" always; - add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" always; + # ---- Task 2: rate limit on the auth endpoint ---- location = /rest/user/login { limit_req zone=login burst=5 nodelay; limit_req_log_level warn; diff --git a/labs/lab11/waf/docker-compose.override.yml b/labs/lab11/waf/docker-compose.override.yml new file mode 100644 index 000000000..68abc19b6 --- /dev/null +++ b/labs/lab11/waf/docker-compose.override.yml @@ -0,0 +1,43 @@ +# Bonus — ModSecurity v3 + OWASP CRS sidecar in front of the app. +# +# We use the official OWASP image `owasp/modsecurity-crs:nginx-alpine`, which +# ships nginx + ModSecurity v3 + OWASP CRS v4.x pre-wired and driven by env vars. +# +# Topology: client -> waf (ModSec+CRS, :8080) -> juice:3000 +# The hardened nginx (Task 1/2) stays up on :80/:443 as the *no-WAF* baseline, +# so we can send the same payload down both paths and compare (blocked vs. passed). +# +# Run with: +# docker compose -f docker-compose.yml -f waf/docker-compose.override.yml up -d + +services: + waf: + image: owasp/modsecurity-crs:nginx-alpine + # For strict "CRS v4.x" compliance, pin a dated v4 tag instead, e.g.: + # image: owasp/modsecurity-crs:4.20.0-nginx-alpine-202511100111 + restart: unless-stopped + depends_on: + - juice + ports: + - "8080:8080" # HTTP entrypoint for the WAF path + environment: + # ---- proxy target ---- + BACKEND: "http://juice:3000" + PORT: "8080" + SERVER_NAME: "localhost" + + # ---- ModSecurity engine: BLOCK, don't just detect ---- + MODSEC_RULE_ENGINE: "On" + + # ---- OWASP CRS: production-safe paranoia level 1 ---- + PARANOIA: "1" + BLOCKING_PARANOIA: "1" + ANOMALY_INBOUND: "5" + ANOMALY_OUTBOUND: "4" + + # ---- audit log: ship to stdout (Native = readable rule lines) so `docker logs` + # shows every matched CRS rule (942xxx SQLi) + the final 949110 block ---- + MODSEC_AUDIT_ENGINE: "RelevantOnly" + MODSEC_AUDIT_LOG: "/dev/stdout" + MODSEC_AUDIT_LOG_FORMAT: "Native" + MODSEC_AUDIT_LOG_PARTS: "ABIJDEFHZ" diff --git a/submissions/lab11.md b/submissions/lab11.md new file mode 100644 index 000000000..c8705e73f --- /dev/null +++ b/submissions/lab11.md @@ -0,0 +1,208 @@ +# Lab 11 — BONUS — Submission + +> Hardened Nginx reverse proxy (TLS 1.3 + full security-header set + rate/connection +> limiting + cipher hardening) in front of Juice Shop v20.0.0, plus a ModSecurity v3 + +> OWASP CRS WAF sidecar that blocks a SQL-injection probe that Nginx alone passes. + +--- + +## Task 1: TLS + Security Headers + +### nginx.conf (SSL + header sections only) + +```nginx +# ---- HTTP -> HTTPS redirect (headers apply on redirects too) ---- +server { + listen 80; + listen [::]:80; + server_name _; + + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" always; + + return 308 https://$host$request_uri; +} + +# ---- HTTPS: TLS 1.3 only + the six required headers ---- +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name _; + + ssl_certificate /etc/nginx/certs/localhost.crt; + ssl_certificate_key /etc/nginx/certs/localhost.key; + + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers off; # TLS 1.3 ignores this anyway + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" always; + + location / { proxy_pass http://juice; } +} +``` + +### A. HTTPS redirect proof +``` +HTTP/1.1 308 Permanent Redirect +Server: nginx +Date: Fri, 17 Jul 2026 19:07:15 GMT +Content-Type: text/html +Content-Length: 164 +Connection: keep-alive +Location: https://localhost/ +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +Referrer-Policy: strict-origin-when-cross-origin +Permissions-Policy: camera=(), microphone=(), geolocation=() +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Resource-Policy: same-origin +Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' +``` + +### B. TLS 1.3 proof +``` +Connecting to ::1 +Can't use SSL_get_servername +depth=0 CN=juice.local +verify error:num=18:self-signed certificate +CONNECTION ESTABLISHED +Protocol version: TLSv1.3 +Ciphersuite: TLS_AES_256_GCM_SHA384 +Peer certificate: CN=juice.local +``` + +### C. Security headers proof (all 6 present) +``` +HTTP/2 200 +server: nginx +date: Fri, 17 Jul 2026 19:07:29 GMT +content-type: text/html; charset=UTF-8 +content-length: 9903 +feature-policy: payment 'self' +x-recruiting: /#/jobs +accept-ranges: bytes +cache-control: public, max-age=0 +last-modified: Fri, 17 Jul 2026 18:59:17 GMT +etag: W/"26af-19f71728110" +vary: Accept-Encoding +strict-transport-security: max-age=63072000; includeSubDomains; preload +x-content-type-options: nosniff +x-frame-options: DENY +referrer-policy: strict-origin-when-cross-origin +permissions-policy: camera=(), microphone=(), geolocation=() +content-security-policy-report-only: default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' +cross-origin-opener-policy: same-origin +cross-origin-resource-policy: same-origin +``` +> All six required headers land on the real `HTTP/2 200` response (not just on errors), confirming the `always` keyword works. + +### What each header defends against (1 sentence each) +- **HSTS (`Strict-Transport-Security`):** Forces the browser to use HTTPS for two years on every future visit, so an attacker on the network can't strip TLS and downgrade the connection to plain HTTP (SSL-stripping / MITM). +- **X-Content-Type-Options: nosniff:** Stops the browser from second-guessing (`Content-Type`) and re-interpreting, e.g., a user-uploaded text file as executable JavaScript, killing MIME-sniffing XSS. +- **X-Frame-Options: DENY:** Forbids any site from loading our pages inside an `