# Security review — legacy `/api` → `/api2`

## 0. ДІЯТИ НЕГАЙНО: ротація секретів

Усі перелічені секрети були **закомічені у відкритому вигляді** в легасі-репозиторії
(`essentials/config.php`, `essentials/sqlConnect.php`, `essentials/mailHook.php`,
`study/lmsEvaluate.php`, `ai/aiWorker.php`, `accounts/sendNotification.php`).
Вважайте їх скомпрометованими — **ротуйте до або під час викату `/api2`**:

| Секрет | Де був | Дія |
|---|---|---|
| MySQL пароль (користувач `mf`, пароль у `sqlConnect.php`) | `sqlConnect.php` | новий пароль БД, оновити `.env` |
| monobank API token | `config.php` (`$MONO_API_TOKEN`) | перевипустити в кабінеті mono |
| Google OAuth client secret | `config.php` | перевипустити в Google Cloud Console |
| Telegram bot token | `config.php`, `mailHook.php` | `/revoke` у @BotFather, новий токен |
| Centrifugo API key | `config.php` | новий ключ у `/etc/centrifugo/config.json` |
| Gemini API key | `aiWorker.php` | перевипустити в Google AI Studio |
| `lmsEvaluate` shared secret (значення закомічене в `lmsEvaluate.php`) | `lmsEvaluate.php` | нове значення, узгодити з LMS |
| Mail webhook token (`JUYFAYD...`) | `mailHook.php` | нове значення, оновити на поштовому сервісі |
| `sendNotification` key (`f2b7a1e8...`) | `sendNotification.php` | нове значення |

У `/api2` всі вони живуть **тільки** в `.env` (не в гіті — див. `.gitignore`).

---

## 1. SQL-ін'єкції (критично) — виправлено

Легасі майже скрізь інтерполював вхідні дані прямо в SQL:

```php
mysqli_query($conn, "SELECT * FROM users WHERE email='{$mail}'");        // worker.php
mysqli_query($conn, "SELECT * FROM users WHERE hash='{$hash}'");         // auth by session? -> injectable
mysqli_query($conn, "... WHERE testID='{$testID}' AND inOrder='{$q['id']}'"); // testWorker.php
"UPDATE results SET disabled='1' WHERE userID='{$userID}'";              // resetStats.php
"DELETE FROM results_hw WHERE hwCode = '{$code}'";                        // deleteHw.php
```

Уразливі напряму через параметри запиту: `getUser` (email/hash/appleToken/id),
`getMaterial`, `getTopics`, `getHwTest`, `deleteHw`, `resetStats`, `makeRerun`,
`getTestBio` (частково екранований), `editor/*`, `randomTest`, `test.php` та ін.

**У `/api2`:** увесь доступ до БД — через Laravel Query Builder / Eloquent з
прив'язкою параметрів. Сирих рядкових запитів немає. `LIKE`-патерни для пошуку
формуються біндами. Ідентифікатори (`{subject}`, `{slug}`) додатково валідуються
регуляркою перед використанням.

## 2. `adm.php` — видалено

Легасі-файл давав **будь-кому без автентифікації**:
- перегляд/скидання довільних ключів Redis (`?key=`, `?resetkeys=1`);
- XSS-інʼєкцію (`echo "<div ... value={$data}>"` з невекранованих даних Redis);
- витік вмісту кешу через `console.log`.

У `/api2` відповідника немає. Для операційних задач — `php artisan cache:*`,
`redis-cli`, або окрема адмінка за VPN/basic-auth (поза цим репозиторієм).

## 3. Автентифікація та сесії

| Проблема в легасі | У `/api2` |
|---|---|
| `getProfile` мав `if(true)`-заглушку, що **повністю вимикала** кеш і, за коментарем, валідацію сесій | чистий `AuthenticateToken` middleware, кеш `token:{t}:user` 1200с |
| токен сесії порівнювався звичайним `==` | пошук по індексованому полю + мідлвар відхиляє відсутній/протухлий токен раніше за контролер |
| `deleteAccount` / `resetPassword` — сирий SQL по `userID` з тіла | параметризовано + транзакція |
| немає обмеження швидкості (brute-force логіну, спам reset-листів) | глобальний `throttle:120/min`; `password/forgot` завжди `202` (не розкриває наявність email) |
| паролі: `password_hash`/`password_verify` (ОК) | bcrypt через `Hash::` — **сумісний із наявними хешами**, міграція не потрібна |

## 4. CORS та заголовки

Легасі: `Access-Control-Allow-Origin: *` на кожній ручці, включно з тими, що
повертають персональні дані. У `/api2` політика винесена в `config/cors.php`
(`CORS_ALLOWED_ORIGINS`) — на проді виставте перелік доменів замість `*`.

## 5. Витік даних / IDOR

- `homework/getCompleted` шукав по `name='{$userID}'` без перевірки, що це саме
  ти → у `/api2` `GET homeworks/{code}/completions` бере `userId` з токена.
- `getAssigned` / `getTestStats` покладались на «магічні» рядкові помилки
  (`Abchixba`) замість явної авторизації → тепер `HomeworkService::assertManageable()`
  (автор / та сама організація / адмін), інакше `403`.
- `reportQuestion` писав IP/User-Agent гостей у файл `guest_fingerprints.json`
  на диску → у `/api2` прибрано; лог помилки містить лише те, що потрібно.

## 6. Редактор — hardening (окрема вимога)

Легасі-редактор мав кілька системних проблем:

1. **Немає авторизації на рівні даних.** `postData` перевіряв лише `role != 0`,
   але не те, що тест належить тобі/твоїй організації. Правки чужих тестів були
   можливі.
2. **Не атомарно.** На кожне збереження тест видалявся і створювався заново
   (`deleteTest` + `createTest`) без транзакції. Будь-яка помилка посеред процесу
   лишала напівзбережений тест (частина питань зникала).
3. **Довіра до JSON.** Дерево питань бралося як є (`json_decode` → в БД), без
   перевірки типів/розмірів.
4. **Небезпечний аплоад.** `move_uploaded_file` за розширенням із назви файлу;
   тип брався з `$_FILES['type']` (керується клієнтом); шлях будувався з
   `$_POST` (`{subject}/{slug}`) без захисту від `../`; `imagecreatefrompng`
   на неперевіреному вводі (decompression bomb / polyglot).
5. **Race condition** на спільному тимчасовому файлі `tempFile.<ext>`.

**У `/api2`:**

| Загроза | Контрзахід |
|---|---|
| IDOR на тестах/матеріалах | `EditorService::assertCanEdit()` — власник / та сама організація / роль ≥ 2 |
| напівзбережений тест | `PUT editor/tests/{slug}` виконує заміну питань + варіантів у **одній транзакції** (`DB::transaction`) |
| сміттєвий payload | `EditorTestRequest` — сувора схема (типи питань, ліміти довжини/кількості, формат slug) |
| MIME-спуфінг | тип визначається `finfo` з байтів, а не з клієнта; дозволено лише `png/jpeg/webp` |
| decompression bomb | ліміт `10 MB` і `~40 Mpx`; повне **перекодування** у WebP (зрізає EXIF, вбудовані скрипти, polyglot) |
| path traversal | кожен сегмент шляху (`subject`, `slug`, `name`) валідовано `^[A-Za-z0-9_-]{1,128}$`; корінь фіксований (`ASSETS_PATH`) |
| race на temp-файлі | `tempnam()` + атомарний `rename()` |
| ключі-guessable коди ДЗ | `Str::random(11)` з перевіркою унікальності (як і було), плюс rate-limit |

**Ще потрібно (фаза 2):** переписати аплоад із «одразу пишемо в prod-дерево» на
модель «завантаження → тимчасове сховище → прив'язка при збереженні тесту»;
антивірусне сканування; підписані URL для приватних чернеток.

## 7. Телеметрія проходжень (нова, фаза 3)

Мікроповедінка — чутливі дані. Закладено:

- **Гості** — жодного PII. Тільки анонімний `attemptId` (uuid) + поведінка. У
  `attempts` / `attempt_events` для гостя `userId = NULL`; модель засвоєння
  (`user_skill_mastery`) для гостей не ведеться взагалі.
- **Без IP / User-Agent / fingerprint** у телеметрії. `clientMeta` обмежене
  `platform` / `appVersion` / `connection` (валідується, обрізається).
- `attemptId` **не перехоплюється**: якщо переданий id вже прив'язаний до іншого
  `userId`, сервер мовчки видає новий (`TelemetryService::resolveAttempt`).
- `POST /attempts/{id}/events` — окремий throttle `600/хв`, дедуп за
  `(attemptId, seq)`, ліміт 500 подій/виклик, невідомі типи ігноруються,
  `payload` — JSON без розкриття у відповідях.
- Агрегати класу (`/homeworks/{code}/behavior`) — та сама авторизація, що й
  `results` (автор ДЗ / організація / адмін).
- Особисті інсайти (`/me/insights`, `/me/mastery`) — лише власні дані користувача.
- `chosenAnswer` / `correctAnswer` у `attempt_question_stats` — це індекси
  варіантів («2», «0,3»), не текст; текстів відповідей у телеметрії немає.

**На майбутнє:** ретеншн-політика (напр. сирі `attempt_events` > 12 міс →
видаляти, лишаючи `attempt_question_stats`), і експорт/видалення телеметрії
на запит користувача (GDPR-подібне).

## 8. Інше

- `syncOffline` робив server-side проксі-запити з форвардом **усіх** вхідних
  заголовків на власний домен — прибрано (SSRF-подібний патерн, зайвий).
- `essentials/config.php` вимикав `CURLOPT_SSL_VERIFYPEER` для Centrifugo —
  у `/api2` керується `CENTRIFUGO_VERIFY_TLS` (за замовчуванням false лише для
  локального `127.0.0.1:2053`).
- monobank webhook: перевірка ECDSA-підпису (`X-Sign`) збережена і винесена в
  `MonobankService::verifyWebhook()` з кешем публічного ключа та ретраєм на ротацію.
- Уся обробка помилок тепер не світить стек-трейси на проді (`APP_DEBUG=false`
  → `500 SERVER_ERROR` без деталей).
