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 dziesięć:

  • 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:10005920280 („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:10007931100 („RFI: URL Parameter using IP Address") 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:10008accept-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:10010953110 („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.

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 , 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.

Dlaczego wolno to wyłączyć

RFI polega 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ła 931100 nie ma tu czego chronić.

Zakres jest przy tym najwęższy z wszystkich naszych wykluczeń: jedna reguła, jeden prefiks. 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 900250nie 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/.

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 decyzyjne949110 (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.tsaddAdHocFilters() 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.templatenigdy 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.