diff --git a/labs/lab11/reverse-proxy/nginx.conf b/labs/lab11/reverse-proxy/nginx.conf index dff91b265..04d1d373c 100644 --- a/labs/lab11/reverse-proxy/nginx.conf +++ b/labs/lab11/reverse-proxy/nginx.conf @@ -20,17 +20,23 @@ http { access_log /var/log/nginx/access.log security; error_log /var/log/nginx/error.log warn; - # Upstream app - upstream juice { - server juice:3000; - keepalive 32; - } + # Docker embedded DNS — resolve juice at request time (avoids boot race) + resolver 127.0.0.11 ipv6=off valid=10s; - # Rate limit zone for login - # ~10 req/min per IP, burst of 5 + # Rate limit: ~10 req/min per IP, burst of 5 limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m; limit_req_status 429; + # Connection limit per IP + limit_conn_zone $binary_remote_addr zone=conn:10m; + + # Fail-closed timeouts + client_body_timeout 10s; + client_header_timeout 10s; + proxy_read_timeout 30s; + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + map $http_upgrade $connection_upgrade { default upgrade; '' close; } # Common proxy settings @@ -41,42 +47,24 @@ http { proxy_http_version 1.1; proxy_set_header Connection $connection_upgrade; proxy_set_header Upgrade $http_upgrade; - # Prevent upstream TLS BREACH vector by disabling compression from upstream proxy_set_header Accept-Encoding ""; - proxy_read_timeout 30s; - proxy_send_timeout 30s; - proxy_connect_timeout 5s; proxy_hide_header X-Powered-By; - # Hide upstream headers to avoid duplicates and enforce policy at the proxy proxy_hide_header X-Frame-Options; proxy_hide_header X-Content-Type-Options; proxy_hide_header Referrer-Policy; proxy_hide_header Permissions-Policy; - proxy_hide_header Cross-Origin-Opener-Policy; - proxy_hide_header Cross-Origin-Resource-Policy; proxy_hide_header Content-Security-Policy; proxy_hide_header Content-Security-Policy-Report-Only; - proxy_hide_header Access-Control-Allow-Origin; - # HTTP server (redirect to HTTPS) + # HTTP → HTTPS redirect (compose publishes 80/443; remap host ports if busy) server { listen 80; listen [::]:80; server_name _; - - # Core headers (also on redirects) - 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 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; - return 308 https://$host$request_uri; } - # HTTPS server + # HTTPS server { listen 443 ssl; listen [::]:443 ssl; @@ -85,43 +73,49 @@ http { ssl_certificate /etc/nginx/certs/localhost.crt; ssl_certificate_key /etc/nginx/certs/localhost.key; - ssl_session_timeout 10m; - 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_stapling off; - # If using a publicly-trusted certificate, you may enable OCSP stapling: + + # Task 1: TLS 1.3 only + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers off; + + # Task 2: Mozilla Modern — TLS 1.3 suites via OpenSSL 3 conf command + # (ssl_ciphers only applies to TLS ≤1.2 and rejects AEAD-only lists) + ssl_conf_command Ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256; + ssl_ecdh_curve X25519:secp384r1; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # OCSP stapling — no effect on self-signed; documented for prod # ssl_stapling on; # ssl_stapling_verify on; - # resolver 1.1.1.1 8.8.8.8 valid=300s; - # resolver_timeout 5s; - # ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + # resolver 8.8.8.8 1.1.1.1 valid=300s; + ssl_stapling off; client_max_body_size 2m; - client_body_timeout 10s; - client_header_timeout 10s; - keepalive_timeout 10s; send_timeout 10s; + keepalive_timeout 10s; - # Security headers (include HSTS here only) - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; - add_header X-Frame-Options "DENY" always; + limit_conn conn 50; + + # Task 1: six required security headers (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 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; + 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'; font-src 'self' data:" always; location = /rest/user/login { limit_req zone=login burst=5 nodelay; limit_req_log_level warn; - proxy_pass http://juice; + set $juice_upstream juice:3000; + proxy_pass http://$juice_upstream; } location / { - proxy_pass http://juice; + set $juice_upstream juice:3000; + proxy_pass http://$juice_upstream; } } } diff --git a/labs/lab11/waf/certs/server.crt b/labs/lab11/waf/certs/server.crt new file mode 100644 index 000000000..eb5c8b536 --- /dev/null +++ b/labs/lab11/waf/certs/server.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFDTCCAvWgAwIBAgIUPh3z2NGCviEZsh4SUZhM6QPgpUMwDQYJKoZIhvcNAQEL +BQAwFjEUMBIGA1UEAwwLanVpY2UubG9jYWwwHhcNMjYwNzE0MTUwMzI5WhcNMzYw +NzExMTUwMzI5WjAWMRQwEgYDVQQDDAtqdWljZS5sb2NhbDCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBALQpV6gxrOnBOKIHkMpaigwkDwSRLqJfmWjFk7Nr +muDp0nJB9sFvzcnNMA8+wF+t7Dlbz5Ic2fnaU7JMHbZVi9oa0FFrT3mkILraSapg +/G0Q4OlmoDv9FQyAqXczpThJayV9HKf8H0obYK01EcPEkJ0Etu1d6j7if+yrKnJF +rHXtycYCuN7XFVvBcdLS175SY1MCFnSXvB9oU/5wukxHNZI1pMfvRODEXYpUOy81 +amAHxaqWqt1rxHiru6ru9ljGwLrT/4Mi6t8jsjd24OqFqK6EUENVZ44N0a4kKgpl +HrLkF9PEyn0Gwlf86O8NV7tg36HNBkRGXAJZw51gvVsiMJceM7sXjHkALIrHfNaF +DtPtNI1aDlfW+688oKLgLH4wwWYRwBE5FVFD5oeZ02/scY8Uu6UZU1l6FNpH6Hne +/kELICrXjRPypQPSC2Fscf0T4ZDjSkvanRZrRJ3hhMktMPh9O/BxkTmipiyrOOYi +AkXN6WWgYThsRo64Xc9qoZz0P9oJiP8yaJ3Z4J/yPXTNp9d04nrF0vmPdxjZ+rNH +m6IXm3xbRq1QY/HHW1ZHwTo6bFhszqy6lUUFmeyupRKI8Xqr75amRNd+NoDdJ9YJ +BKu3VS+eTyWirxm/jYDGDQvZ8p8EFc9gJZwF+GijhkKak6bxuSRKWjgX46OkRPJ6 +Li5ZAgMBAAGjUzBRMB0GA1UdDgQWBBTQlRrdHdeTvd0fD98Y+vEQGhqWPTAfBgNV +HSMEGDAWgBTQlRrdHdeTvd0fD98Y+vEQGhqWPTAPBgNVHRMBAf8EBTADAQH/MA0G +CSqGSIb3DQEBCwUAA4ICAQACZFk9VCbJQa8Gs90mKA2qzOL8XIHH9sFaoNAhdExp +0bhImYu5MODuPbml0uM3PYQV1LArNAkw37niWrfKEXtvMAv/qw5uIYspOW8wb7ol +Ch5vqOJ0AiT43CiVxocF/bWEAKkz6JWHfhltMne/QbrjGwo47kURlfDPO9n4dbn6 +XcDO6TuCVWfHER+tFzivekeJVKUEQfMr74m1raZRFK7oTaJELCLlWmPK0JEl8I/3 +CpYpcquQZmq4cruvERqBQCMiS7mOTqfaQ7oU8rwct4GfxCVzfkgoPvEx8bg+nsuy +stDh8RsrM/vkYy1Zo4shrBf+F79NT9/wQnXXFGG09Jg4nDjSAOwckSFjjSxEdv3C +otqRH0pRqlWEjZ5ZjJHSpe3El8NjWVDyzTu8ipUuH1pqQVBnTCYZ7DRfBGs7F66R +iDGTVCC6VYUneuKz5iAcnn3CA/bzYq4cDhJj9gCUhqAHLUkl+ORlw49Xo3EYcLEF +ygbjBX0+Fmpgvp/s9j2O2t+hdyeaj9/VH5QZfpCif26XFvYOoDN88KBo2vuuRB1T +eZdfrz+XgFO5DWL6XOpEvTuJsr7HqYvRR3VamiWfROa/iQiJlpLPVCiTnXbLSqB2 +SgK8avkloy3oTCsYRLNdd3m0uYcIraR4APD9yjyhF0R5cFxDt9Lt9Zb3whVBlKE9 +aw== +-----END CERTIFICATE----- diff --git a/labs/lab11/waf/docker-compose.override.yml b/labs/lab11/waf/docker-compose.override.yml new file mode 100644 index 000000000..eba12116e --- /dev/null +++ b/labs/lab11/waf/docker-compose.override.yml @@ -0,0 +1,28 @@ +# Lab 11 Bonus — ModSecurity v3 + OWASP CRS in front of Juice Shop +# Approach (c): ModSecurity v3 (OWASP CRS docs are richer than Coraza). +# Exposes HTTP :9080 so we can compare nginx-alone (:8443) vs WAF without +# fighting the image's built-in TLS paths. + +services: + waf: + image: owasp/modsecurity-crs:nginx-alpine + restart: unless-stopped + depends_on: + - juice + ports: + - "9080:8080" # HTTP WAF edge (compare to nginx HTTPS :8443) + environment: + BACKEND: "http://juice:3000" + PORT: "8080" + MODSEC_RULE_ENGINE: "On" + PARANOIA: "1" + ANOMALY_INBOUND: "5" + ANOMALY_OUTBOUND: "4" + BLOCKING_PARANOIA: "1" + MODSEC_AUDIT_ENGINE: "On" + MODSEC_AUDIT_LOG: "/var/log/modsecurity/audit.log" + MODSEC_AUDIT_LOG_FORMAT: "Native" + MODSEC_AUDIT_LOG_TYPE: "Serial" + SERVER_NAME: "localhost" + volumes: + - ./results/modsec:/var/log/modsecurity:rw diff --git a/submissions/lab11.md b/submissions/lab11.md new file mode 100644 index 000000000..ac36a4879 --- /dev/null +++ b/submissions/lab11.md @@ -0,0 +1,168 @@ +# Lab 11 — BONUS — Submission + +> Ports: nginx listens on container `80`/`443` (course default). Local proofs below used host remap `8080:80` / `8443:443` because another stack already held 80/443. +> WAF edge: `http://localhost:9080` (ModSecurity CRS sidecar). + +## Task 1: TLS + Security Headers + +### nginx.conf (SSL + header sections) + +```nginx + # HTTP → HTTPS redirect + server { + listen 80; + listen [::]:80; + server_name _; + return 308 https://$host$request_uri; + } + + 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; + ssl_conf_command Ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256; + ssl_ecdh_curve X25519:secp384r1; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + 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'; font-src 'self' data:" always; + ... + } +``` + +### A. HTTPS redirect proof + +``` +HTTP/1.1 308 Permanent Redirect +Server: nginx +Date: Tue, 14 Jul 2026 15:06:22 GMT +Content-Type: text/html +Content-Length: 164 +Connection: keep-alive +Location: https://localhost:8443/ +``` + +### B. TLS 1.3 proof + +``` +CONNECTION ESTABLISHED +Protocol version: TLSv1.3 +Ciphersuite: TLS_AES_256_GCM_SHA384 +Peer certificate: CN = juice.local +Server Temp Key: X25519, 253 bits +DONE +``` + +### C. Security headers proof (all 6 present) + +``` +HTTP/2 200 +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'; font-src 'self' data: +``` + +### What each header defends against (1 sentence each) +- HSTS: Forces browsers to use HTTPS for this host (and subdomains) for ~2 years, blocking SSL-stripping downgrades. +- X-Content-Type-Options: nosniff: Stops MIME sniffing so a crafted response cannot be reinterpreted as executable script/style. +- X-Frame-Options: DENY: Prevents the app from being embedded in iframes (clickjacking). +- Referrer-Policy: Limits cross-origin Referer leakage to same-origin-or-HTTPS downgrade-safe cases. +- Permissions-Policy: Disables camera/mic/geolocation APIs unless explicitly re-enabled, shrinking the browser feature attack surface. +- Content-Security-Policy: Report-Only CSP that monitors script/style/image sources without breaking Juice Shop’s inline scripts; tighten iteratively in production. + +--- + +## Task 2: Production Posture + +### Rate limit proof + +| HTTP code | Count out of 60 | +|-----------|----------------:| +| 200 | 0 | +| 401 | 6 | +| 429 | 54 | +| 5xx | 0 | + +(60 concurrent POSTs to `/rest/user/login`; first burst allowed, then `limit_req` returns 429.) + +### Timeout enforced + +Slow partial ClientHello/header on TLS caused nginx to drop the connection after `client_header_timeout 10s` (OpenSSL saw unexpected EOF): + +``` +40A76DDBEB760000:error:0A000126:SSL routines:ssl3_read_n:unexpected eof while reading:../ssl/record/rec_layer_s3.c:316: +``` + +### Cipher hardening + +``` +Server Temp Key: X25519, 253 bits +New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 +``` + +### Cert rotation runbook (7 steps) +1. **Detect expiry**: Monitor `openssl x509 -enddate -noout -in localhost.crt` / alerting when <30 days remain (or ACME remaining lifetime). +2. **Order new cert**: For prod, ACME/Let’s Encrypt (`certbot`/`acme.sh`); for this lab, regenerate self-signed with the same CN. +3. **Validate**: Check SAN/CN, chain, dates (`openssl verify` / `openssl x509 -text`) before install. +4. **Atomic swap**: Write new files to a temp path, then `mv` into `/etc/nginx/certs/` (atomic rename) so readers never see a partial write. +5. **Verify**: `nginx -t` then `nginx -s reload`; `openssl s_client -connect ...` confirms the new leaf serial. +6. **Rollback plan**: Keep the previous `.crt`/`.key` pair as `*.prev`; rename back + reload if handshake or clients break. +7. **Audit**: Log rotation time, operator, old/new serials, and verify access logs show successful TLS after cutover. + +### What OCSP stapling buys you (2-3 sentences, reference Reading 11) +OCSP stapling lets the server present a fresh CA-signed revocation status during the handshake so clients need not query the CA OCSP endpoint themselves (privacy + latency). It only works with a publicly trusted certificate that has an OCSP responder URL in the AIA extension — a self-signed lab cert has neither, so stapling is documentation-only here. In production with Let’s Encrypt/public CAs, enabling `ssl_stapling` + a DNS resolver closes the “soft-fail ignore revocation” gap many browsers otherwise accept. + +--- + +## Bonus: WAF Sidecar with OWASP CRS + +### Setup choice +- WAF used: **ModSecurity v3** (`owasp/modsecurity-crs:nginx-alpine`) — choice (c) from the lab (richer CRS docs than Coraza) +- OWASP CRS version: **3.3.10** (what the official image ships as of this run; config path v3) +- Paranoia level: **1** +- Edge: `http://localhost:9080` → Juice Shop; nginx-alone remains on `https://localhost` (or remapped `:8443`) + +### Attack payload sent +`GET /rest/products/search?q=' OR 1=1--` (URL-encoded) + +### Before WAF (Nginx alone) +``` +no-waf: HTTP 500 +``` +(Nginx proxied the probe; Juice Shop returned an application error — **no WAF block**.) + +### After WAF +``` +with-waf: HTTP 403 +``` + +### Audit log excerpt (the rule that fired) +``` +ModSecurity: Warning. detected SQLi using libinjection. + [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf"] + [id "942100"] [msg "SQL Injection Attack Detected via libinjection"] + [data "Matched Data: s&1c found within ARGS:q: ' OR 1=1--"] + [ver "OWASP_CRS/3.3.10"] [tag "attack-sqli"] [tag "paranoia-level/1"] +ModSecurity: Access denied with code 403 (phase 2). + [id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"] +``` +Rule ID: **942100** — OWASP CRS rule name: **SQL Injection Attack Detected via libinjection** (blocked via **949110** inbound anomaly score). + +### Tradeoff analysis (3 sentences) +A WAF adds a runtime L7 filter for *unknown* request patterns (SQLi/XSS/path traversal) that SAST/DAST already found as *known* vulns and that CI Conftest gates never see in HTTP traffic. The cost is false positives (especially at higher paranoia), another cert/config surface to operate, and latency on every request. Skip a WAF for purely internal trusted-network services with tiny attack surface, or when a managed edge (Cloudflare/AWS WAF) already provides CRS-equivalent coverage.