Skip to content

fix(security): hardening z równoległego security review (6 findingów)#610

Open
mpasternak wants to merge 4 commits into
devfrom
fix/security-hardening-236
Open

fix(security): hardening z równoległego security review (6 findingów)#610
mpasternak wants to merge 4 commits into
devfrom
fix/security-hardening-236

Conversation

@mpasternak

Copy link
Copy Markdown
Member

Wynik równoległego security review (5 sub-agentów, attack-surface newralgiczny: auth · API+PII · DjangoQL · upload/import · injection/XSS/sekrety). Sześć potwierdzonych findingów, każdy z repro-testem (TDD: RED bez fixu → GREEN po).

Findingi

# Sev Problem Fix
1 HIGH Adminowa wyszukiwarka DjangoQL (BppQLSchema, brak allow-listy) schodziła przez reverse-relację Autor.user do BppUserpbn_token (żywy token PBN), email, is_superuser były filtrowalne → blind oracle: redaktor eksfiltruje cudzy (nawet superusera) token PBN znak-po-znaku (user.pbn_token startswith "…"). API/web nie były podatne (allow-lista). Override excluded() wykluczający get_user_model() z całego schematu (nie atrybutem exclude — konflikt z include podklasy).
2 MED Stored-XSS: admin „Przemapowanie prac autora" budował HTML f-stringiem z nieescapowanym tytułem/źródłem/wydawnictwem publikacji (niezaufany HTML) i mark_safe-ował. escape() na wartościach przed interpolacją.
3 MED IDOR: /api/v1/autor_jednostka/ zwracał zatrudnienie (jednostka, daty, PK autora) także autorów ukrytych (pokazuj=False), obchodząc AutorViewSet. get_queryset() filtruje autor__pokazuj=True dla anonima.
6 MED /api/v1/zapytanie/* (ciężki multi-join DjangoQL) bez throttlingu → saturacja DB. throttle_classes = [SearchUserThrottle] (endpointy wymagają logowania).
4 MED SECRET_KEY fallbackował po cichu do placeholdera z repo, gdy env nieustawiony → produkcja startowała z publicznie znanym kluczem (forgowanie sesji/tokenów). Fail-closed raise ImproperlyConfigured w production.py (importowany w każdym procesie — inaczej niż system-checki, które nie odpalają się na daphne). Build-owy dummy świadomie przepuszczony.
5 MED Import XLSX bez limitu rozmiaru pliku + load_workbook bez read_only → „gruby" plik (setki MB) wciągał cały skoroszyt do RAM → OOM workera. Limit rozmiaru (sprawdz_rozmiar_pliku, 100 MB) przed load + read_only=True (strumieniowy odczyt). Zweryfikowane, że cała ścieżka parsowania tylko czyta.

Przy okazji

Naprawiony pre-existing flaky test snapshotu #605: porównywał plik XLSX bajt-w-bajt, a openpyxl stempluje docProps/core.xml aktualnym czasem przy każdym save(). Zmienione na porównanie zawartości (nagłówki + wiersze).

Weryfikacja

  • Każdy repro-test RED przed fixem → GREEN po.
  • Pełne suity bez regresji: import_common + import_pracownikow (1028), api_v1 + przemapuj + admin DjangoQL.
  • ruff check + ruff format czyste; 6 newsfragmentów (bugfix, PL).

Poza zakresem

Drobne LOW z review (brak AUTH_PASSWORD_VALIDATORS na ścieżce admin/CLI, TOCTOU w rate-limicie resetu hasła, FileExtensionValidator na plik_xls, ekspozycja pól uwagi/nie-public_) — do osobnego przejścia lub świadomego pominięcia.

🤖 Generated with Claude Code

mpasternak and others added 4 commits July 14, 2026 23:37
…hrottling DjangoQL

Trzy poprawki z równoległego security review (attack-surface newralgiczny):

- #2 stored-XSS: admin „Przemapowanie prac autora" budował HTML f-stringiem
  z nieescapowanym tytułem/źródłem/wydawnictwem publikacji (niezaufany HTML
  z importu/zgłoszenia) i mark_safe-ował go. Dodano escape() na wartościach.
  Ta sama klasa co wcześniejszy fix XSS w kolejce PBN, nieobjęta nim.

- #3 IDOR: /api/v1/autor_jednostka/ zwracał powiązania zatrudnienia (jednostka,
  daty pracy, funkcja, PK autora) także dla autorów ukrytych (pokazuj=False),
  obchodząc pokazuj=False z AutorViewSet. Dodano filtr autor__pokazuj=True dla
  anonima (analogicznie do AutorViewSet — zalogowany widzi wszystkich).

- #6 DoS: /api/v1/zapytanie/* (ciężki multi-join DjangoQL) nie miał throttlingu.
  Dodano SearchUserThrottle (endpointy wymagają logowania → throttle po userze).

Repro-testy (TDD) dodane dla każdej luki.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oken)

#1 (HIGH) z security review. Adminowa wyszukiwarka DjangoQL (BppQLSchema) nie
ma allow-listy modeli (ma ją tylko podklasa BppQLSchemaOgraniczony używana
przez API/web), więc DjangoQL rekurencyjnie schodził przez reverse-relację
Autor.user do BppUser. Domyślny filtr djangoql wycina tylko pole `password`,
więc pbn_token (żywy token PBN, CharField 128), email, is_superuser, is_staff,
last_login zostawały jako pola FILTROWALNE.

To blind oracle: `user.pbn_token startswith "…"` zwraca wiersze wtw. gdy
prefiks pasuje → zalogowany redaktor (grupa „wprowadzanie danych") eksfiltruje
cudzy (nawet superusera) token PBN znak-po-znaku → impersonacja w PBN. Także
enumeracja superuserów przez `user.is_superuser = True`.

Fix: override excluded() w BppQLSchema wykluczający get_user_model() z CAŁEGO
schematu. Robione w excluded(), NIE atrybutem `exclude`, bo djangoql zabrania
include+exclude naraz, a podklasa ustawia include. Cięcie na poziomie modelu
łapie każdą ścieżkę do BppUser (nie tylko Autor.user) i przyszłe pola.

API i publiczny /zapytanie/ NIE były podatne (allow-lista SEARCH_ALLOWLIST bez
BppUser). Repro-test: bez fixu filtr po user.pbn_token przechodzi (oracle), po
fixie jest odrzucany jako nieznane pole.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#4 (MEDIUM) z security review. SECRET_KEY z base.py fallbackował po cichu do
jawnego sentinela z repo, gdy env DJANGO_BPP_SECRET_KEY nie był ustawiony —
aplikacja startowała z publicznie znanym kluczem (ryzyko forgowania sesji i
tokenów resetu hasła → przejęcie konta). Brak jakiegokolwiek hard-raise ani
system-checka (jedyny z trójki SECRET_KEY/ALTCHA/DSPACE bez żadnego guardu).

Fix: raise ImproperlyConfigured w production.py na placeholder. Świadomie w
production.py, NIE system-checkiem: checki nie odpalają się na daphne/wsgi
(get_asgi_application → django.setup bez checków), więc gate'owałyby tylko
migrate; production.py jest importowany w KAŻDYM procesie (web/celery/migrate)
→ prawdziwe fail-closed. Predykat (secret_key_guard) to czysta funkcja: łapie
pusty klucz, nieustawiony env (sentinel) i .env.docker bez zmian (ZMIEN...),
a świadomie PRZEPUSZCZA build-owy dummy (collectstatic w obrazie), żeby nie
wywalić builda. Dev/test na local.py — nietknięte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tu XLSX

#5 (MEDIUM) z security review. Import XLSX nie miał twardego limitu ROZMIARU
pliku, a load_workbook czytał bez read_only → „gruby" formalnie-legalny plik
(setki MB, po dekompresji pod progiem bomb-checka) wciągał cały skoroszyt do
RAM i zabijał workera importu (OOM DoS; gated grupą „wprowadzanie danych").

Fix:
- sprawdz_rozmiar_pliku() + PlikZaDuzyException: odrzuca plik > 100 MB PRZED
  load, obok istniejącego bomb-checka (oba importery: znajdz_naglowek,
  XLSImportFile).
- load_workbook(read_only=True): strumieniowy odczyt (iteracja rows +
  cell.value) bez ładowania całego skoroszytu do RAM. Zweryfikowane, że cała
  ścieżka parsowania tylko czyta (brak ws.cell()/ws['A1']/.max_row/merged) i
  że realne pliki (w tym wieloarkuszowe) parsują się poprawnie.

Przy okazji: test_zapisz_snapshot_* porównywał snapshot XLSX bajt-w-bajt —
flaky, bo openpyxl stempluje docProps/core.xml aktualnym czasem przy każdym
save(). Zmienione na porównanie ZAWARTOŚCI (nagłówki + wiersze) + sanity na
komórkach. read_only spowalniał parsowanie o ułamek s i odsłaniał tego
istniejącego flake'a częściej.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant