From 3dd4463cd8b377d453fae51f308c009336ec903a Mon Sep 17 00:00:00 2001 From: khaylebfortune <111098422+khaylebfortune@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:59:21 +0000 Subject: [PATCH] feat(security): Integrate ModSecurity WAF with OWASP CRS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a defense-in-depth Web Application Firewall layer in front of the Node.js API using nginx + ModSecurity + OWASP Core Rule Set (CRS v3/v4) in anomaly scoring mode. - nginx.conf: Reverse proxy with per-endpoint body-size limits, WAF wiring, WebSocket upgrade mapping, gzip compression, and JSON error pages (#254) - websocket.conf: Dedicated WebSocket / Socket.io upgrade proxy (#252) - caching.conf: Static asset Cache-Control / Expires / ETag headers (#255) - modsecurity.conf: ModSecurity core config — engine, body inspection, audit logging, CRS inclusion, anomaly scoring (#256) - waf-rules.conf: Custom defense-in-depth rules (JSON validation, request smuggling, null bytes, scanner detection, URI length) (#256) - waf-exclusions.conf: Scoped false-positive exclusions for health checks, WebSocket handshakes, and multipart uploads, plus tuning examples for HTML/PHP/SQL false positives (#256) - README.md: Full setup guide covering installation, verification, anomaly scoring mode, and false-positive tuning workflow 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- nginx/README.md | 213 ++++++++++++++++++++++++++++++++++++++ nginx/caching.conf | 83 +++++++++++++++ nginx/modsecurity.conf | 105 +++++++++++++++++++ nginx/nginx.conf | 172 ++++++++++++++++++++++++++++++ nginx/waf-exclusions.conf | 97 +++++++++++++++++ nginx/waf-rules.conf | 94 +++++++++++++++++ nginx/websocket.conf | 44 ++++++++ 7 files changed, 808 insertions(+) create mode 100644 nginx/README.md create mode 100644 nginx/caching.conf create mode 100644 nginx/modsecurity.conf create mode 100644 nginx/nginx.conf create mode 100644 nginx/waf-exclusions.conf create mode 100644 nginx/waf-rules.conf create mode 100644 nginx/websocket.conf diff --git a/nginx/README.md b/nginx/README.md new file mode 100644 index 0000000..1f24879 --- /dev/null +++ b/nginx/README.md @@ -0,0 +1,213 @@ +# nginx + ModSecurity WAF (OWASP CRS) + +Defense-in-depth layer in front of the Deen Bridge API. The Node.js app already +hardens itself with `helmet`, `express-rate-limit`, `express-mongo-sanitize`, +`hpp` and `xss-clean` (`src/middlewares/security.js`); this nginx layer adds a +Web Application Firewall at the edge so attacks are stopped before they ever +reach Node. + +| File | Purpose | Closes | +|------|---------|--------| +| `nginx.conf` | Main reverse proxy: body-size limits per endpoint, WAF wiring, error pages | #254 | +| `websocket.conf` | WebSocket / Socket.io upgrade proxy | #252 | +| `caching.conf` | Static asset `Cache-Control`/`Expires`/ETag policies | #255 | +| `modsecurity.conf` | ModSecurity core config + OWASP CRS (anomaly scoring mode) | #256 | +| `waf-rules.conf` | Custom WAF rules (JSON validation, smuggling, scanners, …) | #256 | +| `waf-exclusions.conf` | Scoped false-positive exclusions + tuning examples | #256 | + +--- + +## Architecture + +``` +Client ──► nginx (TLS termination, WAF, limits, caching) + │ ModSecurity + OWASP CRS (anomaly scoring) + ▼ + Node.js API :5000 (helmet, rate-limit, sanitizers) +``` + +nginx inspects every request with ModSecurity before proxying. Requests that +accumulate enough anomaly points (default threshold: 5) are rejected with +`403`; hard protocol violations are rejected immediately. + +## 1. Install ModSecurity for nginx + +The nginx connector is [modsecurity-nginx](https://github.com/owasp-modsecurity/ModSecurity-nginx) +on top of **libmodsecurity v3**. Two options: + +### Option A — build a custom nginx (recommended) + +```bash +# libmodsecurity v3 +git clone --depth 1 https://github.com/owasp-modsecurity/ModSecurity +cd ModSecurity +git submodule update --init --recursive +./build.sh +./configure --prefix=/usr +make -j"$(nproc)" && make install + +# nginx connector (dynamic module) +git clone --depth 1 https://github.com/owasp-modsecurity/ModSecurity-nginx +# build with your nginx version: +./configure --add-dynamic-module=../ModSecurity-nginx +make modules +install -m 755 objs/ngx_http_modsecurity_module.so /etc/nginx/modules/ +``` + +Then uncomment in `nginx.conf`: + +```nginx +load_module modules/ngx_http_modsecurity_module.so; +``` + +### Option B — distro packages + +Debian/Ubuntu ship `libmodsecurity3` and nginx packages; the connector module +may be available as `libnginx-mod-http-modsecurity`. If not, use Option A. + +### Option C — containerised nginx + +Use an image that already bundles ModSecurity (e.g. the official +`owasp/modsecurity-crs` nginx image or the Coraza-based alternatives), mount +this directory and the CRS into it, and point `modsecurity_rules_file` at +`/etc/nginx/modsecurity/modsecurity.conf`. + +## 2. Install OWASP CRS + +The config targets **CRS v4.x** (recommended) and is compatible with **v3.x** +(see notes below). CRS satisfies "v3.x+" from the issue. + +```bash +mkdir -p /etc/nginx/modsecurity/crs +cd /etc/nginx/modsecurity/crs +# v4.x (recommended) +curl -sL https://github.com/coreruleset/coreruleset/archive/refs/tags/v4.12.0.tar.gz | tar xz --strip-components=1 +# or v3.3.x: +# curl -sL https://github.com/coreruleset/coreruleset/archive/refs/tags/v3.3.7.tar.gz | tar xz --strip-components=1 + +cp crs-setup.conf.example crs-setup.conf +``` + +Review `crs-setup.conf` and enable the tuning blocks you need (anomaly +thresholds, paranoia level, allowed methods/content-types). Anomaly scoring is +**enabled by default** — `modsecurity.conf` includes `crs-setup.conf` followed +by `crs/rules/*.conf`, and `waf-rules.conf` re-asserts the thresholds as a +fallback (rule `200008`). + +> **CRS v3.x notes**: copy `utils/unicode.mapping` next to `modsecurity.conf` +> and uncomment the `SecUnicodeMapFile` line in `modsecurity.conf`. + +## 3. Install the config files + +```bash +mkdir -p /etc/nginx/modsecurity /etc/nginx/conf.d +cp nginx/modsecurity.conf /etc/nginx/modsecurity/ +cp nginx/waf-rules.conf /etc/nginx/modsecurity/ +cp nginx/waf-exclusions.conf /etc/nginx/modsecurity/ +cp nginx/nginx.conf /etc/nginx/nginx.conf +cp nginx/websocket.conf /etc/nginx/conf.d/ +cp nginx/caching.conf /etc/nginx/conf.d/ + +# make sure audit/tmp paths are writable by the nginx worker +touch /var/log/modsec_audit.log && chown nginx:nginx /var/log/modsec_audit.log + +nginx -t && systemctl reload nginx +``` + +Update the upstream (`server 127.0.0.1:5000;`) and any `root` paths in +`caching.conf` to match your deployment. + +## 4. Verify + +```bash +# Normal traffic passes +curl -s -o /dev/null -w "%{http_code}\n" http://localhost/health # 200 + +# SQLi in the query string -> blocked by CRS (anomaly score) +curl -s -o /dev/null -w "%{http_code}\n" \ + "http://localhost/api/courses?q=1%27%20OR%20%271%27=%271" # 403 + +# XSS payload -> blocked +curl -s -o /dev/null -w "%{http_code}\n" \ + "http://localhost/api/courses" -H 'Content-Type: application/json' \ + -d '{"title":""}' # 403 + +# Scanner user-agent -> scored, then blocked (anomaly) +curl -s -o /dev/null -w "%{http_code}\n" \ + -A "sqlmap/1.8" "http://localhost/api/courses" # 403 + +# Oversized body -> 413 from nginx before the WAF +head -c 2000000 /dev/zero | tr '\0' 'a' > /tmp/big.txt +curl -s -o /dev/null -w "%{http_code}\n" -X POST \ + --data-binary @/tmp/big.txt http://localhost/api/courses # 413 + +# Request smuggling signature -> 400 +curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost/api/courses \ + -H 'Transfer-Encoding: chunked' -H 'Content-Length: 5' # 400 + +# WebSocket handshake (if Socket.io is enabled in the app) +curl -s -o /dev/null -w "%{http_code}\n" \ + -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \ + http://localhost/socket.io/?EIO=4\&transport=polling # 200 +``` + +Check the audit log for the rule that fired on each blocked request: + +```bash +tail -f /var/log/modsec_audit.log +``` + +## 5. Anomaly scoring mode + +OWASP CRS runs in **anomaly scoring** mode (this is how the CRS is designed): + +- Every matching rule adds points to `tx.anomaly_score` (default: critical +5, + warning +4, notice +3, informational +2) instead of blocking immediately. +- At the end of phase 2 the CRS final rules (`949110` inbound / `959100` + outbound) compare the accumulated score against + `tx.inbound_anomaly_score_threshold` (default **5**) and block with `403`. +- A single suspicious (but not clearly malicious) signal is therefore *not* + enough to block a request — several independent signals must agree. + +Our custom rules follow the same model: hard violations deny immediately, +ambiguous signals (e.g. scanner user-agents) are scored with `setvar:tx.anomaly_score=+5`. + +Tuning knobs live in `crs-setup.conf`: +`tx.inbound_anomaly_score_threshold`, `tx.outbound_anomaly_score_threshold`, +`tx.paranoia_level` (1–4; each level enables stricter sibling rules). + +## 6. False-positive tuning + +Exclusions live in `waf-exclusions.conf`, already pre-scoped for this app: + +| Scope | What is disabled | Why | +|-------|------------------|-----| +| `/health`, `/health/*` | all CRS + custom rules | probes/LB checks trip UA & header rules | +| `/socket.io/` | CRS 920xxx header/content-type policy family | WebSocket upgrades send non-standard headers | +| `multipart/form-data` on uploads | rule `200002` (JSON check), `920410`, `920420` | multipart bodies are not JSON | + +Workflow for a new false positive: + +1. Switch `SecRuleEngine DetectionOnly` (in `modsecurity.conf`) so nothing is + blocked while you tune — or grep the audit log for `Matched Rule`. +2. Reproduce the false positive and note the rule id from the audit log. +3. Add a **narrowly scoped** `ctl:ruleRemoveById=` (by path and/or + content-type) to `waf-exclusions.conf` — never a global removal. +4. Run the verification commands above, then flip `SecRuleEngine On` back on. + +The file also contains commented examples for the classic API false positives: +HTML-rich JSON content (941xxx XSS), PHP-injection noise on a Node backend +(932xxx), and SQL keywords in search text (942xxx). + +## Security notes + +- Keep `SecRuleEngine On` in production. Use `DetectionOnly` only while tuning. +- Raise `tx.paranoia_level` to 2+ for stricter protection once the rule set has + been running cleanly for a while. +- `SecRequestBodyLimit` (50 MB) intentionally matches the largest + `client_max_body_size` (50 MB on `/api/uploads`), so the WAF never rejects a + request nginx would accept. +- This config is the edge layer only — keep the in-app protections + (`helmet`, rate limiting, sanitizers) enabled; they protect against + application-level attacks the WAF cannot see. diff --git a/nginx/caching.conf b/nginx/caching.conf new file mode 100644 index 0000000..39351df --- /dev/null +++ b/nginx/caching.conf @@ -0,0 +1,83 @@ +# ============================================================================= +# Static asset caching headers (closes #255) +# ----------------------------------------------------------------------------- +# Include this file inside the server {} block of nginx.conf: +# include /etc/nginx/conf.d/caching.conf; +# +# Applies long-lived Cache-Control / Expires policies per asset type: +# - Fingerprinted (hashed) JS/CSS -> 1 year, immutable +# - Regular JS/CSS -> 7 days +# - Images -> 30 days +# - Fonts -> 1 year, immutable +# - HTML -> no-store (never cache) +# +# NOTE: the Deen Bridge API is served by Node on :5000; static assets are +# usually served from object storage / a CDN. The location blocks below assume +# files are also available under an nginx root - adjust `root`/`alias` to your +# deployment, or keep only the header logic you need. ETags are enabled so +# clients can revalidate cheaply. +# ============================================================================= + +# ---------------------------------------------------------------------------- +# Fingerprinted assets (e.g. app.8f3a2b1c9d0e4f5a.js) - content-addressed, +# safe to cache for a year and never revalidate. +# NOTE: this regex location MUST stay above the generic css/js location. +# ---------------------------------------------------------------------------- +location ~* \.[a-f0-9]{16}\.(?:css|js)$ { + root /var/www/dnb; + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + etag on; +} + +# ---------------------------------------------------------------------------- +# Regular (non-fingerprinted) JavaScript / CSS - short cache so new deploys +# propagate, long enough that repeat visits hit the cache. +# ---------------------------------------------------------------------------- +location ~* \.(?:css|js)$ { + root /var/www/dnb; + expires 7d; + add_header Cache-Control "public, max-age=604800"; + etag on; +} + +# ---------------------------------------------------------------------------- +# Images +# ---------------------------------------------------------------------------- +location ~* \.(?:png|jpe?g|gif|webp|avif|svg|ico)$ { + root /var/www/dnb; + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + etag on; +} + +# ---------------------------------------------------------------------------- +# Fonts - rarely change, cache aggressively +# ---------------------------------------------------------------------------- +location ~* \.(?:woff2?|ttf|otf|eot)$ { + root /var/www/dnb; + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + etag on; +} + +# ---------------------------------------------------------------------------- +# HTML - never cache (always revalidate) +# ---------------------------------------------------------------------------- +location ~* \.html?$ { + root /var/www/dnb; + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + etag on; +} + +# ---------------------------------------------------------------------------- +# CDN-style media path (large user uploads proxied/redirected from the API, +# e.g. Cloudinary URLs) - 30 day public cache, no cookie leakage. +# ---------------------------------------------------------------------------- +location /media/ { + proxy_pass http://dnb_backend; + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + add_header X-Content-Type-Options nosniff; +} diff --git a/nginx/modsecurity.conf b/nginx/modsecurity.conf new file mode 100644 index 0000000..b76eaa6 --- /dev/null +++ b/nginx/modsecurity.conf @@ -0,0 +1,105 @@ +# ============================================================================= +# ModSecurity core configuration for the Deen Bridge backend (nginx) +# (closes #256) +# ----------------------------------------------------------------------------- +# This is a ModSecurity rules file - NOT an nginx config. It is loaded by the +# modsecurity-nginx connector from nginx.conf: +# +# modsecurity on; +# modsecurity_rules_file /etc/nginx/modsecurity/modsecurity.conf; +# +# It enables the rule engine, configures request body inspection, loads the +# OWASP Core Rule Set (CRS) in anomaly-scoring mode and finally loads our +# custom rules (waf-rules.conf) and false-positive exclusions +# (waf-exclusions.conf) so they can override CRS behaviour. +# +# Install layout (see nginx/README.md for full instructions): +# /etc/nginx/modsecurity/ +# ├── modsecurity.conf <- this file +# ├── waf-rules.conf <- custom WAF rules +# ├── waf-exclusions.conf <- false-positive exclusions +# └── crs/ +# ├── crs-setup.conf <- OWASP CRS configuration +# └── rules/*.conf <- OWASP CRS rule files (v3.x or v4.x) +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Rule engine +# ----------------------------------------------------------------------------- +# Start in blocking mode. For a cautious rollout, switch to DetectionOnly, +# watch the audit log for a week, tune exclusions (waf-exclusions.conf), +# then flip back to On. +SecRuleEngine On + +# Request body inspection (required for rules that look at POST bodies) +SecRequestBodyAccess On + +# Maximum request body size - must be >= the largest client_max_body_size in +# nginx.conf (50m for /api/uploads) or the WAF would reject big uploads first. +SecRequestBodyLimit 52428800 # 50 MB +SecRequestBodyNoFilesLimit 1048576 # 1 MB for non-file (JSON) bodies +SecRequestBodyInMemoryLimit 1048576 # buffer up to 1 MB in memory, spill to disk +SecRequestBodyLimitAction Reject + +# Response body inspection is OFF: the API returns JSON that may legitimately +# contain user content (course text, book excerpts). Rules 950xxx/959xxx that +# need response bodies are skipped, which removes a whole class of false +# positives for an API-first service. +SecResponseBodyAccess Off +SecResponseBodyLimit 1048576 +SecResponseBodyMimeType text/plain text/html text/xml + +# PCRE limits - guard against ReDoS via expensive rules +SecPcreMatchLimit 100000 +SecPcreMatchLimitRecursion 100000 + +# ----------------------------------------------------------------------------- +# Runtime directories (must exist and be writable by the nginx worker) +# ----------------------------------------------------------------------------- +SecTmpDir /tmp/ +SecDataDir /tmp/ + +# ----------------------------------------------------------------------------- +# Audit log - the primary source for false-positive tuning +# ----------------------------------------------------------------------------- +# "RelevantOnly" + 4xx (except 404) / 5xx status keeps the log focused on +# traffic that matters: denied requests and suspicious-but-passed ones. +SecAuditEngine RelevantOnly +SecAuditLogRelevantStatus "^(?:5|4(?!04))" +SecAuditLogParts ABIJDEFHZ +SecAuditLogType Serial +SecAuditLog /var/log/modsec_audit.log + +# ----------------------------------------------------------------------------- +# Parsing +# ----------------------------------------------------------------------------- +SecArgumentSeparator & +SecCookieFormat 0 + +# CRS v3.x ships unicode.mapping (utils/unicode.mapping in the CRS repo). +# CRS v4.x no longer needs it, so it is commented out - uncomment + copy the +# file if you deploy CRS v3.x: +# SecUnicodeMapFile /etc/nginx/modsecurity/unicode.mapping 20127 + +SecStatusEngine Off + +# ----------------------------------------------------------------------------- +# OWASP Core Rule Set (v3.x+) +# ----------------------------------------------------------------------------- +# crs-setup.conf holds the CRS tuning knobs: anomaly thresholds, paranoia +# level, allowed HTTP methods/content-types, etc. Copy crs-setup.conf.example +# from the CRS release to this path and review it before going live. +Include /etc/nginx/modsecurity/crs/crs-setup.conf + +# The CRS rule files (REQUEST-901-INITIALIZATION.conf ... RESPONSE-999-EXCEPTIONS.conf). +# nginx/ModSecurity does not use the modsecurity.d-style loading - include the +# whole rules/ directory explicitly. +Include /etc/nginx/modsecurity/crs/rules/*.conf + +# ----------------------------------------------------------------------------- +# Custom rules & false-positive exclusions +# ----------------------------------------------------------------------------- +# Loaded LAST so they run after CRS rules and can override them (exclusions +# via ctl:ruleRemoveById must be evaluated after the rules they disable). +Include /etc/nginx/modsecurity/waf-rules.conf +Include /etc/nginx/modsecurity/waf-exclusions.conf diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..d4c2b70 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,172 @@ +# ============================================================================= +# Deen Bridge backend - nginx reverse proxy configuration +# ----------------------------------------------------------------------------- +# Reverse proxy in front of the Node.js API (default port 5000) providing: +# - Request body size limits per endpoint (closes #254) +# - ModSecurity WAF with OWASP CRS (anomaly scoring) (closes #256) +# - WebSocket / Socket.io proxy (closes #252) +# - Static asset caching headers (closes #255) +# +# Deployment: copy this file (and the sibling snippets in this directory) to +# the nginx host/container, e.g. /etc/nginx/nginx.conf, then run: +# nginx -t && systemctl reload nginx +# See nginx/README.md for the full setup guide. +# ============================================================================= + +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +# Load the ModSecurity nginx connector (modsecurity-nginx). +# Uncomment once the module is compiled/installed - see nginx/README.md: +# load_module modules/ngx_http_modsecurity_module.so; + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Hide nginx version in Server headers / error pages + server_tokens off; + + # ------------------------------------------------------------------ + # WebSocket connection upgrade mapping + # Used by websocket.conf: closes #252 + # ------------------------------------------------------------------ + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # ------------------------------------------------------------------ + # Compression for proxied responses + # ------------------------------------------------------------------ + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_types + text/plain + text/css + application/json + application/javascript + text/xml + application/xml + application/xml+rss + image/svg+xml + application/font-woff2; + + # ------------------------------------------------------------------ + # Upstream application (matches PORT in .env / docker-compose.yml) + # ------------------------------------------------------------------ + upstream dnb_backend { + server 127.0.0.1:5000; + keepalive 32; + } + + server { + listen 80; + server_name _; + + # ============================================================== + # Request body size limits (closes #254) + # -------------------------------------------------------------- + # Defaults applied to every request. Larger limits are granted + # only where the API actually needs them (see locations below). + # ============================================================== + client_max_body_size 1m; # strict default for API endpoints + client_body_buffer_size 128k; + client_body_timeout 30s; # prevent slow-loris body uploads + + # Clear JSON error when a client exceeds a size limit + error_page 413 /413.json; + location = /413.json { + internal; + default_type application/json; + return 413 '{"error":"Payload Too Large","message":"Request body exceeds the maximum allowed size for this endpoint"}'; + } + + # ============================================================== + # ModSecurity WAF with OWASP CRS (closes #256) + # -------------------------------------------------------------- + # modsecurity_rules_file points at the main ModSecurity config, + # which enables the rule engine, loads OWASP CRS (anomaly + # scoring mode) and our custom rules + exclusions. + # ============================================================== + modsecurity on; + modsecurity_rules_file /etc/nginx/modsecurity/modsecurity.conf; + + # Common proxy settings for the API upstream + 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 ""; + proxy_connect_timeout 10s; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + + # -------------------------------------------------------------- + # Health checks - minimal body size, no caching + # -------------------------------------------------------------- + location = /health { + client_max_body_size 1k; + proxy_pass http://dnb_backend; + } + location /health/ { + client_max_body_size 1k; + proxy_pass http://dnb_backend; + } + + # -------------------------------------------------------------- + # File/media uploads - largest allowed body (Cloudinary signed + # uploads, media POSTs). Still capped to prevent DoS. + # -------------------------------------------------------------- + location /api/uploads { + client_max_body_size 50m; + client_body_buffer_size 1m; + proxy_pass http://dnb_backend; + } + + # -------------------------------------------------------------- + # Generic API - inherits the strict 1m default + # -------------------------------------------------------------- + location /api/ { + proxy_pass http://dnb_backend; + } + + # -------------------------------------------------------------- + # Static asset caching headers (closes #255) + # -------------------------------------------------------------- + include /etc/nginx/conf.d/caching.conf; + + # -------------------------------------------------------------- + # WebSocket / Socket.io proxy (closes #252) + # -------------------------------------------------------------- + include /etc/nginx/conf.d/websocket.conf; + + # -------------------------------------------------------------- + # Everything else -> API + # -------------------------------------------------------------- + location / { + proxy_pass http://dnb_backend; + } + } +} diff --git a/nginx/waf-exclusions.conf b/nginx/waf-exclusions.conf new file mode 100644 index 0000000..86c9d83 --- /dev/null +++ b/nginx/waf-exclusions.conf @@ -0,0 +1,97 @@ +# ============================================================================= +# False-positive exclusions for the Deen Bridge backend (closes #256) +# ----------------------------------------------------------------------------- +# The OWASP CRS is generic and written primarily for traditional web apps, so +# an API-first backend like this one needs targeted exclusions. Each exclusion +# below is scoped as narrowly as possible (specific path AND/OR content-type) +# and documents WHY it exists. Rule IDs use the 200100+ range. +# +# Tuning workflow (also documented in nginx/README.md): +# 1. Run in DetectionOnly mode (SecRuleEngine DetectionOnly) or watch the +# audit log at /var/log/modsec_audit.log for blocked legit traffic. +# 2. Identify the CRS rule id from the audit log ("Matched rule ..."). +# 3. Add a scoped ctl:ruleRemoveById for that id - never globally. +# 4. Verify with the "benign traffic" checks in the README before enabling. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 1) Health checks (200101) +# ----------------------------------------------------------------------------- +# Liveness/readiness probes (k8s, Docker HEALTHCHECK, LB checks) hit /health +# with bare user agents and no auth. Inspecting them adds zero value and +# probes have been known to trip UA/header rules. Skip all CRS + custom rules +# for these endpoints. +SecRule REQUEST_URI "@beginsWith /health" \ + "id:200101,phase:1,pass,nolog,t:none,\ + ctl:ruleRemoveById=920000-990000,\ + ctl:ruleRemoveById=200000-200099" + +# ----------------------------------------------------------------------------- +# 2) WebSocket / Socket.io handshake (200102) +# ----------------------------------------------------------------------------- +# Socket.io clients connect to /socket.io/ with an Upgrade request that may +# carry non-standard headers (Sec-WebSocket-*, no content-type, odd +# user-agents from native apps). The CRS protocol-policy family (920xxx) +# frequently false-positives on these. We keep the attack-detection rules +# (930xxx-942xxx) active - the handshake is a GET with no body - and only +# disable the header/content-type policy checks for this path. +SecRule REQUEST_URI "@beginsWith /socket.io/" \ + "id:200102,phase:1,pass,nolog,t:none,\ + ctl:ruleRemoveById=920210,ctl:ruleRemoveById=920220,ctl:ruleRemoveById=920230,\ + ctl:ruleRemoveById=920240,ctl:ruleRemoveById=920250,ctl:ruleRemoveById=920260,\ + ctl:ruleRemoveById=920270,ctl:ruleRemoveById=920280,ctl:ruleRemoveById=920290,\ + ctl:ruleRemoveById=920300,ctl:ruleRemoveById=920310,ctl:ruleRemoveById=920320,\ + ctl:ruleRemoveById=920330,ctl:ruleRemoveById=920340,ctl:ruleRemoveById=920350,\ + ctl:ruleRemoveById=920360,ctl:ruleRemoveById=920370,ctl:ruleRemoveById=920380,\ + ctl:ruleRemoveById=920390,ctl:ruleRemoveById=920400,ctl:ruleRemoveById=920410,\ + ctl:ruleRemoveById=920420,ctl:ruleRemoveById=920430,ctl:ruleRemoveById=920440,\ + ctl:ruleRemoveById=920450,ctl:ruleRemoveById=920460,ctl:ruleRemoveById=920470,\ + ctl:ruleRemoveById=920480" + +# ----------------------------------------------------------------------------- +# 3) Multipart file uploads (200103) +# ----------------------------------------------------------------------------- +# /api/uploads uses multipart/form-data (and Cloudinary signed uploads). +# CRS rule 920420 ("Request content type is not allowed by policy") and +# 920410 ("File upload") can flag legitimate multipart uploads depending on +# the crs-setup policy, and our own JSON-body rule (200002) must never apply +# to multipart. Scoped to the upload path + content type only. +SecRule REQUEST_HEADERS:Content-Type "@beginsWith multipart/form-data" \ + "id:200103,phase:1,pass,nolog,t:none,\ + ctl:ruleRemoveById=200002,\ + ctl:ruleRemoveById=920410,\ + ctl:ruleRemoveById=920420" + +# ----------------------------------------------------------------------------- +# 4) Tuning examples - uncomment only after observing real traffic +# ----------------------------------------------------------------------------- +# +# a) Rich text / HTML in JSON (course descriptions, book excerpts, posts). +# CRS 941xxx (XSS) may flag HTML snippets that are legitimate user content. +# If course content trips 941100, scope an exclusion to that path: +# +# SecRule REQUEST_URI "@beginsWith /api/courses" \ +# "id:200104,phase:1,pass,nolog,t:none,\ +# ctl:ruleRemoveById=941100-941199" +# +# b) PHP-specific injection rules (932xxx). This is a Node.js backend, so PHP +# payloads in request bodies are almost always noise. Keep them ON by +# default (defense-in-depth); if they start firing on legit traffic, the +# usual suspects are 932130 / 932131 / 932150. Exclude per-rule, e.g.: +# +# SecRule REQUEST_URI "@beginsWith /api/" \ +# "id:200105,phase:1,pass,nolog,t:none,\ +# ctl:ruleRemoveById=932130,ctl:ruleRemoveById=932131" +# +# c) SQL-keyword false positives in search/text endpoints (942xxx). +# Search for "select", "union" etc. in book/course text is legitimate. +# If that happens, scope 942xxx exclusions to the search route instead of +# weakening SQLi protection globally: +# +# SecRule REQUEST_URI "@beginsWith /api/search" \ +# "id:200106,phase:1,pass,nolog,t:none,\ +# ctl:ruleRemoveById=942100-942999" +# +# d) Paranoia level. If the default PL1 is too noisy in production, first try +# raising thresholds in crs-setup.conf (tx.inbound_anomaly_score_threshold) +# before disabling rules. Lowering paranoia is a last resort. diff --git a/nginx/waf-rules.conf b/nginx/waf-rules.conf new file mode 100644 index 0000000..f0a48de --- /dev/null +++ b/nginx/waf-rules.conf @@ -0,0 +1,94 @@ +# ============================================================================= +# Custom WAF rules for the Deen Bridge backend (closes #256) +# ----------------------------------------------------------------------------- +# Defense-in-depth rules on top of the OWASP CRS. Rule IDs use the +# 200000-200099 range to avoid colliding with CRS (900000+) or the +# exclusions file (200100+). +# +# Design notes: +# * Hard protocol violations (request smuggling, null bytes, invalid JSON, +# oversized input) are denied immediately. +# * Suspicious-but-ambiguous traffic (scanner user agents) is scored into +# the CRS anomaly bucket instead of hard-blocked, so the final decision +# still comes from the anomaly threshold - keeping "anomaly scoring mode" +# as the single enforcement point. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Anomaly scoring fallback (200008) +# ----------------------------------------------------------------------------- +# Guarantees anomaly scoring is active even if crs-setup.conf ships with the +# threshold/paranoia blocks commented out. For real tuning edit crs-setup.conf: +# this rule runs after CRS initialization (901xxx), so raising the paranoia +# level here only affects phase:2 rules, not the phase:1 protocol checks. +SecAction "id:200008,phase:1,nolog,pass,t:none,\ +setvar:'tx.inbound_anomaly_score_threshold=5',\ +setvar:'tx.outbound_anomaly_score_threshold=4',\ +setvar:'tx.paranoia_level=1'" + +# ----------------------------------------------------------------------------- +# Disallowed HTTP methods (200001) +# ----------------------------------------------------------------------------- +# TRACE enables XST attacks; CONNECT is only for proxies and is never needed +# by the API. CRS flags these too, but deny at the edge regardless. +SecRule REQUEST_METHOD "@pm TRACE CONNECT" \ + "id:200001,phase:1,deny,status:405,log,\ + msg:'Disallowed HTTP method',\ + tag:'protocol-violation',\ + tag:'attack-generic'" + +# ----------------------------------------------------------------------------- +# Strict JSON content-type enforcement (200002) +# ----------------------------------------------------------------------------- +# API endpoints exchange JSON. If a request claims to be application/json but +# the body does not parse as JSON (starts with { or [ after whitespace), reject +# it. Multipart uploads are exempted in waf-exclusions.conf (rule 200103). +SecRule REQUEST_HEADERS:Content-Type "@rx ^application/json" \ + "id:200002,phase:2,deny,status:400,log,\ + msg:'Request body is not valid JSON',\ + tag:'language-json',\ + tag:'application-multi',\ + t:none,\ + chain" + SecRule REQUEST_BODY "@rx ^(?!\s*[{[])" + +# ----------------------------------------------------------------------------- +# Request smuggling indicators (200003) +# ----------------------------------------------------------------------------- +# A request that carries BOTH Transfer-Encoding and Content-Length is the +# classic CL.TE/TE.CL smuggling signature - proxies disagree on the body end. +SecRule REQUEST_HEADERS:Transfer-Encoding "@rx ." \ + "id:200003,phase:1,deny,status:400,log,\ + msg:'Request smuggling attempt: Transfer-Encoding with Content-Length',\ + tag:'protocol-violation',\ + tag:'attack-generic',\ + chain" + SecRule REQUEST_HEADERS:Content-Length "@rx ^\d+$" + +# ----------------------------------------------------------------------------- +# Null bytes (200004) +# ----------------------------------------------------------------------------- +SecRule REQUEST_URI|ARGS "@rx %00" \ + "id:200004,phase:1,deny,status:400,log,\ + msg:'Null byte detected in request',\ + tag:'protocol-violation'" + +# ----------------------------------------------------------------------------- +# Known attack scanners (200005) +# ----------------------------------------------------------------------------- +# Score into the anomaly bucket (+5 = critical, above the default threshold) +# instead of hard-denying, so a false positive never takes the site down. +SecRule REQUEST_HEADERS:User-Agent "@rx (?:sqlmap|nikto|nuclei|masscan|wpscan|nessus|acunetix|openvas|fimap|metasploit|hydra|dirbuster|gobuster|wfuzz|whatweb|jarm|zmap)" \ + "id:200005,phase:1,log,pass,t:none,t:lowercase,\ + setvar:'tx.anomaly_score=+5',\ + msg:'Known attack scanner user-agent',\ + tag:'attack-scanner'" + +# ----------------------------------------------------------------------------- +# Excessively long URIs (200007) +# ----------------------------------------------------------------------------- +# Prevents log flooding and abuse of very long query strings. +SecRule REQUEST_URI "@rx .{4097,}" \ + "id:200007,phase:1,deny,status:414,log,\ + msg:'Request URI too long',\ + tag:'protocol-violation'" diff --git a/nginx/websocket.conf b/nginx/websocket.conf new file mode 100644 index 0000000..a99feea --- /dev/null +++ b/nginx/websocket.conf @@ -0,0 +1,44 @@ +# ============================================================================= +# WebSocket / Socket.io proxy configuration (closes #252) +# ----------------------------------------------------------------------------- +# Include this file inside the server {} block of nginx.conf: +# include /etc/nginx/conf.d/websocket.conf; +# +# Socket.io clients connect to /socket.io/ (WebSocket upgrade, with HTTP +# long-polling as the transport fallback). nginx must speak HTTP/1.1 to the +# upstream and forward the Upgrade / Connection headers so the connection can +# be hijacked into a full-duplex WebSocket tunnel. Without this the upgrade +# is silently dropped and the client falls back to polling. +# +# The $connection_upgrade map used below is defined in the http {} block of +# nginx.conf. +# ============================================================================= + +location /socket.io/ { + proxy_pass http://dnb_backend; + + # HTTP/1.1 is required for the Upgrade mechanism + proxy_http_version 1.1; + + # Forward the WebSocket upgrade headers + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Standard forwarding headers (also set globally, kept here for clarity) + 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; + + # Long-lived connections: keep idle sockets (and heartbeats) alive. + # Socket.io pings every 25s by default, so 60s comfortably covers them. + proxy_read_timeout 60s; + proxy_send_timeout 60s; + proxy_connect_timeout 10s; + + # Stream frames in real time - do not buffer + proxy_buffering off; + + # WebSocket handshakes and long-polling frames are small + client_max_body_size 16k; +}