--- phase: 04-csrf-protection plan: 01 type: execute wave: 1 depends_on: [] files_modified: - autoload/Shared/Security/CsrfToken.php - autoload/admin/Support/Forms/FormRequestHandler.php - admin/templates/components/form-edit.php - admin/templates/site/unlogged-layout.php - admin/templates/users/user-2fa.php - autoload/admin/App.php - tests/Unit/Shared/Security/CsrfTokenTest.php autonomous: true --- ## Goal Dodać ochronę CSRF do wszystkich state-changing POST endpointów panelu administracyjnego. ## Purpose Brak tokenów CSRF umożliwia atakującemu wymuszenie na zalogowanym adminie wykonania akcji (zapis/usuń/aktualizuj) poprzez spreparowany link lub stronę. Jest to podatność MEDIUM wg concerns.md. ## Output - Nowa klasa `\Shared\Security\CsrfToken` z generowaniem i walidacją tokenu - Integracja w `FormRequestHandler` (walidacja) + `form-edit.php` (token w formularzu) - Integracja w formularzach logowania i 2FA - Test jednostkowy dla CsrfToken ## Project Context @.paul/PROJECT.md @.paul/ROADMAP.md ## Source Files @autoload/admin/Support/Forms/FormRequestHandler.php @admin/templates/components/form-edit.php @admin/templates/site/unlogged-layout.php @admin/templates/users/user-2fa.php @autoload/admin/App.php ## Required Skills (from SPECIAL-FLOWS.md) | Skill | Priority | When to Invoke | Loaded? | |-------|----------|----------------|---------| | /feature-dev | required | Przed APPLY — nowe klasy, zmiany wielu plików | ○ | **BLOCKING:** /feature-dev musi być załadowany przed /paul:apply. ## Skill Invocation Checklist - [ ] /feature-dev loaded (uruchom przed apply) ## AC-1: Formularz edycji chroni przed CSRF ```gherkin Given admin jest zalogowany i otwiera dowolny formularz edycji When formularz jest renderowany Then zawiera ukryte pole _csrf_token z aktualnym tokenem z sesji ``` ## AC-2: Zapis przez formularz bez tokenu jest odrzucany ```gherkin Given admin endpoint odbiera POST z FormRequestHandler When żądanie nie zawiera _csrf_token lub token jest nieprawidłowy Then handleSubmit() zwraca ['success' => false, 'errors' => ['csrf' => '...']] And żadna operacja na danych nie jest wykonywana ``` ## AC-3: Formularz logowania zawiera CSRF token ```gherkin Given niezalogowany użytkownik otwiera stronę logowania /admin/ When strona jest renderowana Then formularz logowania zawiera ukryte pole _csrf_token ``` ## AC-4: special_actions waliduje CSRF dla user-logon i user-2fa-verify ```gherkin Given żądanie POST trafia do special_actions() When s-action to 'user-logon' lub 'user-2fa-verify' Then token jest walidowany przed przetworzeniem danych And brak tokenu kończy się przekierowaniem z komunikatem błędu ``` ## AC-5: Token jest unikalny per sesja ```gherkin Given sesja PHP jest aktywna When CsrfToken::getToken() jest wywołany wielokrotnie Then zwraca ten sam token w ramach jednej sesji And token ma co najmniej 64 znaki hex (32 bajty) ``` Task 1: Utwórz klasę CsrfToken + test jednostkowy autoload/Shared/Security/CsrfToken.php, tests/Unit/Shared/Security/CsrfTokenTest.php Utwórz `autoload/Shared/Security/CsrfToken.php` z namespace `\Shared\Security`: ```php class CsrfToken { const SESSION_KEY = 'csrf_token'; public static function getToken(): string // Jeśli nie ma tokenu w sesji — generuje bin2hex(random_bytes(32)) i zapisuje // Zwraca istniejący lub nowy token public static function validate(string $token): bool // Pobiera token z sesji, używa hash_equals() dla bezpiecznego porównania // Zwraca false jeśli sesja nie ma tokenu lub tokeny się różnią public static function regenerate(): void // Generuje nowy token i nadpisuje w sesji // Używać po udanym logowaniu (session fixation prevention) } ``` Utwórz `tests/Unit/Shared/Security/CsrfTokenTest.php`: - test getToken() zwraca string długości 64 - test getToken() zwraca ten sam token przy kolejnym wywołaniu (idempotency) - test validate() zwraca true dla poprawnego tokenu - test validate() zwraca false dla pustego stringa - test validate() zwraca false dla błędnego tokenu - test regenerate() zmienia token Uwaga PHP < 8.0: brak `match`, brak named arguments, brak union types. Użyj `isset($_SESSION[...])` zamiast `??` na zmiennych sesji w metodach static (sesja musi być started przed wywołaniem). ./test.ps1 tests/Unit/Shared/Security/CsrfTokenTest.php AC-5 satisfied: token unikalny, 64 znaki, idempotentny Task 2: Integracja CSRF w formularzach edycji (form-edit.php + FormRequestHandler) admin/templates/components/form-edit.php, autoload/admin/Support/Forms/FormRequestHandler.php **1. form-edit.php** — dodaj token CSRF jako hidden field zaraz po `_form_id`: ```php ``` Dodaj po linii z `_form_id` (linia ~80). **2. FormRequestHandler::handleSubmit()** — dodaj walidację CSRF jako PIERWSZĄ operację, przed walidacją pól: ```php $csrfToken = isset($postData['_csrf_token']) ? (string)$postData['_csrf_token'] : ''; if (!\Shared\Security\CsrfToken::validate($csrfToken)) { return [ 'success' => false, 'errors' => ['csrf' => 'Nieprawidłowy token bezpieczeństwa. Odśwież stronę i spróbuj ponownie.'], 'data' => [] ]; } ``` Unikaj: modyfikowania logiki walidacji pól — CSRF check to osobny guard przed walidacją. Ręcznie: sprawdź źródło strony formularza edycji — musi zawierać input[name="_csrf_token"]. Testy: ./test.ps1 (suite nie powinna się zepsuć). AC-1 i AC-2 satisfied Task 3: CSRF w formularzach logowania i special_actions admin/templates/site/unlogged-layout.php, admin/templates/users/user-2fa.php, autoload/admin/App.php **1. unlogged-layout.php** — dodaj hidden field CSRF do formularza logowania (zaraz po `s-action`): ```php ``` **2. user-2fa.php** — sprawdź czy jest formularz POST i dodaj analogicznie token CSRF. **3. App::special_actions()** — dodaj walidację CSRF na początku, dla akcji które mają konsekwencje: - `user-logon` — waliduj token, przy błędzie: alert + redirect `/admin/` - `user-2fa-verify` i `user-2fa-resend` — waliduj token - Po udanym logowaniu (`user-logon` case 1) — wywołaj `\Shared\Security\CsrfToken::regenerate()` PRZED `self::finalize_admin_login()` (zapobiega session fixation) Wzorzec walidacji w special_actions (na początku switch lub przed każdym case): ```php $csrfToken = isset($_POST['_csrf_token']) ? (string)$_POST['_csrf_token'] : ''; if (!\Shared\Security\CsrfToken::validate($csrfToken)) { \Shared\Helpers\Helpers::alert('Nieprawidłowy token bezpieczeństwa. Spróbuj ponownie.'); header('Location: /admin/'); exit; } ``` Umieść ten blok PRZED switch ($sa), aby był wspólny dla wszystkich case. Unikaj: dodawania CSRF do user-logout (to GET link, nie POST — zmiana na POST wykracza poza zakres). Ręcznie: sprawdź źródło strony logowania — musi zawierać input[name="_csrf_token"]. ./test.ps1 (suite nie powinna się zepsuć). AC-3 i AC-4 satisfied ## DO NOT CHANGE - Logika walidacji pól w `FormValidator` — tylko dodajemy CSRF guard przed walidacją - Mechanizm sesji w `admin/index.php` — sesja jest już startowana przed wywołaniem kodu - Routing w `admin\App::route()` — nie zmieniamy routingu - Jakiekolwiek pliki frontendowe (front/) — CSRF dotyczy tylko admina w tej fazie - Pliki testów innych niż nowy CsrfTokenTest.php ## SCOPE LIMITS - Nie zmieniać logout z GET na POST — to osobna zmiana wykraczająca poza zakres - Nie dodawać CSRF do admin/ajax.php (shop-category, users ajax) — to osobna iteracja - Nie refaktoryzować FormRequestHandler — tylko dodać CSRF check - Nie zmieniać struktury sesji poza `csrf_token` key Przed uznaniem planu za zakończony: - [ ] ./test.ps1 — wszystkie testy przechodzą (w tym nowe CsrfTokenTest) - [ ] Strona formularza edycji zawiera hidden input[name="_csrf_token"] - [ ] Strona logowania /admin/ zawiera hidden input[name="_csrf_token"] - [ ] POST bez tokenu do FormRequestHandler zwraca error 'csrf' - [ ] Brak regresji w istniejących testach (810 testów nadal przechodzi) - Wszystkie 3 taski wykonane - CsrfTokenTest przechodzi (min. 6 assertions) - Pełna suite testów przechodzi bez regresji - Wszystkie acceptance criteria AC-1 do AC-5 spełnione - Token regenerowany po udanym logowaniu Po zakończeniu utwórz `.paul/phases/04-csrf-protection/04-01-SUMMARY.md`