Files
shopPRO/.paul/phases/04-csrf-protection/04-01-PLAN.md
2026-03-12 13:36:06 +01:00

9.2 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous
phase plan type wave depends_on files_modified autonomous
04-csrf-protection 01 execute 1
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
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)

<acceptance_criteria>

AC-1: Formularz edycji chroni przed CSRF

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

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

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

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

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)

</acceptance_criteria>

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)

<success_criteria>

  • 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 </success_criteria>
Po zakończeniu utwórz `.paul/phases/04-csrf-protection/04-01-SUMMARY.md`