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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ labs/lab10/imports/import-*.json
labs/lab11/logs/
labs/lab11/reverse-proxy/certs/
labs/lab12/results/
labs/lab11/results/

# Standard
__pycache__/
Expand Down
24 changes: 14 additions & 10 deletions labs/lab11/reverse-proxy/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ http {
# ~10 req/min per IP, burst of 5
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
limit_req_status 429;
limit_conn_zone $binary_remote_addr zone=conn:10m;

map $http_upgrade $connection_upgrade { default upgrade; '' close; }

Expand Down Expand Up @@ -68,10 +69,10 @@ 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;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;

return 308 https://$host$request_uri;
}
Expand All @@ -85,11 +86,13 @@ 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_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;
ssl_stapling off;
# If using a publicly-trusted certificate, you may enable OCSP stapling:
# ssl_stapling on;
Expand All @@ -103,16 +106,17 @@ http {
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 Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
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;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;

location = /rest/user/login {
limit_req zone=login burst=5 nodelay;
Expand Down
27 changes: 27 additions & 0 deletions labs/lab11/waf/docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
services:
waf:
image: owasp/modsecurity-crs:4.25.1-nginx-alpine-lts
restart: unless-stopped
depends_on:
- nginx
ports:
- "8080:8080"
- "8443:8443"
environment:
BACKEND: "https://nginx:443"
SERVER_NAME: "localhost-waf"
PORT: "8080"
SSL_PORT: "8443"
SSL_CERT: "/etc/nginx/certs/localhost.crt"
SSL_CERT_KEY: "/etc/nginx/certs/localhost.key"
PROXY_SSL_VERIFY: "off"
MODSEC_RULE_ENGINE: "On"
PARANOIA: "1"
BLOCKING_PARANOIA: "1"
MODSEC_AUDIT_ENGINE: "RelevantOnly"
MODSEC_AUDIT_LOG: "/var/log/modsec/audit.log"
MODSEC_AUDIT_LOG_PARTS: "ABIJDEFHZ"
MODSEC_AUDIT_LOG_FORMAT: "Native"
volumes:
- ./reverse-proxy/certs:/etc/nginx/certs:ro
- ./waf/logs:/var/log/modsec:rw
35 changes: 35 additions & 0 deletions labs/lab11/waf/logs/audit.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---FyNlDcNp---A--
[08/Jul/2026:14:28:02 +0000] 178352088279.236056 172.22.0.1 58996 172.22.0.3 8443
---FyNlDcNp---B--
GET /rest/products/search?q='%20OR%201=1-- HTTP/1.1
Host: localhost:8443
User-Agent: curl/8.19.0
Accept: */*

---FyNlDcNp---D--

---FyNlDcNp---E--
<html>\x0d\x0a<head><title>403 Forbidden</title></head>\x0d\x0a<body>\x0d\x0a<center><h1>403 Forbidden</h1></center>\x0d\x0a<hr><center>nginx</center>\x0d\x0a</body>\x0d\x0a</html>\x0d\x0a

---FyNlDcNp---F--
HTTP/1.1 403
Server: nginx
Date: Wed, 08 Jul 2026 14:28:02 GMT
Content-Length: 146
Content-Type: text/plain
Access-Control-Allow-Origin: *
Connection: keep-alive
Access-Control-Max-Age: 3600
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: *

---FyNlDcNp---H--
ModSecurity: Warning. detected SQLi using libinjection. [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf"] [line "46"] [id "942100"] [rev ""] [msg "SQL Injection Attack Detected via libinjection"] [data "Matched Data: s&1c found within ARGS:q: ' OR 1=1--"] [severity "2"] [ver "OWASP_CRS/4.25.1"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-sqli"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "OWASP_CRS/ATTACK-SQLI"] [tag "capec/1000/152/248/66"] [hostname "localhost"] [uri "/rest/products/search"] [unique_id "178352088279.236056"] [ref "v28,10"]
ModSecurity: Access denied with code 403 (phase 2). Matched "Operator `Ge' with parameter `5' against variable `TX:BLOCKING_INBOUND_ANOMALY_SCORE' (Value: `5' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "222"] [id "949110"] [rev ""] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"] [data ""] [severity "0"] [ver "OWASP_CRS/4.25.1"] [maturity "0"] [accuracy "0"] [tag "modsecurity"] [tag "anomaly-evaluation"] [tag "OWASP_CRS"] [hostname "localhost"] [uri "/rest/products/search"] [unique_id "178352088279.236056"] [ref ""]

---FyNlDcNp---I--

---FyNlDcNp---J--

---FyNlDcNp---Z--

186 changes: 186 additions & 0 deletions submissions/lab11.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# Lab 11 — BONUS — Submission

## Task 1: TLS + Security Headers

### nginx.conf (paste the SSL + header sections only — not the whole file)
```nginx
# HTTP server headers and HTTPS redirect
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'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;

return 308 https://$host$request_uri;
}

# HTTPS SSL posture and required headers
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-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'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
```

### A. HTTPS redirect proof
```
HTTP/1.1 308 Permanent Redirect
Server: nginx
Date: Wed, 08 Jul 2026 14:19:02 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'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
```

### B. TLS 1.3 proof
```
Connecting to 192.168.65.254
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
Hash used: SHA256
```

### C. Security headers proof (all 6 present)
```
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 08 Jul 2026 14:19:02 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 9903
Connection: keep-alive
Feature-Policy: payment 'self'
X-Recruiting: /#/jobs
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Wed, 08 Jul 2026 14:16:26 GMT
ETag: W/"26af-19f42165095"
Vary: Accept-Encoding
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
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'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
```

### What each header defends against (1 sentence each)
- HSTS: Forces browsers to use HTTPS for the host and subdomains, reducing SSL-stripping downgrade risk after the first trusted response.
- X-Content-Type-Options: nosniff: Prevents browsers from MIME-sniffing a response into an executable type when the declared content type says otherwise.
- X-Frame-Options: DENY: Blocks framing of the site and reduces clickjacking risk.
- Referrer-Policy: Limits cross-origin referrer leakage to the origin only and suppresses referrers on HTTPS-to-HTTP downgrades.
- Permissions-Policy: Disables camera, microphone, and geolocation access unless explicitly re-enabled.
- Content-Security-Policy: Runs a report-only policy that records unsafe resource-loading patterns before enforcing a stricter XSS and data-exfiltration defense.

## Task 2: Production Posture

### Rate limit proof
| HTTP code | Count out of 60 |
|-----------|----------------:|
| 200 | 0 |
| 429 | 54 |
| 5xx | 6 |

Note: the first 6 requests reached Juice Shop but returned 5xx because the login request body was intentionally empty; the remaining 54 requests were blocked by Nginx rate limiting with HTTP 429.

### Timeout enforced
```
Connecting to 192.168.65.254
depth=0 CN=juice.local
verify error:num=18:self-signed certificate
verify return:1
depth=0 CN=juice.local
verify return:1
289B592F80750000:error:0A000126:SSL routines::unexpected eof while reading:ssl/record/rec_layer_s3.c:698:
```

### Cipher hardening
```
Server Temp Key: X25519, 253 bits
Cipher: TLS_AES_256_GCM_SHA384
```

### Cert rotation runbook (7 steps)
1. **Detect expiry**: Monitor certificate expiry continuously, alert at 30 days, and page at 7 days.
2. **Order new cert**: Renew through Let's Encrypt/certbot or the production CA used for the service.
3. **Validate**: Inspect the new certificate and chain with `openssl x509 -in newcert.pem -text` and `openssl verify -CAfile ca.pem newcert.pem`.
4. **Atomic swap**: Place the new cert/key beside the old pair, switch the active symlink atomically, then run `nginx -s reload`.
5. **Verify**: Confirm the served certificate and TLS posture with `curl -vk https://service.example` and `testssl.sh`.
6. **Rollback plan**: Keep the previous cert/key for at least 7 days and roll back by repointing the active symlink and reloading Nginx.
7. **Audit**: Record the rotation time, operator, certificate serial, issuer, and new expiry in the security log or SIEM.

### What OCSP stapling buys you (2-3 sentences, reference Reading 11)
OCSP stapling lets the server fetch revocation status from the CA and staple it into the TLS handshake, which removes a client-side OCSP lookup, reduces latency, and avoids leaking client browsing activity to the CA. Reading 11 notes that this matters for publicly trusted production certificates, but it does not help this lab certificate because the cert is self-signed and has no CA OCSP responder to query.

## Bonus: WAF Sidecar with OWASP CRS

### Setup choice
- WAF used: ModSecurity v3 with Nginx connector
- OWASP CRS version: 4.25.1
- 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)
```
---FyNlDcNp---B--
GET /rest/products/search?q='%20OR%201=1-- HTTP/1.1
Host: localhost:8443
User-Agent: curl/8.19.0
Accept: */*

---FyNlDcNp---F--
HTTP/1.1 403
Server: nginx
Date: Wed, 08 Jul 2026 14:28:02 GMT
Content-Length: 146
Content-Type: text/plain

---FyNlDcNp---H--
ModSecurity: Warning. detected SQLi using libinjection. [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf"] [line "46"] [id "942100"] [msg "SQL Injection Attack Detected via libinjection"] [data "Matched Data: s&1c found within ARGS:q: ' OR 1=1--"] [severity "2"] [ver "OWASP_CRS/4.25.1"] [tag "attack-sqli"] [tag "paranoia-level/1"]
ModSecurity: Access denied with code 403 (phase 2). Matched "Operator `Ge' with parameter `5' against variable `TX:BLOCKING_INBOUND_ANOMALY_SCORE' (Value: `5' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"] [ver "OWASP_CRS/4.25.1"]
```
Rule ID: **942100** - OWASP CRS rule name: **SQL Injection Attack Detected via libinjection**

### Tradeoff analysis (3 sentences)
The WAF blocks exploit-shaped traffic at runtime, so it catches attack strings that SAST, DAST, and an L7 policy gate can miss after deployment or after a new payload variant appears. It costs operational tuning time, false-positive handling at higher paranoia levels, audit-log storage, extra TLS/proxy configuration, and another component in the request path. I would not deploy it in front of a purely internal low-risk service with strict authentication, low exposure, and a team that cannot monitor or tune WAF alerts.