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):
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/healthzco 10 s) zapalał regułę920350(„Host header is a numeric IP address"), bo w nagłówkuHostjest 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ątrz920350jest 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/passwdzapaliło930120(LFI),932235i932160(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 →949110blokowało wszystko, także zwykłeGET /. Ponieważ vhost reklamujeAlt-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 bezHost:), 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") wyłączone na/o/i/.well-known/. Reguła blokowałaredirect_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-charsetzdję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 wDetectionOnly. 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), potem932115,933210i930110(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 to10.1051/fopen/, afopenjest 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 zfopen.
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:
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:
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:
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
localhostavoids 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:
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 [<>]\(.*\)
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:
- Każdy z tych endpointów wymaga konta ze
staff_member_required— kto tam POST-uje, jest już uwierzytelnionym redaktorem. - Django parametryzuje SQL przez ORM, a szablony auto-escapują HTML; ochrona składniowa CRS jest tu w dużej mierze zdublowaniem.
- 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:
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:
- linię tekstową nginksowego error.log (
ModSecurity: Access denied…), - 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:
- zapaliła się reguła z akcją
auditlog, - 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_requestna/grafana/,/dozzle/,/flower/,/netdata/— a to jest ścieżka zaprojektowana:error_page 401 = @bpp_loginprzekierowuje 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:
Po kliknięciu Grafana wstawia filtr za parserem i wszystko działa:
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¶
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ślnie18443),MODSEC_RULE_ENGINE— ustawDetectionOnly, ż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:
- ten sam payload wystrzelony poza
/grafana/musi zostać zablokowany, - na
/grafana/api/ds/querymusi 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:
444przyHTTP/3.0w logu — realna blokada WAF-a, czyliFAIL,- 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:
- Zostaw
DetectionOnlyi zbierz trafienia z audit logu ModSecurity (leci na stdout w formacie JSON — widoczny w Dozzle i w Loki). - 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.
- panel admina
- 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 nieSecRuleRemoveById. -
Dopiero wtedy ustaw w
.env:i
make run. Wdrażaj instalacja po instalacji, nie wszystkie naraz.
Rollback¶
Konfiguracja nginksa jest montowana wolumenami, więc powrót to jedna linia:
…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.