Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 43 additions & 49 deletions labs/lab11/reverse-proxy/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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;
}
}
}
30 changes: 30 additions & 0 deletions labs/lab11/waf/certs/server.crt
Original file line number Diff line number Diff line change
@@ -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-----
28 changes: 28 additions & 0 deletions labs/lab11/waf/docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -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
168 changes: 168 additions & 0 deletions submissions/lab11.md
Original file line number Diff line number Diff line change
@@ -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.