UPDATE
This commit is contained in:
246
.paul/phases/04-csrf-protection/04-01-PLAN.md
Normal file
246
.paul/phases/04-csrf-protection/04-01-PLAN.md
Normal file
@@ -0,0 +1,246 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
<objective>
|
||||
## 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
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
## 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
|
||||
</context>
|
||||
|
||||
<skills>
|
||||
## 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)
|
||||
</skills>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
||||
## 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)
|
||||
```
|
||||
|
||||
</acceptance_criteria>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Utwórz klasę CsrfToken + test jednostkowy</name>
|
||||
<files>autoload/Shared/Security/CsrfToken.php, tests/Unit/Shared/Security/CsrfTokenTest.php</files>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>./test.ps1 tests/Unit/Shared/Security/CsrfTokenTest.php</verify>
|
||||
<done>AC-5 satisfied: token unikalny, 64 znaki, idempotentny</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Integracja CSRF w formularzach edycji (form-edit.php + FormRequestHandler)</name>
|
||||
<files>admin/templates/components/form-edit.php, autoload/admin/Support/Forms/FormRequestHandler.php</files>
|
||||
<action>
|
||||
**1. form-edit.php** — dodaj token CSRF jako hidden field zaraz po `_form_id`:
|
||||
```php
|
||||
<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars(\Shared\Security\CsrfToken::getToken()) ?>">
|
||||
```
|
||||
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ą.
|
||||
</action>
|
||||
<verify>
|
||||
Ręcznie: sprawdź źródło strony formularza edycji — musi zawierać input[name="_csrf_token"].
|
||||
Testy: ./test.ps1 (suite nie powinna się zepsuć).
|
||||
</verify>
|
||||
<done>AC-1 i AC-2 satisfied</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: CSRF w formularzach logowania i special_actions</name>
|
||||
<files>admin/templates/site/unlogged-layout.php, admin/templates/users/user-2fa.php, autoload/admin/App.php</files>
|
||||
<action>
|
||||
**1. unlogged-layout.php** — dodaj hidden field CSRF do formularza logowania (zaraz po `s-action`):
|
||||
```php
|
||||
<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars(\Shared\Security\CsrfToken::getToken()) ?>">
|
||||
```
|
||||
|
||||
**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).
|
||||
</action>
|
||||
<verify>
|
||||
Ręcznie: sprawdź źródło strony logowania — musi zawierać input[name="_csrf_token"].
|
||||
./test.ps1 (suite nie powinna się zepsuć).
|
||||
</verify>
|
||||
<done>AC-3 i AC-4 satisfied</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<boundaries>
|
||||
|
||||
## 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
|
||||
|
||||
</boundaries>
|
||||
|
||||
<verification>
|
||||
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)
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
Po zakończeniu utwórz `.paul/phases/04-csrf-protection/04-01-SUMMARY.md`
|
||||
</output>
|
||||
119
.paul/phases/04-csrf-protection/04-01-SUMMARY.md
Normal file
119
.paul/phases/04-csrf-protection/04-01-SUMMARY.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
phase: 04-csrf-protection
|
||||
plan: 01
|
||||
subsystem: auth
|
||||
tags: [csrf, security, session, admin]
|
||||
|
||||
requires:
|
||||
- phase: []
|
||||
provides: []
|
||||
provides:
|
||||
- "CsrfToken class — token generation, validation, regeneration"
|
||||
- "CSRF protection on all admin FormRequestHandler POSTs"
|
||||
- "CSRF protection on login and 2FA forms"
|
||||
- "Token regeneration after successful login (session fixation prevention)"
|
||||
affects: []
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: ["CSRF guard before field validation in FormRequestHandler", "bin2hex(random_bytes(32)) per-session token"]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- autoload/Shared/Security/CsrfToken.php
|
||||
- tests/Unit/Shared/Security/CsrfTokenTest.php
|
||||
modified:
|
||||
- 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
|
||||
|
||||
key-decisions:
|
||||
- "Single CSRF validate() call placed before switch($sa) in special_actions() — covers all POST actions uniformly"
|
||||
- "regenerate() called on successful login AND after 2FA verify — both session fixation points"
|
||||
|
||||
patterns-established:
|
||||
- "CSRF check = first operation in handleSubmit(), before field validation"
|
||||
- "CsrfToken::getToken() in templates via htmlspecialchars() escape"
|
||||
|
||||
duration: ~
|
||||
started: 2026-03-12T00:00:00Z
|
||||
completed: 2026-03-12T00:00:00Z
|
||||
---
|
||||
|
||||
# Phase 4 Plan 01: CSRF Protection Summary
|
||||
|
||||
**CSRF protection added to entire admin panel — all state-changing POST endpoints now validate a per-session token.**
|
||||
|
||||
## Performance
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Duration | single session |
|
||||
| Completed | 2026-03-12 |
|
||||
| Tasks | 3 completed |
|
||||
| Files modified | 7 |
|
||||
|
||||
## Acceptance Criteria Results
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| AC-1: Formularz edycji zawiera _csrf_token | Pass | form-edit.php linia 81 |
|
||||
| AC-2: POST bez tokenu odrzucany przez FormRequestHandler | Pass | FormRequestHandler.php linia 36–42 |
|
||||
| AC-3: Formularz logowania zawiera _csrf_token | Pass | unlogged-layout.php linia 46 |
|
||||
| AC-4: special_actions() waliduje CSRF dla user-logon i 2FA | Pass | App.php linia 47–51, przed switch |
|
||||
| AC-5: Token unikalny per sesja, min. 64 znaki hex | Pass | bin2hex(random_bytes(32)) = 64 znaków |
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Nowa klasa `\Shared\Security\CsrfToken` z `getToken()`, `validate()`, `regenerate()`
|
||||
- Guard w `FormRequestHandler::handleSubmit()` jako pierwsza operacja przed walidacją pól
|
||||
- Token w szablonach: `form-edit.php`, `unlogged-layout.php`, `user-2fa.php` (oba formularze)
|
||||
- `regenerate()` wywoływany po udanym logowaniu (linia 96) i po weryfikacji 2FA (linia 140) — zapobiega session fixation
|
||||
- 6 testów jednostkowych w `CsrfTokenTest.php`
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Commit | Type | Description |
|
||||
|------|--------|------|-------------|
|
||||
| Wszystkie 3 taski | `55988887` | security | faza 4 - ochrona CSRF panelu administracyjnego |
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
| File | Change | Purpose |
|
||||
|------|--------|---------|
|
||||
| `autoload/Shared/Security/CsrfToken.php` | Created | Token generation, validation, regeneration |
|
||||
| `tests/Unit/Shared/Security/CsrfTokenTest.php` | Created | 6 unit tests dla CsrfToken |
|
||||
| `autoload/admin/Support/Forms/FormRequestHandler.php` | Modified | CSRF guard w handleSubmit() |
|
||||
| `admin/templates/components/form-edit.php` | Modified | Hidden input _csrf_token |
|
||||
| `admin/templates/site/unlogged-layout.php` | Modified | Token w formularzu logowania |
|
||||
| `admin/templates/users/user-2fa.php` | Modified | Token w obu formularzach 2FA |
|
||||
| `autoload/admin/App.php` | Modified | CSRF walidacja w special_actions() + regenerate() |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Decision | Rationale | Impact |
|
||||
|----------|-----------|--------|
|
||||
| Jeden blok validate() przed switch($sa) | Pokrywa wszystkie case jednym sprawdzeniem | Prostota, mniej kodu |
|
||||
| `\Exception` catch (nie `\Throwable`) | PHP 7.4 compat, wystarczy dla typowych wyjątków | Akceptowalny tradeoff |
|
||||
| Logout poza zakresem (GET link) | Zmiana na POST wykracza poza tę fazę | Zostawione do osobnej iteracji |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
Brak — plan wykonany zgodnie ze specyfikacją.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**Ready:**
|
||||
- Cały admin panel chroniony przed CSRF
|
||||
- Wzorzec do replikacji: `CsrfToken::getToken()` w szablonie + `validate()` w handlerze
|
||||
|
||||
**Concerns:**
|
||||
- `admin/ajax.php` (shop-category, users ajax) jeszcze nie pokryty — odnotowane w planie jako out-of-scope
|
||||
|
||||
**Blockers:** None
|
||||
|
||||
---
|
||||
*Phase: 04-csrf-protection, Plan: 01*
|
||||
*Completed: 2026-03-12*
|
||||
Reference in New Issue
Block a user