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
58 changes: 41 additions & 17 deletions labs/lab11/reverse-proxy/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -68,15 +76,15 @@ 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;

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

# HTTPS server
# ================= HTTPS server =================
server {
listen 443 ssl;
listen [::]:443 ssl;
Expand All @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions labs/lab11/waf/docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -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"
Loading