diff --git a/README.md b/README.md index a93e5df0..ed1aa335 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Further information about nginx third-party add-ons support are available [here] # Usage ModSecurity for nginx extends your nginx configuration directives. -It adds four new directives and they are: +It adds eight directives and they are: modsecurity ----------- @@ -175,6 +175,31 @@ using the same unique identificator. String can contain variables. + +modsecurity_phase4_mode +------------------------ +**syntax:** *modsecurity_phase4_mode minimal | safe | strict* + +**context:** *http, server, location* + +Controls how phase 4 interventions are handled when response headers were already sent. + +modsecurity_phase4_content_types_file +-------------------------------------- +**syntax:** *modsecurity_phase4_content_types_file <path>* + +**context:** *http, server, location* + +Loads the list of response content types that are in scope for phase 4 handling from a file. + +modsecurity_phase4_log +---------------------- +**syntax:** *modsecurity_phase4_log <path>* + +**context:** *http, server, location* + +Sets the file used for phase 4 JSON event logging. + modsecurity_use_error_log ----------- **syntax:** *modsecurity_use_error_log on | off* diff --git a/docs/examples/phase4-content-types.conf b/docs/examples/phase4-content-types.conf new file mode 100644 index 00000000..5baa48ba --- /dev/null +++ b/docs/examples/phase4-content-types.conf @@ -0,0 +1,16 @@ +# phase4-content-types.conf +# +# Format rules: +# 1) Exactly one MIME type per line. +# 2) Lines starting with # are comments. +# 3) Wildcards are not supported (e.g. text/* is invalid). +# 4) Parameters (e.g. "; charset=utf-8") do not need separate entries. +# The module normalizes response Content-Type and compares the base type. +# 5) Keep this list focused on body types you actually want to scope. + +text/html +text/plain +application/json +application/problem+json +application/xml +application/xhtml+xml diff --git a/docs/examples/phase4-minimal.conf b/docs/examples/phase4-minimal.conf new file mode 100644 index 00000000..6d3250e8 --- /dev/null +++ b/docs/examples/phase4-minimal.conf @@ -0,0 +1,31 @@ +# Production example: phase-4 mode "minimal" +# Use when response continuity is more important than forced connection aborts +# for late phase:4 interventions (after headers were already sent). + +http { + upstream app_backend { + server 127.0.0.1:8081; + } + + server { + listen 80; + server_name example.local; + + modsecurity on; + modsecurity_phase4_mode minimal; + modsecurity_phase4_log /var/log/nginx/modsecurity-phase4.log; + modsecurity_phase4_content_types_file /etc/modsecurity/phase4-content-types.conf; + + # Inline rule sample for demonstration. + # In production, you can keep using modsecurity_rules_file as usual. + modsecurity_rules ' + SecRuleEngine On + SecResponseBodyAccess On + SecRule RESPONSE_BODY "@rx sensitive-marker" "id:940101,phase:4,deny,log,status:403" + '; + + location / { + proxy_pass http://app_backend; + } + } +} diff --git a/docs/examples/phase4-safe.conf b/docs/examples/phase4-safe.conf new file mode 100644 index 00000000..baaa7a08 --- /dev/null +++ b/docs/examples/phase4-safe.conf @@ -0,0 +1,29 @@ +# Production example: phase-4 mode "safe" +# Default mode in this module. Recommended baseline if you need phase:4 +# visibility while avoiding forced disconnects on late interventions. + +http { + upstream app_backend { + server 127.0.0.1:8081; + } + + server { + listen 80; + server_name example.local; + + modsecurity on; + modsecurity_phase4_mode safe; + modsecurity_phase4_log /var/log/nginx/modsecurity-phase4.log; + modsecurity_phase4_content_types_file /etc/modsecurity/phase4-content-types.conf; + + modsecurity_rules ' + SecRuleEngine On + SecResponseBodyAccess On + SecRule RESPONSE_BODY "@rx sensitive-marker" "id:940102,phase:4,deny,log,status:403" + '; + + location / { + proxy_pass http://app_backend; + } + } +} diff --git a/docs/examples/phase4-strict.conf b/docs/examples/phase4-strict.conf new file mode 100644 index 00000000..68fb18ec --- /dev/null +++ b/docs/examples/phase4-strict.conf @@ -0,0 +1,29 @@ +# Production example: phase-4 mode "strict" +# Use only if connection aborts are operationally acceptable, because +# late phase:4 interventions may terminate the connection. + +http { + upstream app_backend { + server 127.0.0.1:8081; + } + + server { + listen 80; + server_name example.local; + + modsecurity on; + modsecurity_phase4_mode strict; + modsecurity_phase4_log /var/log/nginx/modsecurity-phase4.log; + modsecurity_phase4_content_types_file /etc/modsecurity/phase4-content-types.conf; + + modsecurity_rules ' + SecRuleEngine On + SecResponseBodyAccess On + SecRule RESPONSE_BODY "@rx sensitive-marker" "id:940103,phase:4,deny,log,status:403" + '; + + location / { + proxy_pass http://app_backend; + } + } +} diff --git a/docs/phase4-handling.de.md b/docs/phase4-handling.de.md new file mode 100644 index 00000000..78799869 --- /dev/null +++ b/docs/phase4-handling.de.md @@ -0,0 +1,251 @@ +# ModSecurity-nginx: Phase-4-Handling (Deutsch) + +## Zweck dieses Dokuments + +Dieses Dokument beschreibt die im aktuellen Code implementierte Phase-4-Behandlung im nginx-Modul, inklusive: + +- technischer Grenzen bei bereits gesendeten Headern, +- Verhalten der Modi `minimal`, `safe`, `strict`, +- Content-Type-Scoping, +- Logging (`modsecurity_phase4_log`) und Sicherheitsaspekte, +- Abgrenzung zwischen **Produktionskonfiguration** und **Test-/Demo-Verhalten**. + +Es werden nur Aussagen getroffen, die durch den aktuellen Repository-Stand belegt sind. + +--- + +## 1) Hintergrund: Request- vs. Response-Phasen + +ModSecurity-Regeln laufen in unterschiedlichen Phasen der Transaktion. + +- Frühe Phasen (z. B. Request-Phasen) treffen Entscheidungen, bevor eine Antwort gesendet wird. +- `phase:4` gehört zur **Response-Body-Verarbeitung**. + +Das ist ein zentraler Unterschied: In `phase:4` kann nginx bereits begonnen haben, Header und/oder Body zu senden. + +### Warum ist das relevant? + +Wenn eine Regel in `phase:4` eine Intervention wie `deny` oder `redirect` auslöst, ist ein sauberer Wechsel auf neuen HTTP-Status nur möglich, solange Header noch nicht final gesendet wurden. + +--- + +## 2) Was `phase:4` praktisch bedeutet + +`phase:4`-Regeln prüfen den Antwortinhalt (Response-Body). Das ist nützlich, wenn Sicherheitskriterien erst am Antwortinhalt erkennbar sind. + +Gleichzeitig erzeugt es eine harte technische Grenze: + +- **Vor Header-Versand**: Status/Redirect kann noch sauber gesetzt werden. +- **Nach Header-Versand**: Status/Redirect lässt sich nicht zuverlässig „nachträglich“ korrigieren. + +Daher existiert im Modul ein dediziertes Handling für späte Interventionen. + +--- + +## 3) Warum Header in `phase:4` bereits gesendet sein können + +In nginx werden Antworten als Stream verarbeitet. Je nach Upstream-, Filter- und Buffering-Verhalten kann der Header bereits auf dem Socket sein, bevor die vollständige Body-Prüfung abgeschlossen ist. + +Folge: Ein in `phase:4` erkanntes `status:403` oder `redirect:302` kann nicht garantiert als sauberer HTTP-Status beim Client landen. + +> Keine falsche Garantie: `phase:4` kann nach gesendeten Headern keinen sauberen HTTP-Status mehr garantieren. + +--- + +## 4) Neue Directives im Modul + +## `modsecurity_phase4_mode` + +Konfiguriert das Verhalten bei Phase-4-Interventionen. + +Unterstützte Werte: + +- `minimal` +- `safe` +- `strict` + +Ungültige Werte führen zu Konfigurationsfehlern. + +## `modsecurity_phase4_content_types_file` + +Lädt erlaubte/gescopte Content-Types aus einer Datei. + +- eine Zeile pro Typ, +- Kommentare mit `#`, +- Einträge werden validiert, +- Wildcards (`*`) sind nicht erlaubt. + +Wenn nicht gesetzt, nutzt das Modul Default-Typen. + +## `modsecurity_phase4_log` + +Aktiviert dediziertes JSON-Line-Logging für Phase-4-Ereignisse. + +--- + +## 5) Modusverhalten (`minimal`, `safe`, `strict`) + +## `minimal` + +Ziel: möglichst wenig Eingriff in laufende Responses. + +Bei Intervention nach gesendeten Headern: + +- Aktion wird auf `log_only` degradiert, +- keine erzwungene Verbindungsbeendigung. + +Sinnvoll, wenn Stabilität und vollständige Antwortauslieferung Vorrang haben. + +## `safe` + +Ziel: konservativer Produktionsstandard (Default-Merge-Verhalten im Modul). + +Bei Intervention nach gesendeten Headern: + +- ebenfalls `log_only`. + +Sinnvoll als Standardmodus, wenn Phase-4-Transparenz gewünscht ist, aber Verbindungsabbrüche vermieden werden sollen. + +## `strict` + +Ziel: strengere Reaktion, wenn keine saubere Statusänderung mehr möglich ist. + +Bei Intervention nach gesendeten Headern: + +- `connection_abort`. + +### Risiken von `strict` + +- Kann aktive Verbindungen abbrechen. +- Liefert nicht garantiert einen „schönen“ HTTP-Fehlerstatus beim Client. +- Kann je nach Client/Proxy als Transportfehler statt als 4xx/3xx sichtbar werden. + +`strict` ist daher nur sinnvoll, wenn diese Nebenwirkungen betrieblich akzeptabel sind. + +--- + +## 6) Verhalten nach Header-Status + +## Header noch **nicht** gesendet + +Wenn eine Intervention vor Header-Versand finalisiert wird, bleibt normaler Deny-/Statuspfad möglich (im Code als `deny_status` geloggt). + +## Header bereits gesendet + +- `minimal`: `log_only` +- `safe`: `log_only` +- `strict`: `connection_abort` + +Das ist eine bewusste Degradierung, um keine falschen Status-Garantien vorzutäuschen. + +--- + +## 7) Warum **kein globales Response-Body-Buffering** genutzt wird + +Ein globales Buffering aller Responses würde zwar den Eingriffszeitpunkt verschieben, bringt aber technische und betriebliche Kosten: + +- zusätzlicher Speicher- und Latenz-Overhead, +- größere Komplexität für allgemeine Response-Pfade, +- Risiko unbeabsichtigter Nebenwirkungen auf Durchsatz/Stabilität. + +Der aktuelle Ansatz versucht stattdessen, late Interventionen transparent und kontrolliert zu behandeln (`log_only` oder `connection_abort`). + +--- + +## 8) Warum kein `ngx_chain_t`-Reordering/Rewriting genutzt wird + +Das Modul implementiert **keine** künstliche Reordering-Logik auf bereits laufenden Body-Ketten, um nachträglich „doch noch“ andere Header-/Statussemantik zu erzwingen. + +Begründung: + +- hohe Komplexität, +- erhöhte Fehleranfälligkeit, +- schwierige Garantien über korrektes Verhalten in allen Filter-/Upstream-Kombinationen. + +Die dokumentierte Degradierung ist technisch robuster als scheinbar harte, aber unsichere Nachkorrekturen. + +--- + +## 9) Content-Type-Scoping: Bedeutung und sichere Nutzung + +`modsecurity_phase4_content_types_file` begrenzt Phase-4-Sonderbehandlung auf definierte MIME-Typen. + +Wenn `Content-Type` fehlt oder nicht im Scope liegt: + +- Ereignis wird als `log_only` dokumentiert, +- Grund ist typischerweise `content_type_missing` oder `content_type_not_in_scope`. + +### Warum wichtig? + +- reduziert Nebeneffekte auf nicht-zielgerichtete Antworttypen, +- macht Verhalten vorhersagbarer, +- zwingt zu expliziter Auswahl relevanter Content-Typen. + +--- + +## 10) Logging-Format und Sicherheitsgrenzen + +Mit `modsecurity_phase4_log` schreibt das Modul JSON-Zeilen, u. a. mit: + +- `event` (`phase4_intervention`) +- `uri`, `method` +- `response_status`, `waf_status` +- `content_type` +- `header_sent` +- `mode` +- `wanted_action`, `actual_action` +- `reason` +- `intervention` +- `rule_id` + +Zusätzlich kann im nginx `error.log` ein Hinweis erscheinen (insbesondere im `strict`-Pfad bei Headern, die schon gesendet wurden). + +### Sicherheitsgrenze im Logging + +Die Tests prüfen explizit, dass keine Response-Body-Inhalte in das Phase-4-Log „durchrutschen“. + +--- + +## 11) Produktionsbeispiele + +Allgemeine (nicht testpfadgebundene) Beispielkonfigurationen: + +- `docs/examples/phase4-minimal.conf` +- `docs/examples/phase4-safe.conf` +- `docs/examples/phase4-strict.conf` +- `docs/examples/phase4-content-types.conf` + +Diese Beispiele nutzen `http`/`server`/`location /` und keine test-spezifischen `/phase4`-Pfade. + +--- + +## 12) Test-/Demo-Verhalten (klar getrennt) + +Im Repository existieren Testfälle mit `/phase4`-Pfaden, z. B. in: + +- `tests/modsecurity.t` +- `tests/modsecurity-proxy.t` +- `tests/modsecurity-h2.t` +- `tests/modsecurity-proxy-h2.t` +- `tests/modsecurity-phase4-*.t` + +Diese Pfade sind **Testkontext** und nicht als allgemeine Produktionskonfiguration gedacht. + +--- + +## 13) Bekannte Grenzen / keine falschen Versprechen + +- `phase:4` kann **nicht garantieren**, dass ein gewünschter 301/302/401/403-Status nach Header-Versand noch sauber beim Client ankommt. +- Bei späten Interventionen ist nur degradierte Behandlung möglich (`log_only` oder `connection_abort`). +- `strict` bedeutet nicht „garantierter 403“, sondern kann Verbindungsabbruch bedeuten. + +--- + +## 14) Betriebsleitfaden (kurz) + +1. Harte Block-/Redirect-Entscheidungen möglichst in frühere Phasen legen. +2. `safe` als Ausgangspunkt verwenden, wenn keine klare Notwendigkeit für `strict` besteht. +3. `strict` nur einsetzen, wenn Abbrüche tolerierbar und beobachtbar sind. +4. `modsecurity_phase4_log` aktivieren und auf `actual_action`/`reason` auswerten. +5. Content-Type-Liste eng halten und regelmäßig prüfen. + diff --git a/docs/phase4-handling.en.md b/docs/phase4-handling.en.md new file mode 100644 index 00000000..7e5e1a69 --- /dev/null +++ b/docs/phase4-handling.en.md @@ -0,0 +1,251 @@ +# ModSecurity-nginx: Phase 4 Handling (English) + +## Purpose of this document + +This document describes the currently implemented Phase 4 behavior in the nginx module, including: + +- technical limits once headers are already sent, +- behavior of `minimal`, `safe`, and `strict` modes, +- content-type scoping, +- logging (`modsecurity_phase4_log`) and security boundaries, +- a clear split between **production configuration** and **test/demo behavior**. + +Only statements supported by the current repository code and tests are included. + +--- + +## 1) Background: request vs response phases + +ModSecurity rules run in multiple transaction phases. + +- Early phases (for example request phases) make decisions before a response is emitted. +- `phase:4` belongs to **response-body processing**. + +This difference is critical: during `phase:4`, nginx may already have sent headers and/or part of the body. + +### Why this matters + +If a `phase:4` rule triggers an intervention (`deny`, `status`, `redirect`), a clean status/redirect rewrite is only possible while headers are still unsent. + +--- + +## 2) What `phase:4` means operationally + +`phase:4` rules inspect response body content. This is useful when security signals are visible only in outgoing payload. + +At the same time, this creates a hard technical boundary: + +- **Before headers are sent**: status/redirect can still be applied cleanly. +- **After headers are sent**: status/redirect can no longer be reliably changed. + +Therefore the module includes dedicated late-intervention handling. + +--- + +## 3) Why headers may already be sent in `phase:4` + +nginx processes responses as a stream. Depending on upstream behavior, buffering, and filter timing, headers can already be on the wire before body inspection fully completes. + +Result: a `phase:4` `status:403` or `redirect:302` is not guaranteed to become a clean client-visible HTTP status. + +> No false guarantee: after headers are sent, `phase:4` cannot guarantee a clean HTTP status rewrite. + +--- + +## 4) New directives in this module + +## `modsecurity_phase4_mode` + +Configures phase-4 late-intervention behavior. + +Supported values: + +- `minimal` +- `safe` +- `strict` + +Invalid values are rejected by configuration parsing. + +## `modsecurity_phase4_content_types_file` + +Loads scoped content types from a file. + +- one MIME type per line, +- `#` comments supported, +- entries are validated, +- wildcards (`*`) are rejected. + +If unset, module defaults are used. + +## `modsecurity_phase4_log` + +Enables dedicated JSON-lines logging for Phase 4 events. + +--- + +## 5) Mode behavior (`minimal`, `safe`, `strict`) + +## `minimal` + +Goal: least intrusive behavior. + +For interventions after headers were sent: + +- action is downgraded to `log_only`, +- no forced connection termination. + +Use when delivery continuity is prioritized. + +## `safe` + +Goal: conservative production baseline (module default merge behavior). + +For late interventions: + +- also downgraded to `log_only`. + +Use as default when you want phase:4 visibility without forced disconnect side effects. + +## `strict` + +Goal: stricter fallback once clean status rewrite is no longer possible. + +For interventions after headers were sent: + +- `connection_abort`. + +### Risks of `strict` + +- Active connections may terminate. +- Clients/proxies may observe transport interruption rather than a clean 4xx/3xx response. +- It does **not** mean “guaranteed 403/401/301/302”. + +Use only when those trade-offs are acceptable. + +--- + +## 6) Behavior by header state + +## Headers **not sent yet** + +If intervention is finalized before header send, normal deny/status behavior remains possible (logged as `deny_status` in code path). + +## Headers **already sent** + +- `minimal`: `log_only` +- `safe`: `log_only` +- `strict`: `connection_abort` + +This is an intentional downgrade to avoid false status guarantees. + +--- + +## 7) Why there is **no global response-body buffering** + +Global buffering of all responses could delay decision points, but introduces broad costs: + +- additional memory and latency overhead, +- higher complexity in generic response paths, +- increased risk of throughput/stability side effects. + +Current implementation instead makes late interventions explicit and controlled (`log_only` or `connection_abort`). + +--- + +## 8) Why there is no `ngx_chain_t` reordering/rewriting + +The module does **not** implement synthetic reordering of already flowing body chains to force post-hoc status semantics. + +Reasoning: + +- high implementation complexity, +- higher fragility, +- difficult correctness guarantees across all filter/upstream combinations. + +The documented downgrade model is more robust than pretending hard guarantees. + +--- + +## 9) Content-type scoping: meaning and safe usage + +`modsecurity_phase4_content_types_file` limits special phase-4 handling to selected MIME types. + +If `Content-Type` is missing or out of scope: + +- action is logged as `log_only`, +- reason is typically `content_type_missing` or `content_type_not_in_scope`. + +### Why this matters + +- reduces side effects on non-target response types, +- improves predictability, +- enforces explicit operator intent. + +--- + +## 10) Logging format and security boundaries + +With `modsecurity_phase4_log`, the module emits JSON lines including fields such as: + +- `event` (`phase4_intervention`) +- `uri`, `method` +- `response_status`, `waf_status` +- `content_type` +- `header_sent` +- `mode` +- `wanted_action`, `actual_action` +- `reason` +- `intervention` +- `rule_id` + +Additionally, nginx `error.log` may contain warnings (especially on strict late-intervention paths). + +### Logging boundary + +Tests explicitly check that response body payload is not leaked into phase-4 log output. + +--- + +## 11) Production examples + +General (non test-path-specific) example configurations: + +- `docs/examples/phase4-minimal.conf` +- `docs/examples/phase4-safe.conf` +- `docs/examples/phase4-strict.conf` +- `docs/examples/phase4-content-types.conf` + +These use `http` / `server` / `location /` patterns and avoid `/phase4` as a production example path. + +--- + +## 12) Test/demo behavior (explicitly separate) + +Repository tests include `/phase4` endpoints, for example in: + +- `tests/modsecurity.t` +- `tests/modsecurity-proxy.t` +- `tests/modsecurity-h2.t` +- `tests/modsecurity-proxy-h2.t` +- `tests/modsecurity-phase4-*.t` + +Those paths are **test context**, not generic production guidance. + +--- + +## 13) Known limits / no false promises + +- `phase:4` cannot **guarantee** clean 301/302/401/403 delivery once headers are already sent. +- For late interventions, only downgraded handling is possible (`log_only` or `connection_abort`). +- `strict` is not “guaranteed block status”; it can mean connection termination. + +--- + +## 14) Operator checklist + +1. Place hard block/redirect decisions in earlier phases whenever possible. +2. Start with `safe` unless you have a clear need for `strict`. +3. Use `strict` only when connection abort side effects are acceptable. +4. Enable `modsecurity_phase4_log` and monitor `actual_action` + `reason` fields. +5. Keep content-type scope narrow and review it regularly. + diff --git a/src/ngx_http_modsecurity_body_filter.c b/src/ngx_http_modsecurity_body_filter.c index 0c28e3c1..0b7223e8 100644 --- a/src/ngx_http_modsecurity_body_filter.c +++ b/src/ngx_http_modsecurity_body_filter.c @@ -14,6 +14,7 @@ */ #include +#include #ifndef MODSECURITY_DDEBUG #define MODSECURITY_DDEBUG 0 @@ -23,6 +24,13 @@ #include "ngx_http_modsecurity_common.h" static ngx_http_output_body_filter_pt ngx_http_next_body_filter; +static ngx_int_t ngx_http_modsecurity_phase4_in_scope(ngx_http_request_t *r); +static ngx_int_t ngx_http_modsecurity_phase4_log_event(ngx_http_request_t *r, ngx_http_modsecurity_conf_t *mcf, const char *wanted, const char *actual, const char *reason); +static ngx_int_t ngx_http_modsecurity_phase4_handle_intervention(ngx_http_request_t *r, ngx_http_modsecurity_conf_t *mcf); +static void ngx_http_modsecurity_json_escape(ngx_pool_t *pool, ngx_str_t *src, ngx_str_t *dst); +static void ngx_http_modsecurity_extract_rule_id(ngx_pool_t *pool, ngx_str_t *intervention, ngx_str_t *rule_id); +static ngx_str_t ngx_http_modsecurity_normalize_content_type(ngx_pool_t *pool, ngx_str_t in); +static ngx_str_t ngx_http_modsecurity_sanitize_intervention(ngx_pool_t *pool, ngx_str_t in); /* XXX: check behaviour on few body filters installed */ ngx_int_t @@ -39,8 +47,8 @@ ngx_http_modsecurity_body_filter(ngx_http_request_t *r, ngx_chain_t *in) { ngx_chain_t *chain = in; ngx_http_modsecurity_ctx_t *ctx = NULL; -#if defined(MODSECURITY_SANITY_CHECKS) && (MODSECURITY_SANITY_CHECKS) ngx_http_modsecurity_conf_t *mcf; +#if defined(MODSECURITY_SANITY_CHECKS) && (MODSECURITY_SANITY_CHECKS) ngx_list_part_t *part = &r->headers_out.headers.part; ngx_table_elt_t *data = part->elts; ngx_uint_t i = 0; @@ -141,6 +149,7 @@ ngx_http_modsecurity_body_filter(ngx_http_request_t *r, ngx_chain_t *in) #endif int is_request_processed = 0; + mcf = ngx_http_get_module_loc_conf(r, ngx_http_modsecurity_module); for (; chain != NULL; chain = chain->next) { u_char *data = chain->buf->pos; @@ -151,12 +160,19 @@ ngx_http_modsecurity_body_filter(ngx_http_request_t *r, ngx_chain_t *in) if (ret > 0) { return ngx_http_filter_finalize_request(r, &ngx_http_modsecurity_module, ret); + } else if (ret < 0) { + ret = ngx_http_modsecurity_phase4_handle_intervention(r, mcf); + if (ret == NGX_ERROR) return NGX_ERROR; } /* XXX: chain->buf->last_buf || chain->buf->last_in_chain */ is_request_processed = chain->buf->last_buf; - if (is_request_processed) { + if (!is_request_processed) { + continue; + } + + { ngx_pool_t *old_pool; old_pool = ngx_http_modsecurity_pcre_malloc_init(r->pool); @@ -167,12 +183,18 @@ ngx_http_modsecurity_body_filter(ngx_http_request_t *r, ngx_chain_t *in) XXX: body we can proceed to adjust body size (content-length). see xslt_body_filter() for example */ ret = ngx_http_modsecurity_process_intervention(ctx->modsec_transaction, r, 0); if (ret > 0) { + if (!ctx->phase4_headers_checked) { + ngx_http_modsecurity_phase4_log_event(r, mcf, "deny", "deny_status", "headers_not_sent"); + ctx->phase4_headers_checked = 1; + } return ret; } - else if (ret < 0) { - return ngx_http_filter_finalize_request(r, - &ngx_http_modsecurity_module, NGX_HTTP_INTERNAL_SERVER_ERROR); - + if (ret < 0) { + ret = ngx_http_modsecurity_phase4_handle_intervention(r, mcf); + if (ret == NGX_ERROR) { + return NGX_ERROR; + } + return ngx_http_next_body_filter(r, in); } } } @@ -184,3 +206,196 @@ ngx_http_modsecurity_body_filter(ngx_http_request_t *r, ngx_chain_t *in) /* XXX: xflt_filter() -- return NGX_OK here */ return ngx_http_next_body_filter(r, in); } + +static ngx_int_t +ngx_http_modsecurity_phase4_handle_intervention(ngx_http_request_t *r, ngx_http_modsecurity_conf_t *mcf) +{ + ngx_http_modsecurity_ctx_t *ctx = ngx_http_modsecurity_get_module_ctx(r); + ngx_int_t in_scope = ngx_http_modsecurity_phase4_in_scope(r); + const char *wanted = "deny"; + if (ctx && ctx->last_intervention_status >= 300 && ctx->last_intervention_status < 400) { + wanted = "redirect"; + } + if (ctx && ctx->phase4_headers_checked) return NGX_OK; + if (ctx) ctx->phase4_headers_checked = 1; + + if (in_scope == 0) { + ngx_http_modsecurity_phase4_log_event(r, mcf, wanted, "log_only", r->headers_out.content_type.len ? "content_type_not_in_scope" : "content_type_missing"); + return NGX_OK; + } + if (mcf->phase4_mode == NGX_HTTP_MODSEC_PHASE4_MODE_STRICT) { + ngx_http_modsecurity_phase4_log_event(r, mcf, wanted, "connection_abort", "headers_already_sent"); + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, + "modsecurity phase4 intervention after headers sent, action=connection_abort, uri=\"%V\"", &r->uri); + r->connection->error = 1; + return NGX_ERROR; + } + ngx_http_modsecurity_phase4_log_event(r, mcf, wanted, "log_only", + mcf->phase4_mode == NGX_HTTP_MODSEC_PHASE4_MODE_MINIMAL ? "mode_minimal" : "mode_safe"); + return NGX_OK; +} + +static ngx_int_t +ngx_http_modsecurity_phase4_in_scope(ngx_http_request_t *r) +{ + ngx_http_modsecurity_conf_t *mcf = ngx_http_get_module_loc_conf(r, ngx_http_modsecurity_module); + ngx_uint_t i; + ngx_str_t ct; + u_char *semi; + if (r->headers_out.content_type.len == 0 || mcf->phase4_content_types == NULL) return 0; + ct = r->headers_out.content_type; + semi = (u_char *)ngx_strlchr(ct.data, ct.data + ct.len, ';'); + if (semi != NULL) ct.len = semi - ct.data; + while (ct.len > 0 && isspace((unsigned char)ct.data[ct.len - 1])) ct.len--; + for (i = 0; i < mcf->phase4_content_types->nelts; i++) { + ngx_str_t *arr = mcf->phase4_content_types->elts; + if (arr[i].len == ct.len && ngx_strncasecmp(arr[i].data, ct.data, ct.len) == 0) return 1; + } + return 0; +} + +static ngx_int_t +ngx_http_modsecurity_phase4_log_event(ngx_http_request_t *r, ngx_http_modsecurity_conf_t *mcf, const char *wanted, const char *actual, const char *reason) +{ + u_char *p; + ngx_str_t euri; + ngx_str_t emethod; + ngx_str_t ect; + ngx_str_t elog; + ngx_str_t erule; + ngx_str_t raw_log; + ngx_str_t slog; + const char *mode = "safe"; + const char *header_sent = r->header_sent ? "true" : "false"; + ngx_http_modsecurity_ctx_t *ctx = ngx_http_modsecurity_get_module_ctx(r); + if (mcf->phase4_log_file == NULL || mcf->phase4_log_file->fd == NGX_INVALID_FILE) return NGX_OK; + ngx_http_modsecurity_json_escape(r->pool, &r->uri, &euri); + ngx_http_modsecurity_json_escape(r->pool, &r->method_name, &emethod); + ngx_str_t nct = ngx_http_modsecurity_normalize_content_type(r->pool, r->headers_out.content_type); + ngx_http_modsecurity_json_escape(r->pool, &nct, &ect); + if (ctx) { + raw_log = ctx->last_intervention_log; + ngx_http_modsecurity_extract_rule_id(r->pool, &raw_log, &erule); + slog = ngx_http_modsecurity_sanitize_intervention(r->pool, raw_log); + ngx_http_modsecurity_json_escape(r->pool, &slog, &elog); + } else { + raw_log.len = 0; raw_log.data = (u_char *)""; + elog.len = 0; elog.data=(u_char*)""; + erule.len = 0; erule.data=(u_char*)""; + } + if (mcf->phase4_mode == NGX_HTTP_MODSEC_PHASE4_MODE_MINIMAL) mode = "minimal"; + else if (mcf->phase4_mode == NGX_HTTP_MODSEC_PHASE4_MODE_STRICT) mode = "strict"; + size_t need = 256 + euri.len + emethod.len + ect.len + elog.len + erule.len + ngx_strlen(mode) + ngx_strlen(wanted) + ngx_strlen(actual) + ngx_strlen(reason); + u_char *dbuf = ngx_pnalloc(r->pool, need); + if (dbuf == NULL) { + ngx_log_error(NGX_LOG_WARN, r->connection->log, 0, "modsecurity phase4 log allocation failed"); + return NGX_ERROR; + } + p = ngx_snprintf(dbuf, need, + "{\"event\":\"phase4_intervention\",\"uri\":\"%V\",\"method\":\"%V\",\"response_status\":%ui,\"waf_status\":%i,\"content_type\":\"%V\",\"header_sent\":%s,\"mode\":\"%s\",\"wanted_action\":\"%s\",\"actual_action\":\"%s\",\"reason\":\"%s\",\"intervention\":\"%V\",\"rule_id\":\"%V\"}\n", + &euri,&emethod,(ngx_uint_t)r->headers_out.status,ctx ? (int) ctx->last_intervention_status : 0,&ect,header_sent,mode,wanted,actual,reason,&elog,&erule); + ssize_t n = ngx_write_fd(mcf->phase4_log_file->fd, dbuf, p - dbuf); + if (n < 0 || (size_t) n != (size_t) (p - dbuf)) { + ngx_log_error(NGX_LOG_WARN, r->connection->log, ngx_errno, + "modsecurity phase4 log write failed"); + return NGX_ERROR; + } + return NGX_OK; +} + +static ngx_str_t +ngx_http_modsecurity_normalize_content_type(ngx_pool_t *pool, ngx_str_t in) +{ + ngx_str_t out; + size_t i; + u_char *semi; + out = in; + if (out.data == NULL || out.len == 0) return out; + semi = (u_char *)ngx_strlchr(out.data, out.data + out.len, ';'); + if (semi) out.len = semi - out.data; + while (out.len > 0 && isspace((unsigned char) out.data[out.len - 1])) out.len--; + out.data = ngx_pnalloc(pool, out.len); + if (out.data == NULL) { out.len = 0; return out; } + for (i = 0; i < out.len; i++) out.data[i] = ngx_tolower(in.data[i]); + return out; +} + +static ngx_str_t +ngx_http_modsecurity_sanitize_intervention(ngx_pool_t *pool, ngx_str_t in) +{ + static ngx_str_t redacted = { sizeof("redacted") - 1, (u_char *) "redacted" }; + ngx_str_t out = redacted; + u_char *id, *msg, *op; + size_t len = 0; + if (in.data == NULL || in.len == 0) { + return redacted; + } + id = (u_char *)ngx_strstr(in.data, "id \""); + msg = (u_char *)ngx_strstr(in.data, "msg \""); + op = (u_char *)ngx_strstr(in.data, "Operator"); + if (id == NULL && msg == NULL && op == NULL) { + return redacted; + } + len = 9 + (id ? 10 : 0) + (msg ? 12 : 0) + (op ? 10 : 0); + out.data = ngx_pnalloc(pool, len); + if (out.data == NULL) return redacted; + out.len = ngx_snprintf(out.data, len, "id:%s msg:%s op:%s", + id ? "present" : "-", msg ? "present" : "-", op ? "present" : "-") - out.data; + return out; +} + +static void +ngx_http_modsecurity_json_escape(ngx_pool_t *pool, ngx_str_t *src, ngx_str_t *dst) +{ + size_t i; + size_t extra = 0; + u_char *d; + if (src == NULL || src->data == NULL) { dst->len=0; dst->data=(u_char*)""; return; } + for (i = 0; i < src->len; i++) { + if (src->data[i] < 0x20 || src->data[i] == '"' || src->data[i] == '\\') { + extra++; + } + } + dst->data = ngx_pnalloc(pool, src->len + extra + 1); + if (dst->data == NULL) { + dst->len = 0; + dst->data = (u_char *)""; + return; + } + d = dst->data; + for (i = 0; i < src->len; i++) { + u_char c = src->data[i]; + if (c == '"' || c == '\\') { *d++='\\'; *d++=c; } + else if (c < 0x20) { *d++=' '; } + else *d++=c; + } + dst->len = d - dst->data; +} + +static void +ngx_http_modsecurity_extract_rule_id(ngx_pool_t *pool, ngx_str_t *intervention, ngx_str_t *rule_id) +{ + size_t i; + rule_id->data = (u_char *)""; + rule_id->len = 0; + if (intervention == NULL || intervention->data == NULL) return; + for (i = 0; i + 4 < intervention->len; i++) { + size_t j; + + if (ngx_strncasecmp(intervention->data + i, (u_char *)"id \"", 4) != 0) continue; + + j = i + 4; + while (j < intervention->len && intervention->data[j] >= '0' && intervention->data[j] <= '9') j++; + if (j <= i + 4 || j >= intervention->len || intervention->data[j] != '"') continue; + + rule_id->len = j - (i + 4); + rule_id->data = ngx_pnalloc(pool, rule_id->len); + if (rule_id->data == NULL) { + rule_id->len = 0; + rule_id->data = (u_char *)""; + return; + } + ngx_memcpy(rule_id->data, intervention->data + i + 4, rule_id->len); + return; + } +} diff --git a/src/ngx_http_modsecurity_common.h b/src/ngx_http_modsecurity_common.h index 9f79b5ba..de9941cb 100644 --- a/src/ngx_http_modsecurity_common.h +++ b/src/ngx_http_modsecurity_common.h @@ -71,6 +71,10 @@ #define MODSECURITY_NGINX_WHOAMI "ModSecurity-nginx v" \ MODSECURITY_NGINX_VERSION +#define NGX_HTTP_MODSEC_PHASE4_MODE_MINIMAL 0 +#define NGX_HTTP_MODSEC_PHASE4_MODE_SAFE 1 +#define NGX_HTTP_MODSEC_PHASE4_MODE_STRICT 2 + typedef struct { ngx_str_t name; ngx_str_t value; @@ -100,6 +104,9 @@ typedef struct { unsigned logged:1; unsigned intervention_triggered:1; unsigned request_body_processed:1; + unsigned phase4_headers_checked:1; + ngx_str_t last_intervention_log; + ngx_int_t last_intervention_status; } ngx_http_modsecurity_ctx_t; @@ -124,6 +131,11 @@ typedef struct { #endif ngx_http_complex_value_t *transaction_id; + ngx_uint_t phase4_mode; + ngx_array_t *phase4_content_types; + ngx_str_t phase4_content_types_file; + ngx_open_file_t *phase4_log_file; + ngx_str_t phase4_log_path; } ngx_http_modsecurity_conf_t; diff --git a/src/ngx_http_modsecurity_module.c b/src/ngx_http_modsecurity_module.c index d3d9624d..e397dfd1 100644 --- a/src/ngx_http_modsecurity_module.c +++ b/src/ngx_http_modsecurity_module.c @@ -22,6 +22,7 @@ #include "ngx_http_modsecurity_common.h" #include "stdio.h" +#include #include #include @@ -36,7 +37,12 @@ static void *ngx_http_modsecurity_create_conf(ngx_conf_t *cf); static char *ngx_http_modsecurity_merge_conf(ngx_conf_t *cf, void *parent, void *child); static void ngx_http_modsecurity_cleanup_instance(void *data); static void ngx_http_modsecurity_cleanup_rules(void *data); - +static char *ngx_conf_set_phase4_mode(ngx_conf_t *cf, ngx_command_t *cmd, void *conf); +static char *ngx_conf_set_phase4_content_types_file(ngx_conf_t *cf, ngx_command_t *cmd, void *conf); +static char *ngx_conf_set_phase4_log(ngx_conf_t *cf, ngx_command_t *cmd, void *conf); +static char *ngx_http_modsecurity_phase4_set_default_content_types(ngx_conf_t *cf, ngx_http_modsecurity_conf_t *mcf); +static char *ngx_http_modsecurity_phase4_load_content_types_file(ngx_conf_t *cf, ngx_http_modsecurity_conf_t *mcf, ngx_str_t *path); +static ngx_int_t ngx_http_modsecurity_phase4_validate_content_type(u_char *s, size_t len); /* * PCRE malloc/free workaround, based on @@ -160,12 +166,25 @@ ngx_http_modsecurity_process_intervention (Transaction *transaction, ngx_http_re dd("nothing to do"); return 0; } - + ctx->last_intervention_status = intervention.status; + ctx->last_intervention_log.len = 0; + ctx->last_intervention_log.data = NULL; mcf = ngx_http_get_module_loc_conf(r, ngx_http_modsecurity_module); if (mcf == NULL) { return NGX_HTTP_INTERNAL_SERVER_ERROR; } + if (mcf->phase4_log_file != NULL && r->header_sent && intervention.log != NULL) { + size_t l = ngx_strlen(intervention.log); + u_char *cp = ngx_pnalloc(r->pool, l + 1); + if (cp != NULL) { + ngx_memcpy(cp, intervention.log, l); + cp[l] = '\0'; + ctx->last_intervention_log.data = cp; + ctx->last_intervention_log.len = l; + } + } + // logging to nginx error log can be disable by setting `modsecurity_use_error_log` to off if (mcf->use_error_log) { log = intervention.log; @@ -479,6 +498,189 @@ char *ngx_conf_set_transaction_id(ngx_conf_t *cf, ngx_command_t *cmd, void *conf return NGX_CONF_OK; } +static char * +ngx_conf_set_phase4_mode(ngx_conf_t *cf, ngx_command_t *cmd, void *conf) +{ + ngx_http_modsecurity_conf_t *mcf = conf; + ngx_str_t *value = cf->args->elts; + if (ngx_strcmp(value[1].data, "minimal") == 0) mcf->phase4_mode = NGX_HTTP_MODSEC_PHASE4_MODE_MINIMAL; + else if (ngx_strcmp(value[1].data, "safe") == 0) mcf->phase4_mode = NGX_HTTP_MODSEC_PHASE4_MODE_SAFE; + else if (ngx_strcmp(value[1].data, "strict") == 0) mcf->phase4_mode = NGX_HTTP_MODSEC_PHASE4_MODE_STRICT; + else return "invalid value for modsecurity_phase4_mode (expected minimal|safe|strict)"; + return NGX_CONF_OK; +} + +static char * +ngx_conf_set_phase4_content_types_file(ngx_conf_t *cf, ngx_command_t *cmd, void *conf) +{ + ngx_http_modsecurity_conf_t *mcf = conf; + ngx_str_t *value = cf->args->elts; + mcf->phase4_content_types_file = value[1]; + return ngx_http_modsecurity_phase4_load_content_types_file(cf, mcf, &value[1]); +} + +static char * +ngx_conf_set_phase4_log(ngx_conf_t *cf, ngx_command_t *cmd, void *conf) +{ + ngx_http_modsecurity_conf_t *mcf = conf; + ngx_str_t *value = cf->args->elts; + mcf->phase4_log_path = value[1]; + mcf->phase4_log_file = ngx_conf_open_file(cf->cycle, &mcf->phase4_log_path); + if (mcf->phase4_log_file == NULL) { + return NGX_CONF_ERROR; + } + return NGX_CONF_OK; +} + +static ngx_int_t +ngx_http_modsecurity_phase4_validate_content_type(u_char *s, size_t len) +{ + size_t i; + size_t slash = (size_t)-1; + if (len == 0 || ngx_strchr(s, '*') != NULL) return NGX_ERROR; + for (i = 0; i < len; i++) { + u_char c = s[i]; + if (c == '/') { + if (slash != (size_t)-1 || i == 0 || i + 1 >= len) return NGX_ERROR; + slash = i; + continue; + } + if (!(isalnum((unsigned char)c) || c == '-' || c == '.' || c == '_' || c == '+')) return NGX_ERROR; + } + return (slash != (size_t)-1) ? NGX_OK : NGX_ERROR; +} + +static char * +ngx_http_modsecurity_phase4_set_default_content_types(ngx_conf_t *cf, ngx_http_modsecurity_conf_t *mcf) +{ + static const char *defs[] = {"text/html","text/plain","application/json","application/xml","text/xml","application/xhtml+xml"}; + ngx_uint_t i; + if (mcf->phase4_content_types != NULL) return NGX_CONF_OK; + mcf->phase4_content_types = ngx_array_create(cf->pool, 6, sizeof(ngx_str_t)); + if (mcf->phase4_content_types == NULL) return NGX_CONF_ERROR; + for (i = 0; i < 6; i++) { + ngx_str_t *ct = ngx_array_push(mcf->phase4_content_types); + if (ct == NULL) return NGX_CONF_ERROR; + ct->len = ngx_strlen(defs[i]); + ct->data = ngx_pnalloc(cf->pool, ct->len); + if (ct->data == NULL) return NGX_CONF_ERROR; + ngx_memcpy(ct->data, defs[i], ct->len); + } + return NGX_CONF_OK; +} + +static ngx_int_t +ngx_http_modsecurity_phase4_push_content_type(ngx_conf_t *cf, ngx_http_modsecurity_conf_t *mcf, + u_char *line, u_char *end, ngx_str_t *path) +{ + ngx_str_t *ct; + + ngx_strlow(line, line, end - line); + if (ngx_http_modsecurity_phase4_validate_content_type(line, end - line) != NGX_OK) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "invalid content-type entry in modsecurity_phase4_content_types_file \"%V\": \"%s\"", path, line); + return NGX_ERROR; + } + + ct = ngx_array_push(mcf->phase4_content_types); + if (ct == NULL) { + return NGX_ERROR; + } + ct->len = end - line; + ct->data = ngx_pnalloc(cf->pool, ct->len); + if (ct->data == NULL) { + return NGX_ERROR; + } + ngx_memcpy(ct->data, line, ct->len); + + return NGX_OK; +} + +static void +ngx_http_modsecurity_phase4_trim_line(u_char **line, u_char **end) +{ + while (*line < *end && isspace((unsigned char)**line)) { + (*line)++; + } + + while (*end > *line && isspace((unsigned char)*((*end) - 1))) { + (*end)--; + } + + **end = '\0'; +} + +static void +ngx_http_modsecurity_phase4_strip_inline_comment(u_char *line) +{ + u_char *hash = (u_char *) ngx_strchr(line, '#'); + u_char *semi = (u_char *) ngx_strchr(line, ';'); + + if (hash && (!semi || hash < semi)) { + *hash = '\0'; + } + if (semi) { + *semi = '\0'; + } +} + +static char * +ngx_http_modsecurity_phase4_load_content_types_file(ngx_conf_t *cf, ngx_http_modsecurity_conf_t *mcf, ngx_str_t *path) +{ + ngx_file_t file; + ngx_file_info_t fi; + u_char *buf; + u_char *p; + u_char *line; + u_char *end; + ssize_t n; + if (ngx_file_info(path->data, &fi) == NGX_FILE_ERROR) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, ngx_errno, "modsecurity_phase4_content_types_file \"%V\" stat() failed", path); + return NGX_CONF_ERROR; + } + buf = ngx_pnalloc(cf->pool, ngx_file_size(&fi) + 1); + if (buf == NULL) return NGX_CONF_ERROR; + ngx_memzero(&file, sizeof(file)); + file.name = *path; + file.log = cf->log; + file.fd = ngx_open_file(path->data, NGX_FILE_RDONLY, NGX_FILE_OPEN, 0); + if (file.fd == NGX_INVALID_FILE) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, ngx_errno, "modsecurity_phase4_content_types_file \"%V\" open() failed", path); + return NGX_CONF_ERROR; + } + n = ngx_read_file(&file, buf, ngx_file_size(&fi), 0); + ngx_close_file(file.fd); + if (n < 0) return NGX_CONF_ERROR; + buf[n] = '\0'; + mcf->phase4_content_types = ngx_array_create(cf->pool, 8, sizeof(ngx_str_t)); + if (mcf->phase4_content_types == NULL) return NGX_CONF_ERROR; + for (p = buf, line = buf; p <= buf + n; p++) { + if (p == buf + n || *p == '\n' || *p == '\r') { + *p = '\0'; + end = p; + + ngx_http_modsecurity_phase4_trim_line(&line, &end); + if (line[0] == '\0' || line[0] == '#') { + line = p + 1; + continue; + } + + ngx_http_modsecurity_phase4_strip_inline_comment(line); + end = line + ngx_strlen(line); + ngx_http_modsecurity_phase4_trim_line(&line, &end); + if (line[0] == '\0') { + line = p + 1; + continue; + } + + if (ngx_http_modsecurity_phase4_push_content_type(cf, mcf, line, end, path) != NGX_OK) { + return NGX_CONF_ERROR; + } + line = p + 1; + } + } + return NGX_CONF_OK; +} + static ngx_command_t ngx_http_modsecurity_commands[] = { { @@ -521,6 +723,30 @@ static ngx_command_t ngx_http_modsecurity_commands[] = { 0, NULL }, + { + ngx_string("modsecurity_phase4_mode"), + NGX_HTTP_LOC_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_MAIN_CONF|NGX_CONF_TAKE1, + ngx_conf_set_phase4_mode, + NGX_HTTP_LOC_CONF_OFFSET, + 0, + NULL + }, + { + ngx_string("modsecurity_phase4_content_types_file"), + NGX_HTTP_LOC_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_MAIN_CONF|NGX_CONF_TAKE1, + ngx_conf_set_phase4_content_types_file, + NGX_HTTP_LOC_CONF_OFFSET, + 0, + NULL + }, + { + ngx_string("modsecurity_phase4_log"), + NGX_HTTP_LOC_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_MAIN_CONF|NGX_CONF_TAKE1, + ngx_conf_set_phase4_log, + NGX_HTTP_LOC_CONF_OFFSET, + 0, + NULL + }, { ngx_string("modsecurity_use_error_log"), NGX_HTTP_LOC_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_MAIN_CONF|NGX_CONF_FLAG, @@ -724,6 +950,13 @@ ngx_http_modsecurity_create_conf(ngx_conf_t *cf) conf->pool = cf->pool; conf->transaction_id = NGX_CONF_UNSET_PTR; conf->use_error_log = NGX_CONF_UNSET; + conf->phase4_mode = NGX_CONF_UNSET_UINT; + conf->phase4_content_types = NULL; + conf->phase4_content_types_file.len = 0; + conf->phase4_content_types_file.data = NULL; + conf->phase4_log_file = NULL; + conf->phase4_log_path.len = 0; + conf->phase4_log_path.data = NULL; #if defined(MODSECURITY_SANITY_CHECKS) && (MODSECURITY_SANITY_CHECKS) conf->sanity_checks_enabled = NGX_CONF_UNSET; #endif @@ -764,6 +997,9 @@ ngx_http_modsecurity_merge_conf(ngx_conf_t *cf, void *parent, void *child) ngx_conf_merge_value(c->enable, p->enable, 0); ngx_conf_merge_ptr_value(c->transaction_id, p->transaction_id, NULL); ngx_conf_merge_value(c->use_error_log, p->use_error_log, 1); + ngx_conf_merge_uint_value(c->phase4_mode, p->phase4_mode, NGX_HTTP_MODSEC_PHASE4_MODE_SAFE); + ngx_conf_merge_ptr_value(c->phase4_log_file, p->phase4_log_file, NULL); + ngx_conf_merge_ptr_value(c->phase4_content_types, p->phase4_content_types, NULL); #if defined(MODSECURITY_SANITY_CHECKS) && (MODSECURITY_SANITY_CHECKS) ngx_conf_merge_value(c->sanity_checks_enabled, p->sanity_checks_enabled, 0); #endif @@ -784,6 +1020,9 @@ ngx_http_modsecurity_merge_conf(ngx_conf_t *cf, void *parent, void *child) dd("NEW CHILD RULES"); msc_rules_dump(c->rules_set); #endif + if (c->phase4_content_types == NULL) { + return ngx_http_modsecurity_phase4_set_default_content_types(cf, c); + } return NGX_CONF_OK; } diff --git a/tests/modsecurity-h2.t b/tests/modsecurity-h2.t index 981d103d..ce3853bd 100644 --- a/tests/modsecurity-h2.t +++ b/tests/modsecurity-h2.t @@ -83,6 +83,7 @@ http { } location /phase4 { modsecurity on; + modsecurity_phase4_mode strict; modsecurity_rules ' SecRuleEngine On SecResponseBodyAccess On diff --git a/tests/modsecurity-phase4-content-types.t b/tests/modsecurity-phase4-content-types.t new file mode 100644 index 00000000..412e9fea --- /dev/null +++ b/tests/modsecurity-phase4-content-types.t @@ -0,0 +1,93 @@ +#!/usr/bin/perl +use warnings; use strict; +use Test::More; +BEGIN { use FindBin; chdir($FindBin::Bin); } +use lib 'lib'; +use Test::Nginx; + +# content-type parsing and scope behavior +my $t = Test::Nginx->new()->has(qw/http/); + +$t->write_file('phase4-content-types.conf', <<'CT'); +# comments and whitespace + +Application/JSON; charset=utf-8 # inline comment +TEXT/HTML +text/plain +application/xml +application/vnd.api+json +application/problem+json +application/x.custom_type +CT + +$t->write_file_expand('nginx.conf', <<'EOC'); +%%TEST_GLOBALS%% +daemon off; +events {} +http { + %%TEST_GLOBALS_HTTP%% + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /json { + modsecurity on; + modsecurity_phase4_mode strict; + modsecurity_phase4_log %%TESTDIR%%/phase4-content-types.log; + modsecurity_phase4_content_types_file %%TESTDIR%%/phase4-content-types.conf; + default_type application/json; + modsecurity_rules ' + SecRuleEngine On + SecResponseBodyAccess On + SecRule RESPONSE_BODY "@rx HIT" "id:920001,phase:4,deny,log,status:403,msg:\"ct\"" + '; + } + + location /unknown { + modsecurity on; + modsecurity_phase4_mode strict; + modsecurity_phase4_log %%TESTDIR%%/phase4-content-types.log; + modsecurity_phase4_content_types_file %%TESTDIR%%/phase4-content-types.conf; + default_type image/png; + modsecurity_rules ' + SecRuleEngine On + SecResponseBodyAccess On + SecRule RESPONSE_BODY "@rx HIT" "id:920002,phase:4,deny,log,status:403,msg:\"ct\"" + '; + } + + location /emptytype { + modsecurity on; + modsecurity_phase4_mode strict; + modsecurity_phase4_log %%TESTDIR%%/phase4-content-types.log; + modsecurity_phase4_content_types_file %%TESTDIR%%/phase4-content-types.conf; + types { } + default_type ""; + modsecurity_rules ' + SecRuleEngine On + SecResponseBodyAccess On + SecRule RESPONSE_BODY "@rx HIT" "id:920003,phase:4,deny,log,status:403,msg:\"ct\"" + '; + } + } +} +EOC + +$t->write_file('/json', 'HIT JSON'); +$t->write_file('/unknown', 'HIT UNKNOWN'); +$t->write_file('/emptytype', 'HIT EMPTY'); + +$t->run(); +$t->plan(9); + +is(http_get('/json'), '', 'json in-scope + strict => abort after headers sent'); +like(http_get('/unknown'), qr/HIT UNKNOWN/, 'unknown content-type not in scope => no hard action'); +like(http_get('/emptytype'), qr/HIT EMPTY/, 'empty content-type => no hard action'); + +my $log = $t->read_file('phase4-content-types.log'); +like($log, qr/"content_type":"application\/json"/, 'json content type logged'); +like($log, qr/"actual_action":"connection_abort"/, 'strict in-scope abort logged'); +like($log, qr/"reason":"content_type_not_in_scope"/, 'out-of-scope reason logged'); +like($log, qr/"event":"phase4_intervention"/, 'json lines event present'); +unlike($log, qr/HIT JSON|HIT UNKNOWN|HIT EMPTY/, 'no response body data in log'); +like($log, qr/"content_type":"application\/json"/, 'application/json remains valid'); diff --git a/tests/modsecurity-phase4-invalid-config.t b/tests/modsecurity-phase4-invalid-config.t new file mode 100644 index 00000000..cc695551 --- /dev/null +++ b/tests/modsecurity-phase4-invalid-config.t @@ -0,0 +1,38 @@ +#!/usr/bin/perl +use warnings; use strict; +use Test::More tests => 1; +BEGIN { use FindBin; chdir($FindBin::Bin); } +use lib 'lib'; +use Test::Nginx; + +my $t = Test::Nginx->new()->has(qw/http/); +$t->write_file('phase4-invalid.conf', "text/*\n"); +mkdir($t->testdir() . '/logs') unless -d $t->testdir() . '/logs'; +$t->write_file('logs/error.log', ''); + +$t->write_file_expand('nginx.conf', <<'EOF'); +%%TEST_GLOBALS%% + +events {} + +http { + %%TEST_GLOBALS_HTTP%% + + server { + listen 127.0.0.1:19849; + server_name localhost; + location / { + modsecurity on; + modsecurity_phase4_content_types_file %%TESTDIR%%/phase4-invalid.conf; + return 200 "ok\n"; + } + } +} +EOF + +my $cmd = $ENV{TEST_NGINX_BINARY} || ($t->testdir() . '/../nginx'); +$cmd .= " -p " . $t->testdir() . "/ -c nginx.conf -t 2>&1"; +my $out = `$cmd`; + +like($out, qr/invalid content-type entry in modsecurity_phase4_content_types_file/, + 'error points to invalid content-type entry'); diff --git a/tests/modsecurity-phase4-modes.t b/tests/modsecurity-phase4-modes.t new file mode 100644 index 00000000..99c2988c --- /dev/null +++ b/tests/modsecurity-phase4-modes.t @@ -0,0 +1,83 @@ +#!/usr/bin/perl +use warnings; use strict; +use Test::More; +BEGIN { use FindBin; chdir($FindBin::Bin); } +use lib 'lib'; +use Test::Nginx; + +my $t = Test::Nginx->new()->has(qw/http/); +$t->write_file('phase4-content-types.conf', "# comment\n\nApplication/JSON; charset=utf-8 # inline\ntext/html\n"); + +$t->write_file_expand('nginx.conf', <<'EOC'); +%%TEST_GLOBALS%% +daemon off; +events {} +http { + %%TEST_GLOBALS_HTTP%% + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /m { + modsecurity on; + modsecurity_phase4_mode minimal; + modsecurity_phase4_log %%TESTDIR%%/phase4.log; + modsecurity_phase4_content_types_file %%TESTDIR%%/phase4-content-types.conf; + modsecurity_rules ' + SecRuleEngine On + SecResponseBodyAccess On + SecRule RESPONSE_BODY "@rx Hello" "id:910001,phase:4,deny,log,status:403,msg:\"x\\nq\"" + '; + } + + location /s { + modsecurity on; + modsecurity_phase4_mode safe; + modsecurity_phase4_log %%TESTDIR%%/phase4.log; + modsecurity_rules ' + SecRuleEngine On + SecResponseBodyAccess On + SecRule RESPONSE_BODY "@rx Hello" "id:910002,phase:4,deny,log,status:403" + '; + } + + location /x { + modsecurity on; + modsecurity_phase4_mode strict; + modsecurity_phase4_log %%TESTDIR%%/phase4.log; + modsecurity_rules ' + SecRuleEngine On + SecResponseBodyAccess On + SecRule RESPONSE_BODY "@rx Hello" "id:910003,phase:4,deny,log,status:403" + '; + } + } +} +EOC + +$t->write_file('/m', 'Hello minimal'); +$t->write_file('/s', 'Hello safe'); +$t->write_file('/x', 'Hello strict'); +$t->run(); +$t->plan(11); + +like(http_get('/m'), qr/Hello minimal/, 'minimal no fake deny'); +like(http_get('/s'), qr/Hello safe/, 'safe no fake deny'); +is(http_get('/x'), '', 'strict abort after headers sent'); + +my $log = $t->read_file('phase4.log'); +like($log, qr/"actual_action":"log_only"/, 'log_only present'); +like($log, qr/"reason":"mode_safe"/, 'safe reason present'); +like($log, qr/"actual_action":"connection_abort"/, 'strict action logged'); +like($log, qr/"event":"phase4_intervention"/, 'event field present'); +like($log, qr/"header_sent":true/, 'json boolean header_sent'); +unlike($log, qr/Hello minimal|Hello safe|Hello strict/, 'no response body data in phase4 log'); + +my @lines = grep { length $_ } split /\n/, $log; +ok(@lines >= 1, 'phase4 log has one or more json lines'); +my @bad_lines = grep { $_ !~ /^\{.*\}\r?$/ } @lines; +note('phase4.log lines=' . scalar(@lines)); +if (@bad_lines) { + diag('non-json-lines: ' . join(' || ', map { my $x = $_; $x =~ s/\r/\\r/g; $x } @bad_lines)); +} +ok(scalar(@bad_lines) == 0, 'each log line is a single JSON object line'); diff --git a/tests/modsecurity-phase4-regression.t b/tests/modsecurity-phase4-regression.t new file mode 100644 index 00000000..e68c75a9 --- /dev/null +++ b/tests/modsecurity-phase4-regression.t @@ -0,0 +1,46 @@ +#!/usr/bin/perl +use warnings; use strict; +use Test::More; +BEGIN { use FindBin; chdir($FindBin::Bin); } +use lib 'lib'; +use Test::Nginx; + +my $t = Test::Nginx->new()->has(qw/http/); +my $big = ('A' x 70000) . 'TAIL'; + +$t->write_file_expand('nginx.conf', <<'EOC'); +%%TEST_GLOBALS%% +daemon off; +events {} +http { + %%TEST_GLOBALS_HTTP%% + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /big { + modsecurity on; + modsecurity_phase4_mode minimal; + modsecurity_phase4_log %%TESTDIR%%/phase4-regression.log; + modsecurity_rules ' + SecRuleEngine On + SecResponseBodyAccess On + SecRule RESPONSE_BODY "@rx TAIL" "id:930001,phase:4,deny,log,status:403,msg:\"reg\"" + '; + } + } +} +EOC + +$t->write_file('/big', $big); +$t->run(); +$t->plan(5); + +my $resp = http_get('/big'); +like($resp, qr/HTTP\/1\.1 200 OK/, 'big response status remains 200 in minimal mode'); +like($resp, qr/A{1024}/, 'big response body contains expected prefix chunk'); +like($resp, qr/TAIL/, 'big response body tail present (no truncation)'); + +my $log = $t->read_file('phase4-regression.log'); +like($log, qr/"actual_action":"log_only"/, 'minimal mode logs only'); +unlike($log, qr/A{100,}|TAIL/, 'no large response data leaked to phase4 log'); diff --git a/tests/modsecurity-proxy-h2.t b/tests/modsecurity-proxy-h2.t index e8ef0ad2..df0e43ba 100644 --- a/tests/modsecurity-proxy-h2.t +++ b/tests/modsecurity-proxy-h2.t @@ -86,6 +86,7 @@ http { } location /phase4 { modsecurity on; + modsecurity_phase4_mode strict; modsecurity_rules ' SecRuleEngine On SecResponseBodyAccess On @@ -270,6 +271,15 @@ EOF select undef, undef, undef, 0.1; print $client 'AND-THIS'; + } elsif ($uri =~ m{^/phase4\?what=(redirect301|redirect302|block401|block403)$}) { + print $client <<"EOF"; +HTTP/1.1 200 OK +Content-Type: text/html +Connection: close + +phase4 trigger +EOF + } else { print $client <<"EOF"; diff --git a/tests/modsecurity-proxy.t b/tests/modsecurity-proxy.t index a412f5ea..a742029f 100644 --- a/tests/modsecurity-proxy.t +++ b/tests/modsecurity-proxy.t @@ -84,6 +84,7 @@ http { } location /phase4 { modsecurity on; + modsecurity_phase4_mode strict; modsecurity_rules ' SecRuleEngine On SecResponseBodyAccess On @@ -191,6 +192,15 @@ EOF select undef, undef, undef, 0.1; print $client 'AND-THIS'; + } elsif ($uri =~ m{^/phase4\?what=(redirect301|redirect302|block401|block403)$}) { + print $client <<"EOF"; +HTTP/1.1 200 OK +Content-Type: text/html +Connection: close + +phase4 trigger +EOF + } else { print $client <<"EOF"; diff --git a/tests/modsecurity.t b/tests/modsecurity.t index fcb26f4f..ff31c229 100644 --- a/tests/modsecurity.t +++ b/tests/modsecurity.t @@ -95,6 +95,7 @@ http { } location /phase4 { modsecurity on; + modsecurity_phase4_mode strict; modsecurity_rules ' SecRuleEngine On SecResponseBodyAccess On