From 4ab710bb3f4beb3d22c450e69f0b7006f9eb6e68 Mon Sep 17 00:00:00 2001 From: Marat <139675556+Muratich@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:13:16 +0300 Subject: [PATCH 1/2] chore: add pull request template --- .github/PULL_REQUEST_TEMPLATE.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..7154aec08 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +## Goal +Describe what this PR delivers in one sentence. + +## Changes +- Added / updated: + - `submissions/lab1.md` + - `.github/PULL_REQUEST_TEMPLATE.md` +- Other changes: + - ... + +## Testing +- Commands run: + - `docker run -d --name juice-shop -p 127.0.0.1:3000:3000 bkimminich/juice-shop:v20.0.0` + - `curl http://127.0.0.1:3000` +- Observed output: + - HTTP 200 on `/` + - Juice Shop reachable at `http://127.0.0.1:3000` + +## Artifacts & Screenshots +- `submissions/lab1.md` +- Screenshots / links: + - ... + - ... + +## Checklist +- [ ] Title is clear (`feat(labN): ` style) +- [ ] No secrets or large temp files are committed +- [ ] Submission file at `submissions/labN.md` exists From a973757cc6d2fea2fd61fbe14c0d7bc60c731505 Mon Sep 17 00:00:00 2001 From: Marat <139675556+Muratich@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:17:07 +0300 Subject: [PATCH 2/2] feat(lab11): hardened nginx + WAF sidecar --- labs/lab11/reverse-proxy/nginx.conf | 114 ++++---------- labs/lab11/waf/docker-compose.override.yml | 24 +++ labs/lab11/waf/logs/.gitkeep | 0 submissions/lab11.md | 170 +++++++++++++++++++++ 4 files changed, 225 insertions(+), 83 deletions(-) create mode 100644 labs/lab11/waf/docker-compose.override.yml create mode 100644 labs/lab11/waf/logs/.gitkeep create mode 100644 submissions/lab11.md diff --git a/labs/lab11/reverse-proxy/nginx.conf b/labs/lab11/reverse-proxy/nginx.conf index dff91b265..9db6a23eb 100644 --- a/labs/lab11/reverse-proxy/nginx.conf +++ b/labs/lab11/reverse-proxy/nginx.conf @@ -7,116 +7,64 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; sendfile on; - keepalive_timeout 10; - server_tokens off; - gzip off; - # Security-focused logs - log_format security '$remote_addr - $remote_user [$time_local] ' - '"$request" $status $body_bytes_sent ' - '"$http_referer" "$http_user_agent" ' - 'rt=$request_time uct=$upstream_connect_time ' - 'urt=$upstream_response_time'; - 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; - } - - # 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; + limit_conn_zone $binary_remote_addr zone=conn:10m; limit_req_status 429; - - map $http_upgrade $connection_upgrade { default upgrade; '' close; } - - # Common proxy settings - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - 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 ""; + client_body_timeout 10s; + client_header_timeout 10s; 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) + upstream juice { + server juice:3000; + } + server { listen 80; listen [::]:80; server_name _; + return 308 https://$host$request_uri; + } - # 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; + # Internal HTTP for WAF sidecar (docker network only, not exposed to host) + server { + listen 8080; + server_name _; - return 308 https://$host$request_uri; + location / { + proxy_pass http://juice; + } } - # HTTPS server 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_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: - # 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; - - client_max_body_size 2m; - client_body_timeout 10s; - client_header_timeout 10s; - keepalive_timeout 10s; - send_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; + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers off; + ssl_ecdh_curve X25519:secp384r1; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + ssl_stapling on; + ssl_stapling_verify on; + resolver 8.8.8.8 1.1.1.1 valid=300s; + + limit_conn conn 50; + + 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 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 = /rest/user/login { limit_req zone=login burst=5 nodelay; - limit_req_log_level warn; proxy_pass http://juice; } diff --git a/labs/lab11/waf/docker-compose.override.yml b/labs/lab11/waf/docker-compose.override.yml new file mode 100644 index 000000000..04b28c993 --- /dev/null +++ b/labs/lab11/waf/docker-compose.override.yml @@ -0,0 +1,24 @@ +# ModSecurity v3 + OWASP CRS v4 sidecar in front of Nginx. +# no-waf: https://localhost:443 +# with-waf: https://localhost:8443 + +services: + waf: + image: owasp/modsecurity-crs:nginx-alpine + container_name: lab11-waf + restart: unless-stopped + depends_on: + - nginx + environment: + BACKEND: "http://nginx:8080" + BLOCKING_PARANOIA: "1" + MODSEC_RULE_ENGINE: "On" + MODSEC_AUDIT_ENGINE: "RelevantOnly" + MODSEC_AUDIT_LOG: "/var/log/modsec/audit.log" + MODSEC_AUDIT_LOG_PARTS: "ABIJDEFHZ" + SERVER_NAME: "localhost-waf" + SSL_PORT: "8443" + ports: + - "8443:8443" + volumes: + - ./logs:/var/log/modsec:rw diff --git a/labs/lab11/waf/logs/.gitkeep b/labs/lab11/waf/logs/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/submissions/lab11.md b/submissions/lab11.md new file mode 100644 index 000000000..4cc7f1276 --- /dev/null +++ b/submissions/lab11.md @@ -0,0 +1,170 @@ +# Lab 11 — BONUS — Submission + +## Task 1: TLS + Security Headers + +### nginx.conf (paste the SSL + header sections only — not the whole file) +```nginx + server { + listen 80; + listen [::]:80; + server_name _; + return 308 https://$host$request_uri; + } + + server { + listen 443 ssl; + listen [::]:443 ssl; + 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; + + 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/1.30.3 +Date: Tue, 14 Jul 2026 14:17:53 GMT +Content-Type: text/html +Content-Length: 171 +Connection: keep-alive +Location: https://localhost/ +``` + +### B. TLS 1.3 proof +``` +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/1.1 200 OK +Server: nginx/1.30.3 +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' +``` + +### What each header defends against (1 sentence each) +- HSTS: Tells the browser to always use HTTPS for this site. +- X-Content-Type-Options: nosniff: Stops the browser from guessing file types and running them as scripts. +- X-Frame-Options: DENY: Blocks other sites from putting this page in an iframe (clickjacking). +- Referrer-Policy: Limits how much URL info is sent to other sites in the Referer header. +- Permissions-Policy: Blocks camera, microphone, and geolocation access in the browser. +- Content-Security-Policy: Limits what scripts and resources the page can load (report-only here, so it logs but does not block). + +--- + +## Task 2: Production Posture + +### nginx.conf — rate limits, timeouts, and TLS hardening +```nginx + limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m; + limit_conn_zone $binary_remote_addr zone=conn:10m; + limit_req_status 429; + client_body_timeout 10s; + client_header_timeout 10s; + proxy_read_timeout 30s; + proxy_connect_timeout 5s; + + ssl_ecdh_curve X25519:secp384r1; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + ssl_stapling on; + ssl_stapling_verify on; + resolver 8.8.8.8 1.1.1.1 valid=300s; + limit_conn conn 50; + + location = /rest/user/login { + limit_req zone=login burst=5 nodelay; + proxy_pass http://juice; + } +``` + +### Rate limit proof +| HTTP code | Count out of 60 | +|-----------|----------------:| +| 200 | 0 | +| 401 | 6 | +| 429 | 54 | +| 5xx | 0 | + +### Timeout enforced +``` +(no response - connection closed by server) +``` + +### Cipher hardening +``` +Peer Temp Key: X25519, 253 bits +New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 + Cipher : TLS_AES_256_GCM_SHA384 +``` + +### Cert rotation runbook (7 steps) +1. **Detect expiry**: Set alerts when the cert is 30 days from expiring (page at 7 days). +2. **Order new cert**: Renew with certbot/Let's Encrypt or your CA. +3. **Validate**: Check the new cert with `openssl x509 -in newcert.pem -text`. +4. **Atomic swap**: Point a symlink to the new cert/key and run `nginx -s reload`. +5. **Verify**: Confirm the live site serves the new cert with `curl -vk`. +6. **Rollback plan**: Keep the old cert for ~7 days; swap the symlink back if something breaks. +7. **Audit**: Log who rotated what, cert serial, and new expiry date. + +### What OCSP stapling buys you (2-3 sentences, reference Reading 11) +The server attaches cert revocation status to the TLS handshake, so the client does not query the CA separately. That is faster and the CA does not see every visitor. With our self-signed lab cert there is no CA OCSP to staple, so it only documents prod practice. + +--- + +## Bonus: WAF Sidecar with OWASP CRS + +### Setup choice +- WAF used: ModSecurity v3 (`owasp/modsecurity-crs:nginx-alpine` sidecar) +- OWASP CRS version: 3.3.10 (bundled in the image) +- Paranoia level: 1 + +### Attack payload sent +`GET /rest/products/search?q=' OR 1=1--` (URL-encoded) + +### Before WAF (Nginx alone) +``` +no-waf: HTTP 500 +``` + +### After WAF +``` +with-waf: HTTP 403 +``` + +### Audit log excerpt (the rule that fired) +``` +"ruleId":"942100" +"message":"SQL Injection Attack Detected via libinjection" +"data":"Matched Data: s&1c found within ARGS:q: ' OR 1=1--" +"ruleId":"949110" +"message":"Inbound Anomaly Score Exceeded (Total Score: 5)" +``` +Rule ID: **942100** - OWASP CRS rule name: **SQL Injection Attack Detected via libinjection** + +### Tradeoff analysis (3 sentences) +A WAF blocks bad requests at runtime; SAST/DAST and Conftest only check code and config before deploy. The downside is tuning effort, false positives, and extra ops. Skip a WAF for simple internal services or when a CDN WAF already covers you.