Przejdź do treści

WAF na brzegu (ModSecurity + OWASP CRS)

Webserver BPP nie jest gołym nginksem — to obraz owasp/modsecurity-crs:nginx, czyli ten sam oficjalny nginx plus moduł ModSecurity i reguły OWASP Core Rule Set. Zapytania są sprawdzane pod kątem znanych klas ataków, zanim dotkną Django.

WAF BLOKUJE — rozpoznany atak dostaje zerwane połączenie

MODSEC_RULE_ENGINE=On. Żądanie z rozpoznanym atakiem dostaje od ModSecurity 403, które nginx zamienia na 444: zamknięcie połączenia bez żadnej odpowiedzi. Skaner nie dostaje ani kodu, ani strony błędu, ani nagłówków.

Dwie ścieżki są z blokowania wyjęte i tylko obserwowane — /admin/dbtemplates/ i /api/v1/zapytanie/ (patrz Wykluczenia reguł).

Awaryjne zejście do obserwacji — w repo-owym .env (tym obok docker-compose.yml, nie w ${BPP_CONFIGS_DIR}/.env — patrz .env.sample):

MODSEC_RULE_ENGINE=DetectionOnly
i make run.

Po co to jest

Przegląd błędów z lipca 2026 pokazał zautomatyzowany skan SQL injection (sqlmap) przeciwko jednej z instalacji: 2 165 żądań z payloadami UNION ALL SELECT i UPDATEXML(…). Wszystkie odbiły się o routing Django (HTTP 404) i nic nie wyciekło, ale:

  • skan przyszedł z 1 342 adresów IP, z czego 1 340 użyto dokładnie raz — więc limit_req (per-IP) nie miał czego złapać,
  • User-Agenty były rotowane na przeglądarkowe, więc filtr po UA też by nie zadziałał.

Jedyną skuteczną dźwignią przy takim rozkładzie jest filtrowanie po treści żądania — czyli dokładnie to, co robi WAF.

Konfiguracja

Wszystko przez zmienne w .env (Compose ma sensowne domyślne, więc świeża instalacja nie wymaga żadnych zmian).

Zmienna Domyślnie u nas Domyślnie w obrazie Po co
MODSEC_RULE_ENGINE On On blokowanie włączone
BLOCKING_PARANOIA 1 1 poziom agresywności reguł
MODSEC_REQ_BODY_LIMIT 132120576 (126 MiB) 13107200 (12,5 MiB) musi być ≥ client_max_body_size 120M
MODSEC_REQ_BODY_NOFILES_LIMIT 4194304 (4 MiB) 131072 (128 KiB) duże formularze BPP
MODSEC_AUDIT_LOG_PARTS AHZ ABIJDEFHZ bez ciał i bez nagłówków w logu
MODSEC_AUDIT_LOG_RELEVANT_STATUS ^$ (nigdy) ^(?:5\|4(?!04)) audit log wyłącznie z trafień reguł
ALLOWED_HTTP_VERSIONS + HTTP/3 HTTP/3.0 HTTP/1.0 HTTP/1.1 HTTP/2 HTTP/2.0 bez tego h3 jest blokowane w całości

Nie usuwaj HTTP/3 z ALLOWED_HTTP_VERSIONS

Reguła 920430 („HTTP protocol version is not allowed by policy") ma severity CRITICAL = 5 punktów, czyli dokładnie próg blokowania — jedno trafienie wystarczy, żeby zerwać połączenie. Domyślna lista CRS nie zna HTTP/3, a my h3 włączamy świadomie (listen 443 quic + Alt-Svc), więc każde żądanie h3 dostawałoby 444.

Objaw jest wyjątkowo paskudny: przeglądarka raz przełączona na h3 trzyma się go przez dobę (Alt-Svc: ma=86400), więc to pełna niedostępność serwisu bez żadnego komunikatu. Deklarujemy wersję zamiast wyłączać regułę — polityka wersji protokołu zostaje egzekwowana.

Obraz CRS to tag pływający

owasp/modsecurity-crs:nginx nie jest przypięty do wersji. Przebudowa z 2026-08-05 zaczęła egzekwować 920430 i zablokowała h3 — złapał to dopiero make test-waf w CI, dzień po tym, jak ten sam test przechodził. Po każdej aktualizacji obrazu warto puścić make test-waf.

Nie przywracaj domyślnych MODSEC_AUDIT_LOG_PARTS

Domyślne ABIJDEFHZ zawiera I (ciało żądania) i E (ciało odpowiedzi, do 1 MiB). Przy nich każda oflagowana transakcja zapisuje do stdout — a stamtąd przez Alloy do Loki — pełną treść strony, a oflagowany POST na formularz logowania zapisałby przesłane hasło. To nie jest tylko kwestia objętości logów, tylko danych osobowych.

Nie wystarczy usunąć ciał. Część B to nagłówki żądania, a w nich Cookie z sessionid — czyli poświadczenie. Kto ma dostęp do Loki, mógłby przejąć sesję zalogowanego użytkownika. Potwierdzone na stagingu 2026-08-03.

AHZ = nagłówek transakcji (w tym linia żądania z URI, czyli payload) + komunikaty reguł + domknięcie. Do diagnostyki komplet. User-Agent nie ginie — jest w access logu nginksa (bpp_access), do skorelowania po czasie i adresie IP.

Inspekcja ciał nadal działa (MODSEC_REQ_BODY_ACCESS=on) — wyłączamy tylko ich logowanie, więc ataki w POST są dalej wykrywane.

Nie obniżaj limitów ciała żądania

Domyślne wartości ModSecurity są znacznie niższe niż to, co przepuszcza nginx. Przy domyślnych: importy POLON/PBN powyżej 12,5 MiB dostają 413, a masowa edycja (BPP dopuszcza DATA_UPLOAD_MAX_NUMBER_FIELDS=50000) przekracza 128 KiB i też leci 413. Objaw dla użytkownika jest mylący — „import nie działa" — i pojawia się dopiero przy dużym pliku, czyli długo po wdrożeniu.

Dwie pułapki integracyjne (przeczytaj przed zmianą configu nginksa)

Obraz CRS różni się od gołego nginksa w dwóch miejscach, które łatwo przeoczyć.

1. Inny katalog wyjściowy envsubst

Obraz ustawia NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx (goły nginx ma /etc/nginx/conf.d), a jego szablony odwzorowują całe drzewo /etc/nginx.

Dlatego default.conf.template montujemy pod /etc/nginx/templates/conf.d/default.conf.template, a nie prosto w templates/. Mount w starym miejscu wyrenderowałby się do /etc/nginx/default.conf, którego include /etc/nginx/conf.d/*.conf nie obejmuje — konfiguracja BPP zniknęłaby, a nginx wstałby bez błędu. To awaria cicha: nginx -t jej nie wykryje.

Nasz plik celowo nadpisuje default.conf z obrazu. Wersja z obrazu to generyczny reverse-proxy z listen … default_server i własną mapą $connection_upgrade — jedno i drugie kolidowałoby z konfiguracją BPP. Nadpisanie nie wyłącza WAF-a: modsecurity on; siedzi w osobnym conf.d/modsecurity.conf.

2. Brak NGINX_ENVSUBST_FILTER

Wcześniej Compose ustawiał NGINX_ENVSUBST_FILTER: "DJANGO_BPP_". Tego już nie ma i nie wolno tego przywracać. Szablony obrazu CRS używają 75 zmiennych (WORKER_CONNECTIONS, LOGLEVEL, MODSEC_*, SSL_*…); z filtrem zostałyby dosłowne i nginx nie wstałby w ogóle (invalid number "${WORKER_CONNECTIONS}").

Filtr chronił konfigurację BPP przed nadgorliwym podstawianiem — ale default.conf.template nie używa żadnych zmiennych środowiskowych (nginksowe $http_upgrade i $connection_upgrade to zmienne runtime, których envsubst i tak nie rusza), więc stracił rację bytu. Vhosty renderuje 30-render-bpp-vhosts.sh własnym envsubst z jawną listą zmiennych, niezależnie od tego mechanizmu.

Wykluczenia reguł

Własne naddefinicje idą do defaults/webserver/modsecurity-override.conf.template (montowany jako /etc/nginx/templates/modsecurity.d/modsecurity-override.conf.template). setup.conf obrazu includuje ten plik przed regułami CRS — co ma praktyczną konsekwencję: SecRuleRemoveById tam nie zadziała, bo reguły CRS jeszcze nie istnieją w momencie parsowania. Działa natomiast akcja ctl we własnej regule, bo wykonuje się w trakcie transakcji.

Zakres ID 1-99999 jest zarezerwowany dla reguł lokalnych (CRS używa 900000-999999).

Obecnie jest ich dwanaście:

  • id:10001 — healthcheck poza audytem. Healthcheck Dockera (curl http://127.0.0.1:80/healthz co 10 s) zapalał regułę 920350 („Host header is a numeric IP address"), bo w nagłówku Host jest numeryczny adres. To ~8 640 wpisów audytowych na dobę, które topiły realne trafienia. Wyciszamy logowanie dla tej jednej ścieżki (ctl:auditEngine=Off), a nie regułę globalnie — na ruchu z zewnątrz 920350 jest sensowna, bo skanery wołają po IP, nie po nazwie.

  • id:10002 — /admin/dbtemplates/ tylko obserwowane. Superuser edytuje tam szablony HTML, czyli POST-uje surowy HTML z JavaScriptem — kanoniczny fałszywy alarm rodziny 941 (XSS), opisany wprost w dokumentacji CRS.

  • id:10003 — /bpp/zapytanie/ i /api/v1/zapytanie/ tylko obserwowane. Użytkownik wpisuje tam dowolny tekst zapytania DjangoQL, więc zapalić się może praktycznie każda rodzina reguł — nie tylko 942 (SQL). Potwierdzone na stagingu 2026-08-03: query=test = 5/etc/passwd zapaliło 930120 (LFI), 932235 i 932160 (RCE), łączny score 15 przy progu 5 → blokada.

    Są dwa endpointy i łatwo wykluczyć tylko jeden: /bpp/zapytanie/ to interfejs użytkownika (bpp/src/bpp/urls.py:113), /api/v1/zapytanie/ to wariant API. Ten pierwszy jest ważniejszy — to w nim ludzie realnie piszą zapytania.

  • id:10004 — panele administracyjne bez inspekcji ciała odpowiedzi. /grafana/, /dozzle/, /flower/ i /netdata/ dostają ctl:ruleRemoveById=950000-959999, czyli zdjęcie całej rodziny reguł wychodzących. Szczegóły i uzasadnienie: Reguły wychodzące.

  • id:10005 — 920280 („Request Missing a Host Header") wyłączone dla HTTP/2 i HTTP/3. W h2/h3 adres serwera jedzie w pseudo-nagłówku :authority, którego konektor ModSecurity-nginx (v1.0.4) nie widzi. Reguła (severity CRITICAL = 5 pkt) zapalała się więc na każdym żądaniu h3 i sama z siebie osiągała próg anomalii → 949110 blokowało wszystko, także zwykłe GET /. Ponieważ vhost reklamuje Alt-Svc: h3=":443"; ma=86400, przeglądarka raz przełączona na h3 trzymała się go przez dobę — czyli pełna niedostępność serwisu bez żadnego komunikatu. Nic przy tym nie tracimy: nginx sam odrzuca błędem 400 żądanie h2/h3 bez :authority (i 1.1 bez Host:), zanim dojdzie do aplikacji.

  • id:10006 — /grafana/ całkowicie poza inspekcją WAF-a (ctl:ruleEngine=Off). Jedyne miejsce w BPP, w którym WAF nie ma czego chronić, za to ma gwarantowane fałszywe alarmy. Szczegóły: Dlaczego Grafana jest wyjęta w całości.

  • id:10007 — 931100 („RFI: URL Parameter using IP Address") i 934110 (SSRF, od CRS 4.29) wyłączone na /o/ i /.well-known/. Reguła blokowała redirect_uri=http://127.0.0.1:<port>/callback, czyli pętlę zwrotną, na której odbiera kod każdy klient OAuth bez własnej domeny — CLI, skrypt, serwer MCP. Oba prefiksy to jedna powierzchnia logowania i trzymamy je razem: klient najpierw pyta o metadane w /.well-known/, a dopiero z nich bierze adresy endpointów w /o/. Szczegóły: OAuth i pętla zwrotna.

  • id:10008 — accept-charset zdjęty z listy nagłówków zakazanych (920450). Nagłówek jest wycofany ze standardu, więc CRS traktuje go jako fingerprint klienta automatycznego — ale wysyłają go też starsze biblioteki HTTP i harvestery bibliograficzne. Zgłoszenie 2026-08-07: blokada na /bpp/rekord/, czyli na poprawnym adresie istniejącego rekordu. Szczegóły: Nagłówki zakazane polityką.

  • id:10009 — formularze admina (^/admin/<app>/<model>/) w całości w DetectionOnly. Streszczenie naukowe to kilka tysięcy znaków wolnego tekstu gęstego od średników, nawiasów i akronimów, więc CRS na PL1 zapala tam regułę po regule: 932130 (2026-08-12), potem 932115, 933210 i 930110 (2026-08-23). Formularz logowania nie jest objęty — ma po /admin/ tylko jeden segment. Szczegóły, pomiar i cena: Formularze admina.

  • id:10010 — 953110 („PHP source code leakage") zdjęta GLOBALNIE. Jedyne wykluczenie bez warunku na ścieżkę, bo wyzwalaczem jest wartość w bazie, nie adres: DOI czasopisma 4open to 10.1051/fopen/, a fopen jest w nim pełnoprawnym tokenem (ukośniki to znaki niesłowne). Zgłoszenie 2026-08-12: /api/v1/zrodlo/ zwracało uciętą odpowiedź. W BPP nie ma PHP, więc reguła nie ma tu czego chronić. Szczegóły: DOI z fopen.

  • id:10011 — tytuł raportu multiseek bez reguł, które nie mają tu sinka. Pola suggested-title (/bpp/build_search/) i value (/bpp/update-multiseek-title/) nie są skanowane rodzinami 930/931/932/933/942, bo tytuł trafia wyłącznie do sesji i do HTML-a. XSS (941) zostaje. Zgłoszenie 2026-09-13: „Szukaj publikacji" na stronie jednostki … [09.2024...] (WBIOTECH) → 933210 → zerwane połączenie. Szczegóły: Tytuł raportu multiseek.

  • id:10012 — ciasteczko PostHoga z dashboardu Netdaty poza CRS. Frontend Netdaty zapisuje na całą domenę ciasteczko ph_phc_…_posthog z JSON-em analitycznym; na CRS 4.25 klucz $initial_person_info zapalał 942290 i cała witryna przestawała działać dla tej przeglądarki na rok. Serwer tego ciasteczka nie czyta, więc zdejmujemy z niego cały CRS — i tylko z niego. Szczegóły: Ciasteczko PostHoga z Netdaty.

Reguły 10002 i 10003 schodzą dla swoich ścieżek do ctl:ruleEngine=DetectionOnly: trafienia nadal trafiają do audit logu (i posłużą do napisania precyzyjnych wykluczeń), ale nikomu nie urywają połączenia. To tępe narzędzie na start — patrz Zawężanie wykluczeń po baseline.

Reguła 10004 jest węższa: zdejmuje tylko reguły wychodzące, więc żądania przychodzące do paneli są nadal w pełni blokowane.

ctl:responseBodyAccess nie istnieje w libmodsecurity v3

Naturalnym zapisem dla 10004 byłoby ctl:responseBodyAccess=Off, ale silnik tej akcji nie zna i nginx z nią nie wstaje: Expecting an action, got: ctl:responseBodyAccess. Sprawdzone na owasp/modsecurity-crs:nginx (ModSecurity v3.0.16). Z akcji ctl działają m.in. ruleEngine, auditEngine, ruleRemoveById (także z zakresem A-B) i ruleRemoveByTag.

Reguły wychodzące — inspekcja ciała odpowiedzi

Poza regułami sprawdzającymi żądanie CRS ma rodzinę RESPONSE-95x, która skanuje ciało odpowiedzi w poszukiwaniu wycieków: komunikatów błędów PHP/SQL/Javy, listingów katalogów, web shelli. Obraz ma je włączone domyślnie (MODSEC_RESP_BODY_ACCESS=on, typy text/plain text/html text/xml, limit 1 MiB) i my tego nie zmieniamy — dlatego trzeba o nich wiedzieć.

Ta rodzina ma dwie własności, które łatwo przeoczyć:

1. Próg wyjściowy wynosi 4, a jedno trafienie poziomu ERROR to już 4 punkty. Zmienna ANOMALY_OUTBOUND=4 (wobec ANOMALY_INBOUND=5). Reguła o severity:ERROR dokłada dokładnie 4 — więc pojedyncze trafienie od razu przekracza próg i 959100 kończy deny. Reguły wychodzące nie mają zapasu, jaki mają wejściowe.

2. Blokowanie wychodzące nie blokuje czysto — psuje odpowiedź. 959100 działa w phase:4, czyli na ciele odpowiedzi, gdy nginx zwykle wysłał już nagłówki. Nie da się wtedy zwrócić 403, a więc i naszego 444. Zamiast blokady dostajemy:

"GET /grafana/ HTTP/2.0" 500 0 1378 0.038
[error] ModSecurity: Access denied with code 403 (phase 4) ... while sending to client
[alert] header already sent while sending to client

Użytkownik widzi urwaną albo pustą stronę, a w access logu zostaje 500 0. Objaw nie wskazuje na WAF w żaden sposób.

Dlaczego panele są z tego wyjęte (reguła 10004)

Potwierdzony przypadek ze stagingu, 2026-08-03: /grafana/ było nie do otwarcia. Winna reguła 953100 „PHP Information Leakage", która używa operatora @pmFromFile php-errors.data — dopasowania po podciągu, bez granic słowa i bez rozróżniania wielkości liter. Wtedy w tym pliku danych był wpis SQLConnect (funkcja ODBC z PHP), a Grafana wstrzykuje w index.html obiekt window.grafanaBootData z kluczem "sqlConnectionLimits". Ciąg sqlConnect siedzi w jego środku, więc reguła zapalała się na każdej stronie Grafany.

Ten konkretny ciąg już nie zapala reguły — i to jest argument ZA wykluczeniem, nie przeciw

Zmierzone ponownie 2026-08-12 na aktualnym obrazie: ciało z window.grafanaBootData i sqlConnectionLimits przechodzi bez żadnego trafienia. Wpisu SQLConnect nie ma już w php-errors.data (dziś 218 wpisów, wszystkie długie frazy). Sama reguła działa — kontrolne ciało z Cannot redeclare class / Stack trace: nadal daje 953100 + 959100.

Zmieniły się więc dane upstreamu, nie nasza konfiguracja: owasp/modsecurity-crs:nginx to tag pływający (patrz „Obraz CRS to tag pływający" w Konfiguracji), a wraz z przebudową obrazu przyjeżdżają nowe pliki *.data. Ten sam mechanizm zaskoczył nas już przy regule 920430 (2026-08-05).

Wniosek dla wykluczeń: nie zawężaj ich do jednego zaobserwowanego ciągu. Uzasadnieniem 10004 nie jest „SQLConnect trafia w sqlConnectionLimits", tylko to, że za panelami stoi cudzy dashboard, którego HTML-a nie ma sensu skanować — a jego kolejna wersja, tak jak kolejna wersja pliku danych CRS, może przynieść nowy fałszywy alarm.

Za /grafana/, /dozzle/, /flower/ i /netdata/ nie stoi żadna aplikacja BPP — stoi gotowy dashboard innego producenta, dostępny wyłącznie dla superusera (auth_request /_bpp_superuser_auth). Skanowanie jego HTML-a pod kątem wycieku błędów PHP nie chroni przed niczym.

Zmierzone na prawdziwych obrazach paneli, przez prawdziwą konfigurację z defaults/webserver/:

Panel Sprawdzone strony Reguły wychodzące
Grafana /, /dashboards, /connections/datasources, /explore 953100 + 959100 → blokada
Dozzle / czysto
Flower /, /tasks, /broker, /worker/x czysto
Netdata /, /v3/index.html (100 KB) czysto

Wykluczenie obejmuje mimo to wszystkie cztery — są tej samej natury, a kolejna wersja każdego z nich może wnieść własny fałszywy alarm.

Aplikacja BPP nadal jest skanowana

10004 dotyczy wyłącznie czterech ścieżek paneli. Odpowiedzi Django lecą przez reguły wychodzące jak dotąd — i tam mają sens, bo to nasz kod może wypluć komunikat błędu bazy danych. Jedynym wyjątkiem jest 953110, zdjęta globalnie przez regułę 10010.

DOI z fopen — reguła 10010

Zgłoszenie 2026-08-12: GET /api/v1/zrodlo/ kończył się urwaną odpowiedzią.

Czasopismo 4open (EDP Sciences) ma w bazie DOI 10.1051/fopen/. Reguła 953110 („PHP source code leakage") skanuje ciało odpowiedzi wzorcem nazw funkcji PHP — fopen, fread, fwrite, fgets, gzopen, scandir, readfile, proc_open, call_user_func, $_GET… Granice słowa są, ale ukośniki to znaki niesłowne, więc fopen jest w tym DOI pełnoprawnym tokenem.

Odtworzone lokalnie na realnym ciele odpowiedzi z produkcji, na tym samym obrazie i tej samej konfiguracji:

ruleId=953110  sev=3  PHP source code leakage
   data: Matched Data: fopen found within RESPONSE_BODY: …
ruleId=959100  sev=0  Outbound Anomaly Score Exceeded (Total Score: 4)

Severity ERROR = 4 pkt przy progu wyjściowym 4 — pojedyncze trafienie od razu blokuje.

Zmierzone skutki:

Żądanie Wynik
/api/v1/zrodlo/ + Accept: application/json 200, 5733 B — działa
/api/v1/zrodlo/ + Accept: text/html 200 + Content-Length: 18014, dojechało 15588 B
/api/v1/zrodlo/4276/ + Accept: text/html połączenie zerwane, 0 B

JSON przechodzi, bo application/json nie jest na liście MODSEC_RESP_BODY_MIMETYPE (text/plain text/html text/xml) — klienty API i harvestery nie ucierpiały, zepsuty był przeglądarkowy widok DRF.

Dlaczego globalnie, a nie na /api/

To nie jest problem ścieżki, tylko danych. Wyzwalaczem jest wartość w bazie, a nie adres — ten sam DOI wyrenderuje się wszędzie, gdzie pokazujemy to źródło lub publikację z niego: listy, detale rekordów, raporty, eksporty HTML. Wykluczenie po prefiksie URI byłoby grą w kreta, za każdym razem z objawem, który w niczym nie wskazuje na WAF.

Dlaczego wolno zdjąć całą regułę

953110 wykrywa wyciek kodu źródłowego PHP. W BPP nie ma PHP — ani interpretera, ani plików, ani modułu w nginksie; stos to Django/Python. Reguła nie ma tu czego chronić, więc jedyne, co może wyprodukować, to fałszywe alarmy na danych bibliograficznych. Żądania o *.php są dodatkowo odcinane 444 już na brzegu (Utwardzenie brzegu).

Siostrzane reguły zostają

953100 (@pmFromFile php-errors.data, komunikaty błędów PHP) i 953120 (@rx <\?(?!xml), znaczniki <?php) działają dalej. Zmierzone 2026-08-12: dzisiejszy php-errors.data ma 218 wpisów i wszystkie są długimi frazami („PDO: driver", „Cannot redeclare class", „Stack trace:"), więc na danych bibliograficznych się nie zapalają.

Dlaczego Grafana jest wyjęta w całości (reguła 10006)

Reguła 10004 zdejmowała dla Grafany tylko reguły wychodzące. To okazało się za mało — ruch przychodzący do Grafany jest fałszywym alarmem z definicji, a nie przez przypadek.

Objaw

Wszystkie panele wszystkich dashboardów pokazują „No data". Sama Grafana działa normalnie: strona się ładuje, dashboardy się otwierają, listy zmiennych się wypełniają. Puste są wyłącznie dane.

Powód: Grafana pobiera dane POST-em na /grafana/api/ds/query, a to właśnie te żądania dostawały 403. Zwykłe GET-y (HTML, JS, listy etykiet) przechodziły, więc nic nie wyglądało na zepsute.

Przyczyna

Ciało tego POST-a to zapytanie LogQL. Przy Log Level = All zmienna $level rozwija się do wyliczenia wszystkich poziomów:

detected_level=~"critical|debug|error|info|unknown|warn"

W środku siedzi ciąg |debug. Reguła 932110 („Remote Command Execution: Windows Command Injection") szuka wzorca „metaznak powłoki, potem nazwa polecenia" — a debug to polecenie DOS-a z jej słownika. Zmierzone na produkcji 2026-08-05, wprost z audit logu:

Matched Data: |debug found within ARGS:json.queries.array_0.expr

Severity CRITICAL = 5 punktów przy progu ANOMALY_INBOUND = 5, więc jedno trafienie od razu blokowało. Żadne inne reguły nie były potrzebne.

Dlaczego nie punktowe wykluczenie 932110

Bo problem jest strukturalny. Zapytania LogQL to z natury tekst pełen metaznaków powłoki, nawiasów i regexpów — a w przypadku dashboardu WAF-a dosłownie payloady ataków, po których operator filtruje (cross-filtr po modsec_uri wstawia w zapytanie ścieżkę skanera). Skanowanie tego regułami CRS oznacza podawanie WAF-owi jego własnych znalezisk na wejście. Każde kolejne pole dashboardu dokłada nowy zestaw reguł do wyłączenia — to gra nie do wygrania.

Dlaczego wolno to wyłączyć

Cały location /grafana/ stoi za auth_request /_bpp_superuser_auth (defaults/webserver/_bpp-locations.conf). Żądanie anonimowe dostaje 401 i przekierowanie na logowanie BPP, zanim dojdzie do Grafany. Wyłączamy więc inspekcję ruchu, który i tak może wygenerować wyłącznie zalogowany superuser — czyli ktoś, kto ma w BPP uprawnienia nieporównanie poważniejsze niż cokolwiek, co CRS mógłby tu powstrzymać.

Reguła 10004 zostaje

Po wejściu 10006 wykluczenie reguł wychodzących jest dla Grafany nadmiarowe. Zostawiamy je celowo: gdyby ktoś kiedyś zawęził 10006 (np. tylko do /grafana/api/), bez 10004 nic nie chroniłoby reszty Grafany przed regułami wychodzącymi. Dla /dozzle/, /flower/ i /netdata/ 10004 jest zresztą nadal jedynym wykluczeniem.

Nie jest to zabezpieczenie przed jednym znanym ciągiem — sqlConnectionLimits akurat przestał zapalać 953100 po zmianie pliku danych upstreamu (patrz ostrzeżenie przy regule 10004). Chodzi o to, że cudzy dashboard może przynieść kolejny fałszywy alarm, którego dziś nie znamy.

Nie rozszerzać na pozostałe panele bez pomiaru

Dozzle jest kandydatem z tego samego powodu (przeglądarka logów przesyła szukany tekst), ale nikt tego nie zmierzył. Spekulatywne poszerzanie wykluczeń to dokładnie ten nawyk, który ta konfiguracja stara się trzymać w ryzach — patrz Utwardzenie brzegu i lista prefiksów obcych aplikacji.

OAuth i pętla zwrotna — reguła 10007

Objaw

Logowanie do API BPP przez OAuth (rejestracja klienta + PKCE — tak działa m.in. serwer MCP i skrypty logowania w skillu bpp-api) kończy się zerwanym połączeniem, bez żadnego komunikatu. W Grafanie na dashboardzie WAF-a widać przy tym trafienie:

Possible Remote File Inclusion (RFI) Attack: URL Parameter using IP Address

Objaw jest mylący podwójnie: klient nie dostaje czytelnego 403, tylko curl 52 / „empty reply" (bo BPP mapuje blokadę na 444), a sama nazwa reguły sugeruje atak na nasz serwer — podczas gdy adres 127.0.0.1 w redirect_uri wskazuje maszynę klienta.

Przyczyna

Reguła CRS 931100 szuka w argumentach URL-a z literałem adresu IP zamiast nazwy hosta. Klasyczny RFI wygląda dokładnie tak: ?page=http://198.51.100.7/shell.txt.

Kłóci się to wprost z RFC 8252 (OAuth dla aplikacji natywnych). Klient bez własnej domeny odbiera kod autoryzacyjny na pętli zwrotnej i standard każe użyć tam literału IP — sekcja 7.3:

Specifying a redirect URI with the loopback IP literal rather than localhost avoids inadvertently listening on network interfaces other than the loopback interface.

Wariant z nazwą localhost jest w RFC oznaczony jako NOT RECOMMENDED. Efekt jest więc odwrotny do zamierzonego — zmierzone 2026-08-07 na owasp/modsecurity-crs:nginx, BLOCKING_PARANOIA=1:

redirect_uri Wynik
http://127.0.0.1:50603/callback (zalecane przez RFC) 403 (931100)
http://[::1]:50603/callback przelot
http://localhost:50603/callback (NOT RECOMMENDED) przelot

931100 ma severity CRITICAL = 5 pkt przy ANOMALY_INBOUND 5, więc blokuje pojedyncze trafienie — audit log pokazuje Total Score: 5 i 949110. Żadnej kumulacji tu nie ma.

Blokowany był cały przepływ, nie jeden endpoint

To najważniejsza obserwacja dla kształtu wykluczenia — naprawienie samego /o/authorize/ przesunęłoby błąd o krok dalej:

Krok Wynik przed regułą 10007
POST /o/register/ — rejestracja klienta (JSON) 403
GET /o/authorize/ — zgoda użytkownika 403
POST /o/token/ — wymiana kodu na token 403
POST /o/token/ — odświeżenie tokenu przelot (nie niesie redirect_uri)

Dlaczego nie wykluczenie celowane w argument

Naturalnym odruchem jest ctl:ruleRemoveTargetById=931100;ARGS:redirect_uri. Składnia jest poprawna (nginx wstaje) i załatwia /o/authorize/ oraz /o/token/, ale nie rejestrację klienta: DCR wysyła redirect_uris w JSON, więc ModSecurity nazywa argument ARGS:json.redirect_uris.array_0 — nazwa zależy od formatu ciała i od indeksu w tablicy. Wykluczenie po nazwie trzeba by wyliczać dla każdej formy osobno i tak rozsypuje się przy kliencie z dwoma redirect_uri.

Dlatego 10007 celuje w ścieżkę: ctl:ruleRemoveById=931100 na prefiksie ^/(o|\.well-known)/.

Dlaczego także /.well-known/

To druga połowa tej samej powierzchni. Klient OAuth/MCP nie zna z góry adresów endpointów — najpierw pobiera metadane serwera autoryzacji (/.well-known/oauth-authorization-server, RFC 8414) albo zasobu (/.well-known/oauth-protected-resource, RFC 9728) i dopiero z nich bierze authorization_endpoint oraz token_endpoint w /o/. Oba prefiksy są używane wymiennie w jednym logowaniu, więc muszą być traktowane tak samo — rozjechanie ich to gwarantowany błąd przy następnej zmianie.

Zmierzone 2026-08-07: samo discovery jest dziś GET-em bez argumentów, więc 931100 (która inspekcjonuje ARGS) się tam nie zapala. Wykluczenie jest więc zabezpieczeniem na wypadek, gdy klient dołoży argument z adresem pętli zwrotnej — np. resource= z RFC 9728. Wtedy padłoby dokładnie tak samo jak /o/, tyle że o krok wcześniej i bez żadnego śladu w Django, bo blokada kończy się na brzegu. Przypadek testowy strzela właśnie tą formą (z argumentem), więc nie jest pusty: po wyłączeniu reguły 10007 pada.

Kropka w \.well-known musi być escapowana

W regexpie ^/(o|.well-known)/ kropka to dowolny znak, więc wzorzec złapałby też /Xwell-known/ — a przez alternatywę z o każdy prefiks jednoznakowy. Wykluczenie rozlałoby się po serwisie bez żadnego widocznego objawu.

Routing /.well-known/ to osobna pułapka — nie mylić z WAF-em

Discovery potrafi paść także bez udziału ModSecurity. W nginksie regex ~ ma pierwszeństwo przed zwykłym prefiksem, więc blok location ~ /\. (blokada plików ukrytych) przechwytywał żądania do /.well-known/ i zwracał 403 — bpp-mcp login nie miał skąd wziąć authorization_endpoint. Ratuje to modyfikator ^~ w _bpp-locations.conf.

Rozpoznanie po logach: to zwykły deny all nginksa, więc nie ma go w logach WAF-a ani w dashboardzie ModSecurity — w odróżnieniu od blokady 931100. Osobne przypadki w make test-waf pilnują teraz obu rzeczy naraz: że discovery jest osiągalne i że .git/config mimo to dalej jest blokowany.

CRS 4.29: ta sama pętla zwrotna, nowa reguła — 934110 (SSRF)

Obraz owasp/modsecurity-crs:nginx z 2026-09-24 przyniósł CRS 4.29, w którym adres http://127.0.0.1 łapie już nie 931100, tylko 934110 („Possible Server Side Request Forgery"). Zapala się na każdym kroku logowania: redirect_uri na /o/authorize/ i /o/token/, json.redirect_uris.array_0 w DCR, a także resource= w discovery (/.well-known/oauth-protected-resource) — przypadek, na który 10007 była przygotowana zawczasu. make test-waf wykrył to od razu: cztery przypadki OAuth na czerwono.

Reguła 10007 zdejmuje więc obie: 931100 zostaje dla instalacji, które jeszcze nie pobrały nowego obrazu (tag jest pływający, każda instalacja przechodzi na nowy CRS przy własnym make pull), 934110 jest dla 4.29+.

Dlaczego wolno to wyłączyć

RFI i SSRF polegają na tym, że serwer pobiera zasób spod adresu podanego przez atakującego. django-oauth-toolkit nigdy nie odpytuje redirect_uri — porównuje go ze zbiorem URI zarejestrowanych dla klienta i najwyżej odsyła 302. Reguły 931100 i 934110 nie mają tu czego chronić.

Zakres jest przy tym jeden z najwęższych: dwie reguły tej samej natury (adres w argumencie), jedna powierzchnia. Nie ruleEngine=Off jak w 10006 i nie całe rodziny jak w 10004 — na /o/ nadal w pełni obowiązują 941 (XSS), 942 (SQLi), 930 (LFI), 932 (RCE) i blokada progowa 949110.

Nie rozszerzać na całą witrynę

?q=http://127.0.0.1/... poza /o/ ma dalej dostawać blokadę. Pilnują tego dwie asercje kontrolne w make test-waf (GET i POST): bez nich przypadki „OAuth przechodzi" byłyby zielone także po skasowaniu reguły 10007, a nawet po wyłączeniu całego CRS — czyli test nie dowodziłby niczego. Sprawdzone: z wyłączoną regułą padają dokładnie 3 przypadki OAuth, obie kontrole zostają zielone.

Nagłówki zakazane polityką — reguła 10008

Objaw

Użytkownik dostaje blokadę na całkowicie poprawnym adresie — zgłoszenie 2026-08-07 dotyczyło /bpp/rekord/, czyli istniejącego rekordu. W dashboardzie WAF-a widać przy tym URI żądania, więc trop prowadzi donikąd: z adresem jest wszystko w porządku.

Przy diagnozie zgłoszeń typu «zablokowało mi normalny URL»

920450 to jedyna reguła CRS, która blokuje bez żadnego związku z treścią żądania — nie patrzy ani na ścieżkę, ani na argumenty, ani na ciało. Jeśli URI wygląda niewinnie, sprawdzaj ją pierwszą.

Przyczyna

Reguła porównuje nazwy nagłówków z listą tx.restricted_headers. Domyślnie są na niej:

/accept-charset/ /content-encoding/ /proxy/ /lock-token/ /content-range/ /if/

Paranoia 1, severity CRITICAL = 5 pkt przy progu 5 — więc sam nagłówek, bez żadnej innej przewiny, wystarcza do blokady.

Accept-Charset jest na tej liście, bo został wycofany ze standardu (przeglądarki go nie wysyłają), więc CRS traktuje jego obecność jako sygnał klienta automatycznego. To heurystyka fingerprintingu, nie wykrycie ataku — nie da się tym nagłówkiem niczego zaatakować. Wysyłają go natomiast starsze biblioteki HTTP, a w przypadku BPP także harvestery bibliograficzne, systemy biblioteczne i menedżery cytowań — czyli realni użytkownicy API.

Dlaczego nie ctl:ruleRemoveById=920450

Na tej samej liście jest nagłówek Proxy, czyli ochrona przed httpoxy (CVE-2016-5385): aplikacja czytająca HTTP_PROXY ze środowiska dawała się przekierować przez nagłówek żądania. Skasowanie całej reguły zdjęłoby także ją, a przy okazji content-encoding, lock-token, content-range i if.

Zamiast tego przedefiniowujemy listę, usuwając z niej jedną pozycję (SecAction o id:10008).

Dlaczego to działa z naszego pliku

CRS ustawia domyślną listę w REQUEST-901-INITIALIZATION.conf pod warunkiem, że zmienna jeszcze nie istnieje. Nasz plik jest includowany przed regułami CRS, więc w phase:1 ustawiamy ją pierwsi i inicjalizacja CRS jej nie nadpisuje.

Zakomentowany szablon tej samej operacji siedzi w crs-setup.conf jako reguła 900250 — nie korzystamy z niego, bo crs-setup.conf nie jest naszym plikiem i git pull by go nie zaktualizował.

Dwa przypadki testowe, oba konieczne

„Accept-Charset przechodzi" nie dowodzi niczego samo z siebie — przeszłoby tak samo po skasowaniu całej reguły 920450. Drugi przypadek (Proxy nadal blokowany) pilnuje, że zdjęliśmy jedną pozycję z listy, a nie wylaliśmy dziecka z kąpielą.

Formularze admina — reguła 10009

Ścieżka ^/admin/<app>/<model>/ schodzi w całości do DetectionOnly. Reguła powstała 2026-08-12 jako wykluczenie jednej reguły (932130) i została rozszerzona 2026-08-23, bo wyliczanka ID nie nadążała za danymi.

Objaw

Redaktor nie może zapisać publikacji. POST na formularz zmiany rekordu kończy się blokadą, w logu Total Score: 5:

[id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"]
[uri "/admin/bpp/wydawnictwo_ciagle/105013/change/"]
request: "POST /admin/bpp/wydawnictwo_ciagle/105013/change/ HTTP/2.0"

Wpis 949110 nie mówi, co zablokowało

949110 to reguła sumująca, wykonywana na końcu fazy 2. Loguje samą siebie — stąd puste [data ""]. Reguła, która faktycznie się dopasowała, siedzi w osobnym, wcześniejszym wpisie o tym samym unique_id. To ona ma wypełnione logdata ze wskazaniem pola. Bez niej diagnoza jest zgadywaniem.

Pierwsze zgłoszenie (2026-08-12) — winowajcą była transformacja, nie treść

932130 szuka wyrażeń powłoki: $(...), $((...)), ${...} oraz <(...) i >(...) (podstawienie procesu). Działa jednak po t:cmdLine, która m.in. kasuje spacje przed (, zamienia , i ; na spacje i sprowadza tekst do małych liter. Legalne zdanie staje się więc wyrażeniem powłoki dopiero w silniku:

wpisane:     wynik istotny (p < (0,05))
po cmdLine:  wynik istotny(p <(0 05))
                            ^^^^^^^ pasuje do [<>]\(.*\)
Matched Data: <(0 05)) found within ARGS:streszczenie: wynik istotny(p <(0 05))

Zdjęliśmy wtedy samą 932130, z zapisem: „gdy któraś z pozostałych realnie strzeli — dopisać ją tutaj po zmierzeniu".

Drugie zgłoszenie (2026-08-23) — trzy kolejne reguły w jedenaście dni

Ludzie znów nie mogli zapisywać prac. Pomiar na produkcji (bpp.umlub.pl, audit log z Loki, okno 7 dni) pokazał, że /admin/ dzieli się na dwie rozłączne populacje, a granica biegnie dokładnie po liczbie segmentów ścieżki.

Głębokie — /admin/<app>/<model>/… — 160 blokad, w 100% fałszywe alarmy:

Reguła Trafień Pole Dopasowanie
932115 Windows Command Injection 60 streszczenia-0-streszczenie ; type 1 diabetes mellitus; air pollution; … — ; + type (komenda Windows)
933210 PHP Variable Function Call 50 streszczenia-0-streszczenie (PPEQ) (the Polish adaptation of the CAHPS Child Hospital Survey (Child HCAHPS)) — wzorzec (…)(…)
930110 Path Traversal (/../) 30 autorzy_set-7-data_oswiadczenia .. — niedokończona data z widżetu dd.mm.rrrr
942100 SQLi via libinjection 20 — /admin/bpp/wydawnictwo_zwarte/

Płytkie — /admin/login/, /admin/.env — 343 blokady, w 100% realne ataki:

Reguła Trafień Ścieżka
942100 ×129, 942190 ×115, 920220 ×66, 942140 ×27, 942230 ×1 338 /admin/login/
930130 Restricted File Access 5 /admin/.env

Dlaczego DetectionOnly, a nie kolejne ID na liście

Wyzwalaczem nie jest żaden konkretny wzorzec, tylko sama natura danych. Anglojęzyczne streszczenie naukowe to kilka tysięcy znaków wolnego tekstu, gęstego od średników, nawiasów, akronimów i notacji statystycznej. CRS na PL1 skanuje to dziesiątkami reguł składniowych, z których każda ma severity CRITICAL = 5 pkt przy progu 5 — jedno trafienie blokuje, zapasu nie ma. Kolejne streszczenie znajdzie kolejną regułę, a type 1 diabetes nie da się przewidzieć z wyprzedzeniem.

To ten sam argument, który uzasadnia 10006 dla /grafana/: LogQL jest z natury gęsty od metaznaków, więc wykluczanie pojedynczych reguł byłoby grą w kreta.

Wniosek z 2026-08-12 został obalony przez dane, nie przez opinię

Jedenaście dni wcześniej ta strona argumentowała, że poszerzenie wykluczenia poza 932130 „byłoby zdjęciem ochrony bez ani jednego przypadku, który by to uzasadniał" — bo żaden z siedmiu testowanych łańcuchów bibliograficznych (Stezenie H<sub>2</sub>O<sub>2</sub>, Union of datasets; a comparative study, iloraz szans OR 1=1,25 (95% CI), …) się nie zapalał.

To był poprawny wniosek z wymyślonych payloadów i błędny wniosek o rzeczywistości: prawdziwe streszczenia z produkcji zapaliły trzy reguły, których nikt na tej liście nie przewidział. Lekcja nie brzmi „nie mierzyć", tylko mierzyć na realnym ruchu, nie na wyobrażeniu o nim.

Cena, wprost

Na ^/admin/<app>/<model>/ realny payload RCE, SQLi czy XSS nie jest już blokowany. Nie jest to efekt uboczny — make test-waf zapisuje to jako oczekiwany PASS w trzech przypadkach oznaczonych CENA:, żeby nikt nie odkrył tego przypadkiem i żeby cofnięcie decyzji od razu zapaliło się na czerwono.

Dlaczego to akceptowalne:

  1. Każdy z tych endpointów wymaga konta ze staff_member_required — kto tam POST-uje, jest już uwierzytelnionym redaktorem.
  2. Django parametryzuje SQL przez ORM, a szablony auto-escapują HTML; ochrona składniowa CRS jest tu w dużej mierze zdublowaniem.
  3. Jedyny anonimowo osiągalny endpoint admina to formularz logowania i on zostaje chroniony w pełni. Pomiar wyżej pokazuje, że to właśnie tam leci cały realny ruch atakujący: 338 z 343 płytkich blokad.

Dlaczego DetectionOnly, a nie Off

Trafienia dalej idą do audit logu, więc widać je na dashboardzie WAF-a jako detected (zmienna Akcja). Przy Off ślad znika całkowicie i nie dowiemy się, że coś nowego zaczęło strzelać. Ta sama konwencja co 10002 i 10003.

REQUEST_FILENAME, nie REQUEST_URI — to nie jest kosmetyka

Wzorzec ma dwa zmienne segmenty (<app>/<model>), a REQUEST_URI zawiera query string — który potrafi go domknąć na cudzej ścieżce:

/admin/login/?next=/admin/
        └─┬─┘ └────┬────┘
      [^/]+       [^/]+/      ← "?next=" i "/admin/" domykają wzorzec

Po rozszerzeniu do DetectionOnly ta pomyłka waży nieporównanie więcej. Wcześniej kosztowałaby zdjęcie jednej reguły z formularza logowania. Teraz kosztowałaby zdjęcie całego CRS-a z jedynego anonimowo osiągalnego endpointu admina — czyli dokładnie z tego miejsca, w które leci 338 blokad na 7 dni.

REQUEST_FILENAME to sama ścieżka bez query stringa, więc /admin/login/, /admin/password_change/, /admin/logout/ i /admin/jsi18n/ mają po /admin/ tylko jeden segment i nie pasują.

Pilnują tego cztery asercje kontrolne w make test-waf: goły /admin/login/, wariant z ?next=/admin/ oraz realne SQLi i RCE na formularzu logowania. Dodatkowe trzy sprawdzają, że poza /admin/ reguły 932130, 930110 i 932115 działają bez zmian.

Zasięg

^/admin/<app>/<model>/ pokrywa jednym wzorcem komplet formularzy modelu:

URI Co to
/admin/bpp/wydawnictwo_ciagle/105013/change/ edycja rekordu
/admin/bpp/wydawnictwo_ciagle/add/ dodawanie
/admin/bpp/wydawnictwo_ciagle/ changelist + akcje masowe
/admin/bpp/wydawnictwo_ciagle/105013/delete/ potwierdzenie usunięcia
/admin/bpp/autor/autocomplete/ AJAX select2

Reguła 10002 zostaje nietknięta

/admin/dbtemplates/ nadal schodzi do DetectionOnly osobną regułą. Jest dla swojego prefiksu nadmiarowa, ale obejmuje także /admin/dbtemplates/ bez drugiego segmentu, którego 10009 nie łapie.

Te reguły nie strzelają nigdzie poza /admin/

Zmierzone 2026-08-23: 930110, 932115, 933210 i 932130 nie zapaliły się w oknie 7 dni ani razu poza /admin/, więc osobne wykluczenie globalne byłoby martwym kodem. Wyjątkiem jest 942100, które strzela 273× na /bpp/autorzy — ale to inny problem, z własnym wyzwalaczem, i nie załatwia go wykluczenie dla /admin/.

Stan na 2026-08-23, jedna instancja (bpp.umlub.pl). 2026-09-13 933210 zablokowało na publikacje.up.lublin.pl publiczne /bpp/build_search/ — inna uczelnia, inne nazwy jednostek. Patrz reguła 10011.

Tytuł raportu multiseek — reguła 10011

Pola niosące tytuł raportu wyszukiwania nie są skanowane regułami, dla których ta wartość nie ma dokąd trafić. Ochrona przed XSS zostaje.

Objaw

Na stronie jednostki (autora, źródła, uczelni) przycisk „Szukaj publikacji" albo „Pokaż wszystkie publikacje" kończy się natychmiast zerwanym połączeniem — bez komunikatu, bez śladu w logach Django:

"POST /bpp/build_search/ HTTP/2.0" 444 0 254 0.000 - "https://…/bpp/jednostka/Katedra-Inzynierii-i-Technologii-Zboz-092024/"

Audit log:

933210  PHP Injection Attack: Variable Function Call Found
        Matched Data: [09.2024...] (WBIOTECH) found within ARGS:suggested-title:
        Katedra Inżynierii i Technologii Zbóż [09.2024...] (WBIOTECH)
949110  Inbound Anomaly Score Exceeded (Total Score: 10)

Blokowały się wyłącznie strony, których obiekt ma w nazwie taki wzorzec. Pozostałe działały, więc zgłoszenie brzmiało „nie działa dla tej jednej katedry".

Przyczyna

Szablony browse/{jednostka,autor,zrodlo,uczelnia}.html wkładają do pola suggested-title {{ obiekt }}, czyli __str__ modelu. Dla jednostki to nazwa + (skrót wydziału), a nazwa bywa zakończona datą w nawiasie kwadratowym. 933210 szuka […] albo (…), po którym stoi (…). W PHP to $f['x']('arg'), a w bibliografii — każda nazwa z datą i skrótem.

Score wynosi 10, nie 5, bo formularz wyszukiwania wysyła pole dwa razy (ukryte i edytowalne „Tytuł raportu"). Każde trafienie daje 5 pkt przy progu 5.

Ten sam tytuł można potem edytować w wynikach. To POST pola value na /bpp/update-multiseek-title/ i blokuje się tam identycznie.

Dlaczego zakres wyznaczają sinki

Rodzina Tag Zdjęta z tytułu? Dlaczego
930 LFI attack-lfi tak tytuł nie jest ścieżką pliku
931 RFI attack-rfi tak tytuł nie jest pobierany ani includowany
932 RCE attack-rce tak nie trafia do powłoki
933 PHP attack-injection-php tak w BPP nie ma PHP
942 SQLi attack-sqli tak sesja siedzi w Redisie (SESSION_ENGINE = cache), nie w SQL-u
941 XSS attack-xss nie tytuł jest renderowany |safe

XSS zostaje, bo to jedyny realny sink. Tytuł przechodzi przez nh3.clean, ale potem jest renderowany |safe (multiseek/title.html). Oba endpointy są przy tym @csrf_exempt, więc obca strona może podstawić tytuł do sesji ofiary. WAF jest tu drugą warstwą za nh3 i powinien nią zostać.

Dlaczego nie inne warianty

  • ctl:ruleRemoveTargetById=933210. Tytuł to wolny tekst: wpisuje go użytkownik, a domyślnie są to nazwy jednostek, autorów i czasopism z całej bazy. Dane mają tę samą naturę co streszczenia w 10009, gdzie lista ID przegrała trzy razy w jedenaście dni.
  • ctl:ruleEngine=DetectionOnly na ścieżce, jak w 10009. Granica 10009 biegnie po uwierzytelnieniu, a tu jej nie ma. Oba endpointy są anonimowe, więc pozostałe pola (jednostka, autor, zrodlo, rok, typ) mają zostać w pełni chronione.

Cena i kontrole w make test-waf

Wynik Przypadek
PASS nazwa jednostki z produkcji na /bpp/build_search/ i na /bpp/update-multiseek-title/
PASS — CENA: realne RCE i SQLi w tytule raportu
BLOK XSS w suggested-title i w value
BLOK realne RCE z grupy CENA w polu jednostka (wykluczenie dotyczy argumentu, nie ścieżki)
BLOK to samo RCE w suggested-title na innej ścieżce (wykluczenie dotyczy ścieżki, nie nazwy pola)

Kontrole granic używają payloadu z grupy CENA, a nie nazwy jednostki: CRS 4.29 przestał łapać [..] (..) w 933210, więc na nowym obrazie kontrola na nazwie jednostki przechodziła, niczego nie dowodząc.

Po stronie BPP da się to załatwić u źródła

Domyślny tytuł nie musi jechać z przeglądarki. Widok może ustalić go sam z PK (jednostka, autor, zrodlo), a pole „Tytuł raportu" może być puste, z nazwą jako placeholder. Wtedy WAF widzi nazwę tylko wtedy, gdy użytkownik świadomie ją wpisze. Reguła 10011 zostaje i tak, bo tytuł wpisany ręcznie nadal może zawierać nawiasy.

Ciasteczko PostHoga z Netdaty — reguła 10012

Objaw

Po wejściu w /netdata/ przeglądarka nie otwiera już żadnej strony serwisu — GET / też kończy się 444, a w logach Django nie ma nic. Inna przeglądarka albo okno prywatne działa normalnie. Zgłoszenie 2026-09-25 (bpp.ihit.waw.pl), w audit logu:

942290 Finds basic MongoDB SQL injection attempts
Matched Data: $in found within REQUEST_COOKIES:ph_phc_hnhlqe6D2Q4IcQNrFItaqdXJAxQ8RcHkPAFAp74pubv_posthog:
{"$device_id":"…","$sesid":[…],"$initial_person_info":{…},"$user_state":"anonymous"}

Przyczyna

Dashboard Netdaty (frontend ich chmury, serwowany przez agenta) ma wbudowanego PostHoga. DISABLE_TELEMETRY=1 w compose wyłącza telemetrię agenta, nie tego skryptu. PostHog zapisuje na całą domenę ciasteczko z JSON-em, w którym klucze zaczynają się od $. Na CRS 4.25 wzorzec 942290 nie miał granicy słowa, więc $in z początku $initial_person_info liczył się jako operator MongoDB $in — 5 pkt przy progu 5. Przeglądarka wysyła ciasteczko z każdym żądaniem, a PostHog ustawia je na rok.

Doraźnie: usunąć dane witryny w przeglądarce (Safari: Ustawienia → Prywatność → Zarządzaj danymi witryn).

Dlaczego wykluczenie, skoro CRS 4.29 to naprawił

CRS 4.29 dopisał do wzorca 942290 \b i ta konkretna wartość przestała się zapalać. Wykluczenie zostaje, bo:

  • tag obrazu jest pływający — instalacje przechodzą na 4.29 każda przy swoim make pull, do tego czasu 4.25 blokuje;
  • to JSON analityczny z $-kluczami i pełnymi URL-ami; kolejna zmiana dowolnej rodziny (942, 931, 934, 941) może zrobić to samo, a objaw jest najgorszy z możliwych — cała witryna martwa dla jednej przeglądarki.

Zakres i dlaczego wolno

Cały CRS (ctl:ruleRemoveTargetByTag=OWASP_CRS), ale wyłącznie dla tego jednego ciasteczka. Ani Django, ani agent Netdaty go nie czytają — to stan klienta PostHoga, payload nie ma dokąd trafić.

Dokładna nazwa, nie regex — i to nie z wyboru

Lekser akcji ctl w libmodsecurity v3.0.16 odrzuca w celu znaki ^, \, + i [ — nginx nie wstaje (Expecting an action, got: ^ph_phc_…). Zakotwiczonego wzorca nie da się zapisać, a regex bez kotwic łapałby także x_ph_phc_…_posthog. Środek nazwy to klucz projektu PostHoga zaszyty we frontendzie Netdaty. Objaw, że go zmienili: znów „po wejściu w /netdata/ nic nie działa", a w audit logu inna nazwa ciasteczka. Wtedy dopisać drugą akcję ctl z nową nazwą; starej nie usuwać, bo przeglądarki trzymają ciasteczko rok.

Kontrole w make test-waf

Wynik Przypadek
PASS wartość z produkcji na / i na /admin/login/ (na 4.29 już tylko regresja)
PASS — CENA: realne SQLi w ciasteczku PostHoga
BLOK to samo SQLi w sessionid i w csrftoken obok ciasteczka PostHoga
BLOK nazwa z przedrostkiem x_ i z przyrostkiem _x

Kontrole celowo nie używają wartości z produkcji — na 4.29 ona nigdzie nie blokuje, więc kontrola na niej przechodziłaby niczego nie dowodząc.

Logi WAF-a w Grafanie

Jest gotowy dashboard WAF (ModSecurity / OWASP CRS) — rankingi reguł, adresów IP i ścieżek, kategorie ataków, rozkład anomaly score. Poniżej opis danych, na których stoi.

Dwa wpisy na jedno żądanie

Każde oflagowane żądanie zostawia w stdout webservera dwa wpisy:

  1. linię tekstową nginksowego error.log (ModSecurity: Access denied…),
  2. wpis audit logu w JSON.

Cała treść analityczna jest w (2). Do error.log trafiają wyłącznie reguły decyzyjne — 949110 (inbound) i 959100 (outbound) — bo w CRS z anomaly scoringiem pojedyncze reguły ataku się tam nie logują. Rodzaj ataku (942xxx SQLi, 941xxx XSS, 930xxx LFI) jest więc tylko w JSON-ie. Widać to gołym okiem na jednym przebiegu make test-waf: 8 linii error.log wobec 11 wpisów audit.

Oba wpisy dostają pola modsec_* — bo to na linię z error.log patrzy człowiek w „Log Monitoring" (audit log to ściana JSON-a), a bez własnych pól dałoby się ją filtrować wyłącznie pełnotekstowo. Te pola są dziś fundamentem dropdownu ModSecurity na tamtym dashboardzie (wszystko / tylko WAF / bez WAF — patrz Dashboardy Grafany): tylko WAF to modsec_src="nginx", a bez WAF to modsec_src="", które łapie linie nieposiadające tego klucza w ogóle. Rozróżnia je modsec_src:

modsec_src Co to Ma modsec_attack / modsec_rules
audit wpis audit logu (JSON) tak — pełny łańcuch reguł i kategoria ataku
nginx czytelna linia error.log nie — są tam tylko reguły decyzyjne

Agregaty muszą filtrować modsec_src

Jedno żądanie = dwa wpisy. Zapytanie liczące trafienia bez | modsec_src = "audit" policzy każde żądanie dwa razy. Wszystkie panele dashboardu WAF mają ten filtr; panel z logami celowo pokazuje nginx.

Oba wpisy spina modsec_unique_id.

Wpisy audytowe, w których nie zapaliła się żadna reguła

SecAuditEngine RelevantOnly (domyślne w obrazie) loguje transakcję z dwóch niezależnych powodów, połączonych OR:

  1. zapaliła się reguła z akcją auditlog,
  2. kod odpowiedzi pasuje do SecAuditLogRelevantStatus — a domyślne w obrazie ^(?:5|4(?!04)) to każde 4xx poza 404 i każde 5xx.

Powód (2) wpuszcza do audit logu ruch, którego WAF w ogóle nie dotknął. Taki wpis ma "messages":[] — zero informacji o ataku — a mimo to niesie komplet pól modsec_* (URI, IP, kod), więc dla agregatu wygląda identycznie jak trafienie. Łapie się tam:

  • 401 z auth_request na /grafana/, /dozzle/, /flower/, /netdata/ — a to jest ścieżka zaprojektowana: error_page 401 = @bpp_login przekierowuje niezalogowanego na logowanie BPP,
  • 429 z rate limitingu,
  • 502/503/504, gdy leży appserver.

Objaw: dashboard WAF-a pokazuje własne panele Grafany jako ataki

Produkcja, 2026-08-05: wygasła sesja przy otwartej karcie Grafany dała dziewięć wpisów 401 (strona + fonty + moduły pluginów), a panel „Najczęściej atakowane ścieżki" wyświetlił Inter-Regular.woff2 i grafana-lokiexplore-app/module.js jako najczęstsze cele ataków. Trop prowadził donikąd — reguła 10004 działała poprawnie, CRS nie zgłosił niczego, bo nie było czego zgłaszać.

Dlatego ustawiamy MODSEC_AUDIT_LOG_RELEVANT_STATUS=^$: kod statusu jest zawsze niepusty, więc ten regexp nie pasuje do niczego i zostaje sam powód (1). Zmierzone na owasp/modsecurity-crs:nginx: przy ^$ zwykłe 502 nie zostawia wpisu, a żądanie z SQLi nadal zostawia wpis z czterema regułami.

Nic przy tym nie tracimy: przy MODSEC_AUDIT_LOG_PARTS=AHZ taki wpis niósł mniej niż linia access logu nginksa (format bpp_access, też zbierany przez Alloy), a błędy 5xx mają własny dashboard „Log Monitoring".

Agregaty muszą też filtrować modsec_rule_id

To druga warstwa, niezależna od powyższego ustawienia — MODSEC_AUDIT_LOG_RELEVANT_STATUS jest jawnym knobem w .env i operator może je przywrócić. Każde zapytanie dashboardu WAF ma dlatego oba filtry: | modsec_src = "audit" | modsec_rule_id != "". Wpis bez reguł nie dostaje modsec_rule_id (JMESPath messages[0].details.ruleId na pustej liście nie tworzy klucza), a LogQL traktuje brakującą etykietę jak pustą — więc wypada. Pilnują tego tests/test_makefile.sh (asercja: liczba zapytań z filtrem = liczba zapytań po audit logu) i make test-alloy (asercja: wpis bez reguł nie dostaje modsec_rule_id, ale zachowuje modsec_code=401).

Pola modsec_*

Wyciągane przez defaults/alloy/config.alloy do structured metadata (nie do labeli — modsec_uri i modsec_client jako stream labele wysadziłyby kardynalność indeksu strumieni):

Pole Znaczenie
modsec_action blocked (połączenie zerwane) / detected (przepuszczone, reguły 10002/10003)
modsec_rule_id reguła wiodąca — pierwsza dopasowana, ta merytoryczna
modsec_msg jej opis
modsec_severity severity reguły wiodącej (skala sysloga, 0–7)
modsec_rules pełny łańcuch reguł, po przecinku — do wyszukiwania
modsec_attack kategoria z tagu attack-* (sqli, xss, lfi, rce, disclosure…)
modsec_paranoia poziom paranoi reguły
modsec_direction inbound / outbound
modsec_score anomaly score w momencie decyzji
modsec_uri URI z query stringiem (czyli zwykle z payloadem)
modsec_method, modsec_code metoda HTTP, kod odpowiedzi
modsec_client adres IP widziany przez nginksa
modsec_hostname vhost — istotne przy multi-host
modsec_unique_id spina wpis audit z bliźniaczą linią error.log
modsec_src audit (JSON) / nginx (error.log) — filtruj po tym w agregatach

Poziom trafień to warn, nie error

Mimo że nginx loguje je jako [error]. Ta sama decyzja co przy limit_req_log_level warn w rate limitingu: powódź zdarzeń bezpieczeństwa nie ma zalewać dashboardu błędów aplikacji. Skan z lipca 2026 to 2165 żądań z 1342 adresów IP — każde trafienie to osobna linia, czyli tysiące „błędów" przy w pełni sprawnej aplikacji. Trafienia mają własny dashboard.

Przykładowe zapytania LogQL:

# Najczęściej zapalające się reguły
topk(10, sum by (modsec_rule_id, modsec_msg) (
  count_over_time({service="webserver"} | modsec_action != "" [24h])))

# Co zostało zablokowane temu adresowi
{service="webserver"} | modsec_action = "blocked" | modsec_client = "203.0.113.7"

# Trafienia SQLi na konkretnym vhoście
{service="webserver"} | modsec_attack = "sqli" | modsec_hostname = "bpp.example.org"

Filtry ad-hoc („Filter for value")

Grafana pokazuje przy komórkach tabeli i przy polach w szczegółach linii logu lupki „Filter for value" / „Filter out value". Na dashboardzie WAF-a działają — kliknięcie zawęża wszystkie panele, a wybrany filtr ląduje w chipie Filters nad dashboardem (usuwasz go ×).

Do 08.2026 kliknięcie w tę lupkę wygaszało cały dashboard

Każdy panel pokazywał „No data", co wyglądało jak awaria zbierania logów i nie miało nic wspólnego ze stanem WAF-a. Naprawione parserem-zaślepką na końcu każdego zapytania — mechanizm opisany niżej. Jeśli widzisz ten objaw po git pull, sprawdź, czy zapytania paneli kończą się na | logfmt bpp_noop="__bpp_noop__" | drop bpp_noop.

Mechanizm awarii i naprawy. Filtr ad-hoc trafiał do selektora strumienia:

{job="docker", service="webserver", modsec_msg="SQL Injection Attack Detected…"}
                                    ^^^^^^^^^^ to jest structured metadata

Selektor strumienia jest rozwiązywany po indeksie strumieni, a modsec_* nigdy nie były labelami strumienia — celowo, bo modsec_uri × modsec_client wysadziłoby kardynalność indeksu (patrz tabela pól wyżej). Żaden strumień nie pasuje, więc wynik jest pusty na każdym panelu. Poprawne miejsce to filtr za selektorem (| modsec_msg = "…") i dokładnie tak robi to panel „Logs" — bo tam Grafana zna typ pola z odpowiedzi Loki.

Do filtrów ad-hoc ten typ nie jest przekazywany (Grafana 12.4.2, datasource.ts → addAdHocFilters() woła addLabelToQuery() bez argumentu labelType). modifyQuery.ts bez niego zgaduje po obecności parsera:

if (parserPositions.length === 0) return addFilterToStreamSelector(...)
else                              return addFilterAsLabelFilter(...)

I to jest cała dźwignia. Przycisku nie da się ukryć z poziomu JSON-a (setDashboardPanelContext.ts ustawia onAddAdHocFilter bezwarunkowo, a filterable: false na tabelach dotyczy filtra kolumny), ale da się sprawić, żeby gałąź else była tą wybieraną — wystarczy, że w zapytaniu jest jakikolwiek parser. Dlatego każde zapytanie dashboardu kończy się na:

| logfmt bpp_noop="__bpp_noop__" | drop bpp_noop

Po kliknięciu Grafana wstawia filtr za parserem i wszystko działa:

… | logfmt bpp_noop="__bpp_noop__" | modsec_client=`185.199.108.1` | drop bpp_noop

Zmierzone na stanowisku (loki 3.7.1 + grafana 12.4.2, 60 wpisów audytowych): bez parsera 60 → „No data" i 11 pustych paneli, z parserem 60 → 20.

Dlaczego akurat taki parser

Kandydat Wynik na prawdziwych liniach z tests/fixtures/alloy-loglines.txt
\| pattern "<_>" Loki odrzuca: at least one capture is required
\| json __error__ na liniach error.log — to nie jest JSON
\| logfmt (gołe) przechodzi, ale wyciąga śmieci: level, msg, ts, duration, ___export, ___plik
\| logfmt bpp_noop="__bpp_noop__" wyciąga tylko pustą etykietę bpp_noop
… \| drop bpp_noop sprząta i ją

Najgroźniejszy jest wariant trzeci: level konkurowałby z detected_level, którego jedynym źródłem ma być config.alloy (zamknięty słownik 7 wartości — patrz Logowanie). Jawna etykieta z klucza, który w żadnej linii nie wystąpi, nie wyciąga niczego; drop usuwa pustą etykietę, żeby nie zaśmiecała szczegółów linii w panelu logów.

Parser musi zostać OSTATNIM ogniwem potoku

Wtedy miele wyłącznie linie, które przeszły już przez modsec_src i filtry zmiennych — a nie cały strumień webservera. Przesunięcie go wcześniej jest poprawne składniowo i niewidoczne w wyniku, za to kosztuje. Pilnuje tego osobna asercja w tests/test_makefile.sh (test_waf_crossfilter), niezależna od tej, która sprawdza samą obecność.

Własne filtry — zmienne u góry dashboardu i data linki na wierszach tabel (opis) — zostają. Są wygodniejsze do wpisania wartości z ręki i przeżywają udostępnienie linku.

Jedno ograniczenie klikania po ścieżce

Data link po modsec_uri cytuje wartość literalnie (\Q…\E), żeby metaznaki regexa w ścieżkach skanerów (?, +, .) nie zmieniały znaczenia filtra. Ścieżka zawierająca literalny backslash (np. sonda ThinkPHP …/\think\app/…, jeśli trafi do logu w postaci niezakodowanej) wymagałaby podwojenia także jego — czego data link Grafany nie potrafi — i taki klik wróci pusty. Postać %5C, czyli ta realnie logowana przez ModSecurity, działa normalnie.

Sprawdzenie, czy WAF dziala — make test-waf

make test-waf

Stawia stack testowy: atrapę backendu, która na każde żądanie odpowiada 200 pass, oraz webserver z prawdziwą konfiguracją z defaults/webserver/. Potem strzela baterią zapytań, gdzie każde ma z góry znany oczekiwany wynik — i wypisuje OK albo FAIL per przypadek. Kod wyjścia = liczba niezgodności.

Nie wymaga .env, działającej instalacji ani sieci produkcyjnej; sprząta po sobie własne kontenery i sieć.

Payloady ataku to prawdziwe próby z lipca 2026 (sqlmap przeciwko publikacje.up.lublin.pl), nie wymyślone przykłady. Po stronie „ma przejść" siedzą realne wzorce ruchu BPP: eksporty raportów z sortowaniem, wyszukiwanie tekstem zawierającym angielskie select … from, DjangoQL, dbtemplates.

Zmienne:

  • WAF_TEST_PORT — port na hoście (domyślnie 18443),
  • MODSEC_RULE_ENGINE — ustaw DetectionOnly, żeby zobaczyć, co by zostało zablokowane, bez faktycznego blokowania.

Po tabelce przypadków leci jeszcze jedno, osobne sprawdzenie: inspekcja ciała odpowiedzi. Atrapa serwuje stronę z komunikatem błędu PHP, a test sprawdza, czy zapaliła się któraś reguła 95xxx. Asercja idzie po audit logu, a nie po wyniku HTTP — bo blokowanie wychodzące jest wyścigiem i wynik HTTP migotał (1 na 5 przebiegów kończył się inaczej). Wykrycie jest deterministyczne, egzekucja nie.

Trzecie to zapytanie LogQL z Grafany — para POST-ów z prawdziwym ciałem /grafana/api/ds/query, pilnująca reguły 10006. Sprawdzenia są dwa i oba są potrzebne:

  1. ten sam payload wystrzelony poza /grafana/ musi zostać zablokowany,
  2. na /grafana/api/ds/query musi przejść.

Bez pierwszego test przechodziłby tak samo po skasowaniu reguły 10006, a nawet po wyłączeniu całego CRS — wykluczenie, które niczego nie wyłącza, jest gorsze niż żadne. Sprawdzenie musi też iść POST-em z ciałem: cały problem siedzi w ARGS:json.queries.array_0.expr, więc tabelka GET-ów jest na niego ślepa. Rozstrzygnięcie sprawdza wprost kod 403 — gdyby liczyło „dowolny kod HTTP = przeszło", jak tabelka wyżej, blokada ModSecurity zaliczyłaby się jako sukces.

Czwarte osobne sprawdzenie to legalne GET / po HTTP/3 — przypadek, dla którego powstała reguła 10005. Klient QUIC chodzi z wnętrza sieci dockerowej, bo systemowy curl (także ten z obrazów alpine i curlimages/curl) jest budowany bez QUIC; stąd --network-alias z nazwą vhosta na kontenerze webservera. Wynik znów rozstrzyga access log, a nie kod wyjścia curla:

  • 444 przy HTTP/3.0 w logu — realna blokada WAF-a, czyli FAIL,
  • zero linii HTTP/3.0 — klient zgubił handshake i żądanie w ogóle nie dotarło, więc próba się powtarza (po czterech nieudanych: POMIN).

Pod emulacją amd64 (host arm64) handshake gubi się mniej więcej raz na pięć prób. Bez tego rozróżnienia retry maskowałby regresję.

Czego ten test NIE obejmuje

Sprawdza wyłącznie warstwę brzegową. Sondy o pliki *.php (phpMyAdmin, WordPress) przechodzą tutaj, bo blokuje je dopiero MaliciousRequestBlockingMiddleware po stronie Django — a w tym stacku backend jest atrapą. To jest w teście oznaczone jako oczekiwany PASS.

Sama tabelka przypadków nie łapie błędów specyficznych dla HTTP/2 i /3 — strzela curl --http1.1, czyli protokołem, w którym Host: istnieje. Dlatego h3 ma osobne sprawdzenie (opisane wyżej); dokładając nowy przypadek do tabelki, pamiętaj, że pokrywa on wyłącznie HTTP/1.1.

Nie weryfikuje samego wykluczenia 10004. Lokalnie auth_request tłumi inspekcję odpowiedzi na lokacjach paneli — więc /grafana/ przechodzi tam niezależnie od tego, czy reguła istnieje. Na produkcji tego tłumienia nie ma (audit log z 2026-08-03 pokazuje 953100 na /grafana/), ale przyczyna tej rozbieżności pozostaje niewyjaśniona. Przypadek testowy na regułach wychodzących byłby więc lokalnie zawsze zielony — czyli dawałby fałszywy spokój — i celowo go nie ma.

To zastrzeżenie nie dotyczy reguły 10006, mimo że też siedzi na /grafana/. Tamto tłumienie obejmuje wyłącznie reguły wychodzące; reguły przychodzące działają lokalnie normalnie i sprawdzenie opisane wyżej jest realne — zweryfikowane przez podmianę wzorca URI w regule na niepasujący: test wtedy czerwieni się dokładnie produkcyjnym objawem (HTTP 403 od ModSecurity). Nie usuwaj go, sugerując się akapitem powyżej.

Dlaczego 444, a nie 403

444 to niestandardowy kod nginksa: zamknij połączenie, nie wysyłając nic. Wbrew intuicji nie oznacza to, że klient wisi — połączenie jest zrywane natychmiast, po prostu bez odpowiedzi.

_bpp-locations.conf mapuje error_page 403 na return 444. Trafiają tam wyłącznie 403 wygenerowane przez sam nginx: blokada ModSecurity oraz nasze własne deny all (pliki ukryte, kopie zapasowe, /metrics, wykonywalne w /media/).

Nie włączaj proxy_intercept_errors

403 zwracane przez Django (PermissionDenied) nie wpada w error_page, bo proxy_intercept_errors jest domyślnie wyłączone i celowo tego nie zmieniamy. Dzięki temu użytkownik bez uprawnień widzi normalną stronę „brak dostępu", a nie zerwane połączenie. Włączenie tej dyrektywy gdziekolwiek w konfiguracji zepsuje to natychmiast.

Cena 444: fałszywy alarm jest znacznie trudniejszy do zdiagnozowania. Użytkownik nie zobaczy komunikatu, tylko „połączenie przerwane" — i nie ma jak się domyślić, że zatrzymał go WAF. Dlatego dwie ścieżki, o których z góry wiadomo, że będą fałszywie alarmować, są wyjęte z blokowania.

Zawężanie wykluczeń po baseline

Reguły 10002/10003 to tępe narzędzie — wyłączają blokowanie dla całej ścieżki. Docelowo zastąp je celowanymi wykluczeniami:

  1. Zostaw DetectionOnly i zbierz trafienia z audit logu ModSecurity (leci na stdout w formacie JSON — widoczny w Dozzle i w Loki).
  2. Przejrzyj, które reguły zapaliły się na legalnym ruchu. Miejsca, w których to niemal pewne:
    • panel admina dbtemplates — superuser POST-uje surowy HTML, co jest kanonicznym fałszywym alarmem rodziny 941 (XSS),
    • /api/v1/zapytanie/* — składnia DjangoQL przypomina SQL, rodzina 942.
  3. Dla każdego takiego przypadku dopisz wykluczenie w defaults/webserver/modsecurity-override.conf.template — nigdy przez edycję plików CRS, bo rozjedzie się przy aktualizacji obrazu. Pamiętaj o ograniczeniu z sekcji Wykluczenia reguł: plik jest includowany przed regułami CRS, więc użyj własnej reguły z akcją ctl (np. ctl:ruleRemoveTargetById, ctl:auditEngine=Off), a nie SecRuleRemoveById.
  4. Dopiero wtedy ustaw w .env:

    MODSEC_RULE_ENGINE=On
    

    i make run. Wdrażaj instalacja po instalacji, nie wszystkie naraz.

Rollback

Konfiguracja nginksa jest montowana wolumenami, więc powrót to jedna linia:

# docker-compose.infrastructure.yml
image: nginx:1.30.2

…plus przywrócenie mountu default.conf.template prosto do /etc/nginx/templates/ i NGINX_ENVSUBST_FILTER: "DJANGO_BPP_". Wszystkie trzy zmiany muszą wrócić razem — patrz pułapki wyżej.

Co WAF pokrywa, a czego nie

CRS obejmuje m.in. SQL injection (rodzina 942), path traversal i LFI (930), command injection (932), XSS (941), SSTI i SSRF (934), fingerprinty skanerów (913).

Czego nie rozwiązuje: 404 generowanych przez legalne boty wyszukiwarek na martwych URL-ach. Te adresy wyglądają całkowicie normalnie i nginx nie wie z góry, że zapytanie skończy się 404 — to zadanie dla przekierowań 301 po stronie aplikacji, nie dla WAF-a.

Czego też nie rozwiązuje: żądań, które nie są atakiem, tylko nie są nasze — sond o *.php, prefiksów obcych CMS-ów, niepodstawionych literałów szablonów. Dla CRS to zwykłe URL-e i przepuszcza je poprawnie; odcina je osobna, tańsza warstwa — Utwardzenie brzegu.

Pełne uzasadnienie decyzji i odrzucone warianty: docs/superpowers/specs/2026-08-03-waf-owasp-crs-design.md.