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 dwanaście:
-
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") i934110(SSRF, od CRS 4.29) 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. -
id:10011— tytuł raportu multiseek bez reguł, które nie mają tu sinka. Polasuggested-title(/bpp/build_search/) ivalue(/bpp/update-multiseek-title/) nie są skanowane rodzinami 930/931/932/933/942, bo tytuł trafia wyłącznie do sesji i do HTML-a. XSS (941) zostaje. Zgłoszenie 2026-09-13: „Szukaj publikacji" na stronie jednostki… [09.2024...] (WBIOTECH)→933210→ zerwane połączenie. Szczegóły: Tytuł raportu multiseek. -
id:10012— ciasteczko PostHoga z dashboardu Netdaty poza CRS. Frontend Netdaty zapisuje na całą domenę ciasteczkoph_phc_…_posthogz JSON-em analitycznym; na CRS 4.25 klucz$initial_person_infozapalał942290i cała witryna przestawała działać dla tej przeglądarki na rok. Serwer tego ciasteczka nie czyta, więc zdejmujemy z niego cały CRS — i tylko z niego. Szczegóły: Ciasteczko PostHoga z Netdaty.
Reguły 10002 i 10003 schodzą dla swoich ścieżek do ctl:ruleEngine=DetectionOnly:
trafienia nadal trafiają do audit logu (i posłużą do napisania precyzyjnych
wykluczeń), ale nikomu nie urywają połączenia. To tępe narzędzie na start —
patrz Zawężanie wykluczeń po baseline.
Reguła 10004 jest węższa: zdejmuje tylko reguły wychodzące, więc żądania przychodzące do paneli są nadal w pełni blokowane.
ctl:responseBodyAccess nie istnieje w libmodsecurity v3
Naturalnym zapisem dla 10004 byłoby ctl:responseBodyAccess=Off, ale
silnik tej akcji nie zna i nginx z nią nie wstaje:
Expecting an action, got: ctl:responseBodyAccess. Sprawdzone na
owasp/modsecurity-crs:nginx (ModSecurity v3.0.16). Z akcji ctl działają
m.in. ruleEngine, auditEngine, ruleRemoveById (także z zakresem A-B)
i ruleRemoveByTag.
Reguły wychodzące — inspekcja ciała odpowiedzi¶
Poza regułami sprawdzającymi żądanie CRS ma rodzinę RESPONSE-95x, która
skanuje ciało odpowiedzi w poszukiwaniu wycieków: komunikatów błędów
PHP/SQL/Javy, listingów katalogów, web shelli. Obraz ma je włączone domyślnie
(MODSEC_RESP_BODY_ACCESS=on, typy text/plain text/html text/xml, limit
1 MiB) i my tego nie zmieniamy — dlatego trzeba o nich wiedzieć.
Ta rodzina ma dwie własności, które łatwo przeoczyć:
1. Próg wyjściowy wynosi 4, a jedno trafienie poziomu ERROR to już 4 punkty.
Zmienna ANOMALY_OUTBOUND=4 (wobec ANOMALY_INBOUND=5). Reguła o
severity:ERROR dokłada dokładnie 4 — więc pojedyncze trafienie od razu
przekracza próg i 959100 kończy deny. Reguły wychodzące nie mają zapasu,
jaki mają wejściowe.
2. Blokowanie wychodzące nie blokuje czysto — psuje odpowiedź.
959100 działa w phase:4, czyli na ciele odpowiedzi, gdy nginx zwykle
wysłał już nagłówki. Nie da się wtedy zwrócić 403, a więc i naszego
444. Zamiast blokady dostajemy:
"GET /grafana/ HTTP/2.0" 500 0 1378 0.038
[error] ModSecurity: Access denied with code 403 (phase 4) ... while sending to client
[alert] header already sent while sending to client
Użytkownik widzi urwaną albo pustą stronę, a w access logu zostaje 500 0.
Objaw nie wskazuje na WAF w żaden sposób.
Dlaczego panele są z tego wyjęte (reguła 10004)¶
Potwierdzony przypadek ze stagingu, 2026-08-03: /grafana/ było nie do
otwarcia. Winna reguła 953100 „PHP Information Leakage", która używa
operatora @pmFromFile php-errors.data — dopasowania po podciągu, bez
granic słowa i bez rozróżniania wielkości liter. Wtedy w tym pliku danych był
wpis SQLConnect (funkcja ODBC z PHP), a Grafana wstrzykuje w index.html
obiekt window.grafanaBootData z kluczem "sqlConnectionLimits". Ciąg
sqlConnect siedzi w jego środku, więc reguła zapalała się na każdej
stronie Grafany.
Ten konkretny ciąg już nie zapala reguły — i to jest argument ZA wykluczeniem, nie przeciw
Zmierzone ponownie 2026-08-12 na aktualnym obrazie: ciało z
window.grafanaBootData i sqlConnectionLimits przechodzi bez żadnego
trafienia. Wpisu SQLConnect nie ma już w php-errors.data (dziś 218
wpisów, wszystkie długie frazy). Sama reguła działa — kontrolne ciało
z Cannot redeclare class / Stack trace: nadal daje 953100 + 959100.
Zmieniły się więc dane upstreamu, nie nasza konfiguracja:
owasp/modsecurity-crs:nginx to tag pływający (patrz „Obraz CRS to tag
pływający" w Konfiguracji), a wraz z przebudową obrazu
przyjeżdżają nowe pliki *.data. Ten sam mechanizm zaskoczył nas już przy
regule 920430 (2026-08-05).
Wniosek dla wykluczeń: nie zawężaj ich do jednego zaobserwowanego
ciągu. Uzasadnieniem 10004 nie jest „SQLConnect trafia w
sqlConnectionLimits", tylko to, że za panelami stoi cudzy dashboard,
którego HTML-a nie ma sensu skanować — a jego kolejna wersja, tak jak
kolejna wersja pliku danych CRS, może przynieść nowy fałszywy alarm.
Za /grafana/, /dozzle/, /flower/ i /netdata/ nie stoi żadna aplikacja
BPP — stoi gotowy dashboard innego producenta, dostępny wyłącznie dla superusera
(auth_request /_bpp_superuser_auth). Skanowanie jego HTML-a pod kątem wycieku
błędów PHP nie chroni przed niczym.
Zmierzone na prawdziwych obrazach paneli, przez prawdziwą konfigurację
z defaults/webserver/:
| Panel | Sprawdzone strony | Reguły wychodzące |
|---|---|---|
| Grafana | /, /dashboards, /connections/datasources, /explore |
953100 + 959100 → blokada |
| Dozzle | / |
czysto |
| Flower | /, /tasks, /broker, /worker/x |
czysto |
| Netdata | /, /v3/index.html (100 KB) |
czysto |
Wykluczenie obejmuje mimo to wszystkie cztery — są tej samej natury, a kolejna wersja każdego z nich może wnieść własny fałszywy alarm.
Aplikacja BPP nadal jest skanowana
10004 dotyczy wyłącznie czterech ścieżek paneli. Odpowiedzi Django lecą
przez reguły wychodzące jak dotąd — i tam mają sens, bo to nasz kod może
wypluć komunikat błędu bazy danych. Jedynym wyjątkiem jest 953110, zdjęta
globalnie przez regułę 10010.
DOI z fopen — reguła 10010¶
Zgłoszenie 2026-08-12: GET /api/v1/zrodlo/ kończył się urwaną odpowiedzią.
Czasopismo 4open (EDP Sciences) ma w bazie DOI 10.1051/fopen/. Reguła
953110 („PHP source code leakage") skanuje ciało odpowiedzi wzorcem nazw
funkcji PHP — fopen, fread, fwrite, fgets, gzopen, scandir,
readfile, proc_open, call_user_func, $_GET… Granice słowa są, ale
ukośniki to znaki niesłowne, więc fopen jest w tym DOI pełnoprawnym tokenem.
Odtworzone lokalnie na realnym ciele odpowiedzi z produkcji, na tym samym obrazie i tej samej konfiguracji:
ruleId=953110 sev=3 PHP source code leakage
data: Matched Data: fopen found within RESPONSE_BODY: …
ruleId=959100 sev=0 Outbound Anomaly Score Exceeded (Total Score: 4)
Severity ERROR = 4 pkt przy progu wyjściowym 4 — pojedyncze trafienie od razu
blokuje.
Zmierzone skutki:
| Żądanie | Wynik |
|---|---|
/api/v1/zrodlo/ + Accept: application/json |
200, 5733 B — działa |
/api/v1/zrodlo/ + Accept: text/html |
200 + Content-Length: 18014, dojechało 15588 B |
/api/v1/zrodlo/4276/ + Accept: text/html |
połączenie zerwane, 0 B |
JSON przechodzi, bo application/json nie jest na liście
MODSEC_RESP_BODY_MIMETYPE (text/plain text/html text/xml) — klienty API
i harvestery nie ucierpiały, zepsuty był przeglądarkowy widok DRF.
Dlaczego globalnie, a nie na /api/¶
To nie jest problem ścieżki, tylko danych. Wyzwalaczem jest wartość w bazie, a nie adres — ten sam DOI wyrenderuje się wszędzie, gdzie pokazujemy to źródło lub publikację z niego: listy, detale rekordów, raporty, eksporty HTML. Wykluczenie po prefiksie URI byłoby grą w kreta, za każdym razem z objawem, który w niczym nie wskazuje na WAF.
Dlaczego wolno zdjąć całą regułę¶
953110 wykrywa wyciek kodu źródłowego PHP. W BPP nie ma PHP — ani
interpretera, ani plików, ani modułu w nginksie; stos to Django/Python. Reguła
nie ma tu czego chronić, więc jedyne, co może wyprodukować, to fałszywe alarmy
na danych bibliograficznych. Żądania o *.php są dodatkowo odcinane 444 już
na brzegu (Utwardzenie brzegu).
Siostrzane reguły zostają
953100 (@pmFromFile php-errors.data, komunikaty błędów PHP) i 953120
(@rx <\?(?!xml), znaczniki <?php) działają dalej. Zmierzone 2026-08-12:
dzisiejszy php-errors.data ma 218 wpisów i wszystkie są długimi frazami
(„PDO: driver", „Cannot redeclare class", „Stack trace:"), więc na danych
bibliograficznych się nie zapalają.
Dlaczego Grafana jest wyjęta w całości (reguła 10006)¶
Reguła 10004 zdejmowała dla Grafany tylko reguły wychodzące. To okazało się za mało — ruch przychodzący do Grafany jest fałszywym alarmem z definicji, a nie przez przypadek.
Objaw¶
Wszystkie panele wszystkich dashboardów pokazują „No data". Sama Grafana działa normalnie: strona się ładuje, dashboardy się otwierają, listy zmiennych się wypełniają. Puste są wyłącznie dane.
Powód: Grafana pobiera dane POST-em na /grafana/api/ds/query, a to
właśnie te żądania dostawały 403. Zwykłe GET-y (HTML, JS, listy etykiet)
przechodziły, więc nic nie wyglądało na zepsute.
Przyczyna¶
Ciało tego POST-a to zapytanie LogQL. Przy Log Level = All zmienna
$level rozwija się do wyliczenia wszystkich poziomów:
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.
CRS 4.29: ta sama pętla zwrotna, nowa reguła — 934110 (SSRF)¶
Obraz owasp/modsecurity-crs:nginx z 2026-09-24 przyniósł CRS 4.29, w którym
adres http://127.0.0.1 łapie już nie 931100, tylko 934110 („Possible
Server Side Request Forgery"). Zapala się na każdym kroku logowania:
redirect_uri na /o/authorize/ i /o/token/,
json.redirect_uris.array_0 w DCR, a także resource= w discovery
(/.well-known/oauth-protected-resource) — przypadek, na który 10007 była
przygotowana zawczasu. make test-waf wykrył to od razu: cztery przypadki
OAuth na czerwono.
Reguła 10007 zdejmuje więc obie: 931100 zostaje dla instalacji, które
jeszcze nie pobrały nowego obrazu (tag jest pływający, każda instalacja
przechodzi na nowy CRS przy własnym make pull), 934110 jest dla 4.29+.
Dlaczego wolno to wyłączyć¶
RFI i SSRF polegają na tym, że serwer pobiera zasób spod adresu podanego
przez atakującego. django-oauth-toolkit nigdy nie odpytuje redirect_uri —
porównuje go ze zbiorem URI zarejestrowanych dla klienta i najwyżej odsyła 302.
Reguły 931100 i 934110 nie mają tu czego chronić.
Zakres jest przy tym jeden z najwęższych: dwie reguły tej samej natury
(adres w argumencie), jedna powierzchnia. Nie ruleEngine=Off jak w 10006 i nie całe rodziny jak
w 10004 — na /o/ nadal w pełni obowiązują 941 (XSS), 942 (SQLi), 930 (LFI),
932 (RCE) i blokada progowa 949110.
Nie rozszerzać na całą witrynę
?q=http://127.0.0.1/... poza /o/ ma dalej dostawać blokadę. Pilnują tego
dwie asercje kontrolne w make test-waf (GET i POST): bez nich przypadki
„OAuth przechodzi" byłyby zielone także po skasowaniu reguły 10007, a nawet
po wyłączeniu całego CRS — czyli test nie dowodziłby niczego. Sprawdzone:
z wyłączoną regułą padają dokładnie 3 przypadki OAuth, obie kontrole
zostają zielone.
Nagłówki zakazane polityką — reguła 10008¶
Objaw¶
Użytkownik dostaje blokadę na całkowicie poprawnym adresie — zgłoszenie
2026-08-07 dotyczyło /bpp/rekord/, czyli istniejącego rekordu. W dashboardzie
WAF-a widać przy tym URI żądania, więc trop prowadzi donikąd: z adresem jest
wszystko w porządku.
Przy diagnozie zgłoszeń typu «zablokowało mi normalny URL»
920450 to jedyna reguła CRS, która blokuje bez żadnego związku
z treścią żądania — nie patrzy ani na ścieżkę, ani na argumenty, ani na
ciało. Jeśli URI wygląda niewinnie, sprawdzaj ją pierwszą.
Przyczyna¶
Reguła porównuje nazwy nagłówków z listą tx.restricted_headers.
Domyślnie są na niej:
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/.
Stan na 2026-08-23, jedna instancja (bpp.umlub.pl). 2026-09-13
933210 zablokowało na publikacje.up.lublin.pl publiczne
/bpp/build_search/ — inna uczelnia, inne nazwy jednostek. Patrz
reguła 10011.
Tytuł raportu multiseek — reguła 10011¶
Pola niosące tytuł raportu wyszukiwania nie są skanowane regułami, dla których ta wartość nie ma dokąd trafić. Ochrona przed XSS zostaje.
Objaw¶
Na stronie jednostki (autora, źródła, uczelni) przycisk „Szukaj publikacji" albo „Pokaż wszystkie publikacje" kończy się natychmiast zerwanym połączeniem — bez komunikatu, bez śladu w logach Django:
"POST /bpp/build_search/ HTTP/2.0" 444 0 254 0.000 - "https://…/bpp/jednostka/Katedra-Inzynierii-i-Technologii-Zboz-092024/"
Audit log:
933210 PHP Injection Attack: Variable Function Call Found
Matched Data: [09.2024...] (WBIOTECH) found within ARGS:suggested-title:
Katedra Inżynierii i Technologii Zbóż [09.2024...] (WBIOTECH)
949110 Inbound Anomaly Score Exceeded (Total Score: 10)
Blokowały się wyłącznie strony, których obiekt ma w nazwie taki wzorzec. Pozostałe działały, więc zgłoszenie brzmiało „nie działa dla tej jednej katedry".
Przyczyna¶
Szablony browse/{jednostka,autor,zrodlo,uczelnia}.html wkładają do pola
suggested-title {{ obiekt }}, czyli __str__ modelu. Dla jednostki to
nazwa + (skrót wydziału), a nazwa bywa zakończona datą w nawiasie
kwadratowym. 933210 szuka […] albo (…), po którym stoi (…). W PHP to
$f['x']('arg'), a w bibliografii — każda nazwa z datą i skrótem.
Score wynosi 10, nie 5, bo formularz wyszukiwania wysyła pole dwa razy (ukryte i edytowalne „Tytuł raportu"). Każde trafienie daje 5 pkt przy progu 5.
Ten sam tytuł można potem edytować w wynikach. To POST pola value na
/bpp/update-multiseek-title/ i blokuje się tam identycznie.
Dlaczego zakres wyznaczają sinki¶
| Rodzina | Tag | Zdjęta z tytułu? | Dlaczego |
|---|---|---|---|
| 930 LFI | attack-lfi |
tak | tytuł nie jest ścieżką pliku |
| 931 RFI | attack-rfi |
tak | tytuł nie jest pobierany ani includowany |
| 932 RCE | attack-rce |
tak | nie trafia do powłoki |
| 933 PHP | attack-injection-php |
tak | w BPP nie ma PHP |
| 942 SQLi | attack-sqli |
tak | sesja siedzi w Redisie (SESSION_ENGINE = cache), nie w SQL-u |
| 941 XSS | attack-xss |
nie | tytuł jest renderowany |safe |
XSS zostaje, bo to jedyny realny sink. Tytuł przechodzi przez nh3.clean,
ale potem jest renderowany |safe (multiseek/title.html). Oba endpointy są
przy tym @csrf_exempt, więc obca strona może podstawić tytuł do sesji
ofiary. WAF jest tu drugą warstwą za nh3 i powinien nią zostać.
Dlaczego nie inne warianty¶
ctl:ruleRemoveTargetById=933210. Tytuł to wolny tekst: wpisuje go użytkownik, a domyślnie są to nazwy jednostek, autorów i czasopism z całej bazy. Dane mają tę samą naturę co streszczenia w 10009, gdzie lista ID przegrała trzy razy w jedenaście dni.ctl:ruleEngine=DetectionOnlyna ścieżce, jak w 10009. Granica 10009 biegnie po uwierzytelnieniu, a tu jej nie ma. Oba endpointy są anonimowe, więc pozostałe pola (jednostka,autor,zrodlo,rok,typ) mają zostać w pełni chronione.
Cena i kontrole w make test-waf¶
| Wynik | Przypadek |
|---|---|
| PASS | nazwa jednostki z produkcji na /bpp/build_search/ i na /bpp/update-multiseek-title/ |
PASS — CENA: |
realne RCE i SQLi w tytule raportu |
| BLOK | XSS w suggested-title i w value |
| BLOK | realne RCE z grupy CENA w polu jednostka (wykluczenie dotyczy argumentu, nie ścieżki) |
| BLOK | to samo RCE w suggested-title na innej ścieżce (wykluczenie dotyczy ścieżki, nie nazwy pola) |
Kontrole granic używają payloadu z grupy CENA, a nie nazwy jednostki: CRS 4.29
przestał łapać [..] (..) w 933210, więc na nowym obrazie kontrola na
nazwie jednostki przechodziła, niczego nie dowodząc.
Po stronie BPP da się to załatwić u źródła
Domyślny tytuł nie musi jechać z przeglądarki. Widok może ustalić go sam
z PK (jednostka, autor, zrodlo), a pole „Tytuł raportu" może być
puste, z nazwą jako placeholder. Wtedy WAF widzi nazwę tylko wtedy, gdy
użytkownik świadomie ją wpisze. Reguła 10011 zostaje i tak, bo tytuł
wpisany ręcznie nadal może zawierać nawiasy.
Ciasteczko PostHoga z Netdaty — reguła 10012¶
Objaw¶
Po wejściu w /netdata/ przeglądarka nie otwiera już żadnej strony
serwisu — GET / też kończy się 444, a w logach Django nie ma nic.
Inna przeglądarka albo okno prywatne działa normalnie. Zgłoszenie
2026-09-25 (bpp.ihit.waw.pl), w audit logu:
942290 Finds basic MongoDB SQL injection attempts
Matched Data: $in found within REQUEST_COOKIES:ph_phc_hnhlqe6D2Q4IcQNrFItaqdXJAxQ8RcHkPAFAp74pubv_posthog:
{"$device_id":"…","$sesid":[…],"$initial_person_info":{…},"$user_state":"anonymous"}
Przyczyna¶
Dashboard Netdaty (frontend ich chmury, serwowany przez agenta) ma wbudowanego
PostHoga. DISABLE_TELEMETRY=1 w compose wyłącza telemetrię agenta, nie
tego skryptu. PostHog zapisuje na całą domenę ciasteczko z JSON-em,
w którym klucze zaczynają się od $. Na CRS 4.25 wzorzec 942290 nie miał
granicy słowa, więc $in z początku $initial_person_info liczył się jako
operator MongoDB $in — 5 pkt przy progu 5. Przeglądarka wysyła ciasteczko
z każdym żądaniem, a PostHog ustawia je na rok.
Doraźnie: usunąć dane witryny w przeglądarce (Safari: Ustawienia → Prywatność → Zarządzaj danymi witryn).
Dlaczego wykluczenie, skoro CRS 4.29 to naprawił¶
CRS 4.29 dopisał do wzorca 942290 \b i ta konkretna wartość przestała
się zapalać. Wykluczenie zostaje, bo:
- tag obrazu jest pływający — instalacje przechodzą na 4.29 każda przy swoim
make pull, do tego czasu 4.25 blokuje; - to JSON analityczny z
$-kluczami i pełnymi URL-ami; kolejna zmiana dowolnej rodziny (942, 931, 934, 941) może zrobić to samo, a objaw jest najgorszy z możliwych — cała witryna martwa dla jednej przeglądarki.
Zakres i dlaczego wolno¶
Cały CRS (ctl:ruleRemoveTargetByTag=OWASP_CRS), ale wyłącznie dla tego
jednego ciasteczka. Ani Django, ani agent Netdaty go nie czytają — to stan
klienta PostHoga, payload nie ma dokąd trafić.
Dokładna nazwa, nie regex — i to nie z wyboru
Lekser akcji ctl w libmodsecurity v3.0.16 odrzuca w celu znaki ^,
\, + i [ — nginx nie wstaje (Expecting an action, got:
^ph_phc_…). Zakotwiczonego wzorca nie da się zapisać, a regex bez kotwic
łapałby także x_ph_phc_…_posthog. Środek nazwy to klucz projektu PostHoga
zaszyty we frontendzie Netdaty. Objaw, że go zmienili: znów „po wejściu
w /netdata/ nic nie działa", a w audit logu inna nazwa ciasteczka.
Wtedy dopisać drugą akcję ctl z nową nazwą; starej nie usuwać, bo
przeglądarki trzymają ciasteczko rok.
Kontrole w make test-waf¶
| Wynik | Przypadek |
|---|---|
| PASS | wartość z produkcji na / i na /admin/login/ (na 4.29 już tylko regresja) |
PASS — CENA: |
realne SQLi w ciasteczku PostHoga |
| BLOK | to samo SQLi w sessionid i w csrftoken obok ciasteczka PostHoga |
| BLOK | nazwa z przedrostkiem x_ i z przyrostkiem _x |
Kontrole celowo nie używają wartości z produkcji — na 4.29 ona nigdzie nie blokuje, więc kontrola na niej przechodziłaby niczego nie dowodząc.
Logi WAF-a w Grafanie¶
Jest gotowy dashboard WAF (ModSecurity / OWASP CRS) — rankingi reguł, adresów IP i ścieżek, kategorie ataków, rozkład anomaly score. Poniżej opis danych, na których stoi.
Dwa wpisy na jedno żądanie¶
Każde oflagowane żądanie zostawia w stdout webservera dwa wpisy:
- 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.