# Same-origin TLS reverse proxy for the Dashboard (used by docker-compose.prod.yml). # The BFF serves the SPA *and* /api under one origin, so the whole app sits behind a # single server block — that keeps the SameSite=Lax session cookie + the BFF's # same-origin mutation guard (ADR-009) working without CORS. # # 0.8.0 B3 — the recipe IMPLEMENTS the two controls the BFF delegates to the proxy # (security_hardening.md §Reverse proxy): login rate-limiting and HSTS. # Login throttling (0.8.0 B3): per-client-IP zone for the auth entry points only — # /api/login is a redirect bounce, not a hot path, so 10 req/min with a burst of 5 # absorbs a stuck refresh loop while stopping brute-force/abuse. limit_req_zone $binary_remote_addr zone=kneo_login:10m rate=10r/m; # Access-log line for the throttled locations carries the SAME $request_id the 429 # envelope + X-Request-Id header carry — the correlation triple is asserted in CI. log_format kneo_rl '$remote_addr - [$time_local] "$request" $status rid=$request_id'; server { listen 80; server_name dash.example.com; return 301 https://$host$request_uri; # force TLS } server { listen 443 ssl; http2 on; server_name dash.example.com; ssl_certificate /etc/nginx/tls/fullchain.pem; ssl_certificate_key /etc/nginx/tls/privkey.pem; # HSTS (0.8.0 B3) — max-age only in the generic recipe: `includeSubDomains` is a # deliberate opt-in (uncomment ONLY if every descendant hostname is HTTPS), and no # `preload` before GA. `always` so error responses carry it too. add_header Strict-Transport-Security "max-age=31536000" always; # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # Auth entry points — throttled (0.8.0 B3). Everything else about proxying matches # `location /` below. limit_req_status 429 + the named location keep the response # inside the BFF's stable error envelope (api_contract.md §proxy-generated 429). location ~ ^/api/(login|callback)$ { limit_req zone=kneo_login burst=5 nodelay; limit_req_status 429; limit_req_log_level warn; error_page 429 = @kneo_rate_limited; access_log /dev/stdout kneo_rl; proxy_pass http://kneo-dash:8090; 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-generated 429 in the BFF's envelope shape ({code, message, request_id, # retry_after} — api_contract.md promises it on EVERY non-2xx /api/*). retry_after # is 6s = 60s / 10 r/m, mirrored in the Retry-After header; $request_id is nginx- # minted (correlation is proxy-internal: body == X-Request-Id == access log — the # BFF mints its own ids and adopts no incoming header; trusted-proxy adoption is # out of scope for 0.8.0). NOTE location-level add_header SUPPRESSES inherited # server-level headers, so HSTS is repeated here (touchpoint-1 R5). location @kneo_rate_limited { default_type application/json; add_header X-Request-Id $request_id always; add_header Retry-After 6 always; add_header Strict-Transport-Security "max-age=31536000" always; access_log /dev/stdout kneo_rl; return 429 '{"code":"rate_limited","message":"too many login attempts - retry shortly","request_id":"$request_id","retry_after":6}'; } # OIDC redirect URL must match KNEO_DASH_OIDC_REDIRECT_URL, i.e. # https://dash.example.com/api/callback location / { proxy_pass http://kneo-dash:8090; 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; # BFF sets Secure cookies on https proxy_http_version 1.1; } # Server-Sent Events (live run/trace tail): disable buffering so events stream # through immediately, and allow a long-lived connection. location ~ ^/api/.*/(trace/stream|stream)$ { proxy_pass http://kneo-dash:8090; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; } }