# Аудит сервера SHServ

**Дата:** 2026-06-02  
**Версия PHP:** 8.x  
**Файлов:** 96 `.php`  
**Фреймворк:** Fury (кастомный MVC)  

---

## Как читать этот документ

Каждая фаза — законченный пакет работ. Внутри фазы задачи отсортированы по приоритету (критичные → высокие → средние → низкие). Фазы следуют друг за другом: нет смысла начинать Phase 3, пока не закрыта Phase 1.

Приоритеты:
- 🔴 **Критично** — угроза безопасности, возможен инцидент
- 🟠 **Высоко** — операционный риск, падение стабильности или утечка данных
- 🟡 **Средне** — технический долг, влияет на поддерживаемость
- 🟢 **Низко** — косметика, можно отложить

---

## Phase 1 — Безопасность (Security Foundation)

**Цель:** Закрыть векторы, через которые злоумышленник может получить полный доступ к системе.

> **Блокер для следующих фаз:** нет смысла строить валидацию и обработку ошибок поверх дыр в аутентификации и SQL.

### 1.1 🔴 Аутентификация на REST API

**Где:** `server/SHServ/Routes.php:58-82`  
**Что:** Все endpoint'ы `/api/v1/*` открыты без проверки сессии/токена. Любой HTTP-запрос может управлять устройствами, менять скрипты, перезагружать реле.

**Фикс:**
1. Добавить middleware-слой в `Routes.php` (или `App.php`) перед роутингом.
2. Проверять `Authorization: Bearer <token>` или `Cookie: auth_token=<token>`.
3. Исключения (публичные): `POST /events/new` (устройства шлют без сессии, но с device token).
4. Закрыть dev-роуты (`/dev/test/*`) если `devmode === false`.

---

### 1.2 🔴 SQL-инъекции в ThinBuilder

**Где:**
- `server/Fury/Modules/ThinBuilder/ThinBuilderProcessing.php:40-108`
- `server/Fury/Modules/ThinBuilder/ThinBuilder.php:58-87`

**Что:** `addslashes()` используется как основной механизм экранирования. Это недостаточно для MySQL (возможен multibyte-обход). Все `insert`, `update`, `delete` и `where` собирают raw SQL строки конкатенацией.

**Фикс:**
1. Переписать `ThinBuilder` на PDO prepared statements.
2. Все пользовательские значения — через `?` плейсхолдеры + `bindValue()`.
3. Идентификаторы (имена таблиц, колонок) через whitelist/квотирование.
4. Если переписывать весь ThinBuilder слишком дорого — добавить PreparedStatement-wrapper вокруг существующего билдера, постепенно мигрируя модели.

---

### 1.3 🔴 Секреты вне версионного контроля

**Где:** `server/SHServ/config.php:6,13-14,20`

**Что:**
- `debug => true` и `devmode => true` в коммите — стектрейсы и dev-tools доступны на проде.
- `db.user = "eugene"`, `db.password = "root"` в plaintext в git.

**Фикс:**
1. Создать `.env` файл на уровне `server/` (добавить в `.gitignore`).
2. Использовать библиотеку типа `vlucas/phpdotenv` или простой парсер.
3. `config.php` оставить как `config.example.php` с placeholder'ами.
4. Переименовать живой `config.php` → `.env`-based загрузку.
5. По умолчанию `debug = false`, `devmode = false`. Переопределять через env.

---

### 1.4 🔴 Хеширование паролей

**Где:** `server/SHServ/Models/Example_Auth.php:24`

**Что:** Пароли хешируются `sha1()`, который криптографически сломан и быстрый к брутфорсу.

**Фикс:**
1. `password_hash($password, PASSWORD_ARGON2ID)` при регистрации/смене.
2. `password_verify($password, $hash)` при входе.
3. Миграция: добавить колонку `password_hash`, при первом успешном `password_verify` обновлять из `sha1`.

---

### 1.5 🔴 Криптографически стойкие токены сессий

**Где:** `server/SHServ/Sessions.php:13`

**Что:** Токен генерируется `uniqid($uid . time())` — низкая энтропия, предсказуем.

**Фикс:**
```php
$token = bin2hex(random_bytes(32));
```

---

### 1.6 🟠 Cookie с защитными флагами

**Где:** `server/SHServ/Sessions.php:38`

**Что:** `setcookie("auth_token", ...)` без `HttpOnly`, `Secure`, `SameSite`. Увеличивает impact XSS.

**Фикс:**
```php
setcookie("auth_token", $token, [
    'expires' => time() + 86400 * 30,
    'path' => '/',
    'httponly' => true,
    'secure' => true,        // если HTTPS
    'samesite' => 'Strict',
]);
```

---

### 1.7 🟠 CLI `get.config` сливает конфиг

**Где:** `server/console.php:10-12`

**Что:** Команда `get.config` выводит весь массив `FCONF` (включая пароль БД) без аутентификации.

**Фикс:** Удалить команду или закрыть проверкой локального файла-ключа (например, только если `php_sapi_name() === 'cli'` и uid = владелец файла).

---

## Phase 2 — Целостность данных и обработка ошибок

**Цель:** Убрать silent failures, сделать транзакции атомарными, починить инвертированную логику error handler'а.

> **Блокер:** пока error handler инвертирован, трудно доверять логам и репортам.

### 2.1 🟠 Инвертированный ErrorHandler

**Где:** `server/Fury/Modules/ErrorHandler/ErrorHandler.php:26-29`

**Что:**
```php
if(!FCONF["debug"]) {
    error_reporting(-1);
}
```
Ошибки показываются, когда debug **выключен**. Логика наоборот.

**Фикс:** Поменять ветки — показывать `error_reporting(-1)` когда `debug === true`, подавлять (или логировать) когда `false`.

---

### 2.2 🟠 Убрать сет_exception_handler комментарий

**Где:** `server/Fury/Modules/ErrorHandler/ErrorHandler.php:41`

**Что:** `set_exception_handler` закомментирован — uncaught exceptions утекают со стектрейсами.

**Фикс:** Раскомментировать и направлять через `exception_handler`.

---

### 2.3 🟠 Транзакция при создании устройства

**Где:** `server/SHServ/Models/Devices.php:30-82`

**Что:** `connect_new_device` делает два INSERT'а (`devices`, `device_auth`) без транзакции. Если второй упадёт, первый остаётся сиротой. Есть fallback-удаление, но оно не сработает при fatal error между INSERT'ами.

**Фикс:**
```php
$db->beginTransaction();
try {
    // insert devices
    // insert device_auth
    $db->commit();
} catch (\Exception $e) {
    $db->rollBack();
    throw $e;
}
```

---

### 2.4 🟠 Silent `null` на SQL-ошибках

**Где:** `server/Fury/Modules/ThinBuilder/ThinBuilder.php:28-30`

**Что:** `query()` возвращает `null` при ошибке SQL, не логируя и не бросая исключение. TODO-комментарий подтверждает, что это известный баг.

**Фикс:**
1. Включить `PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION` в `DB.php`.
2. Убрать `return null` — пусть PDO бросает.
3. Обёртка `try/catch` в контроллере, маппинг в `response_error`.

---

### 2.5 🟡 `response_error` должен ставить HTTP-статус

**Где:** `server/SHServ/Utils.php:14-30`

**Что:** `response_error()` всегда отдаёт HTTP 200 OK. Клиенты (и прокси) не могут отличить ошибку от успеха по статусу.

**Фикс:**
```php
function response_error($msg, $alias = "error", $status_code = 400) {
    http_response_code($status_code);
    // ...
}
```
Контроллеры передают подходящий код: 400 для клиентских, 500 для серверных.

---

### 2.6 🟡 Пустые стабы

**Где:**
- `server/SHServ/Models/Areas.php:103-105` — `remove_obsolete()` пустой
- `server/SHServ/Helpers/Validator.php` — класс пустой, не используется

**Фикс:** Реализовать или удалить.

---

## Phase 3 — Укрепление API

**Цель:** Валидация входных данных, единообразные ответы, защита от abuse.

> **Блокер:** валидация входных данных бессмысленна, если за ней всё равно стоит уязвимый ThinBuilder (Phase 1). Поэтому Phase 3 идёт **после** Phase 1.

### 3.1 🟠 Валидация входных данных

**Где:**
- `server/SHServ/Controllers/AreasRESTAPIController.php:52-77` — `new_area` не валидирует `type`, `alias`, `display_name`
- `server/SHServ/Controllers/DevicesRESTAPIController.php:32` — IP проверяется только `strlen < 7`
- `server/SHServ/Controllers/ScriptsRESTAPIController.php:19-29` — `run_action_script` передаёт сырые `$params` в callable без валидации
- `server/SHServ/Controllers/DevicesRESTAPIController.php:206` — `do_device_action` передаёт сырые `$params` на устройство

**Фикс:**
1. Ввести `Validator` (или использовать `filter_var` + regex).
2. `alias` — `/^[a-z0-9_]+$/`, `display_name` — max 255, `type` — whitelist.
3. IP — `filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)`.
4. `$params` — whitelist по action alias или JSON Schema.

---

### 3.2 🟠 `scope_file` возвращает не JSON

**Где:** `server/SHServ/Controllers/ScriptsRESTAPIController.php:59-79`

**Что:** На success возвращает raw PHP source code, на error — JSON. Клиент должен угадывать формат.

**Фикс:** Всегда JSON-обёртка:
```json
{ "status": true, "data": { "source": "<?php ..." } }
```

---

### 3.3 🟠 Scope update — path traversal

**Где:** `server/SHServ/Controllers/ScriptsRESTAPIController.php:81-102`

**Что:** Принимает `$path` и `$file` от клиента, пишет на диск с минимальной проверкой (`strpos($filepath, ".php")`). Можно писать вне `ControlScripts/Scopes/`.

**Фикс:**
1. Разрешить запись только в `__DIR__ . "/../../ControlScripts/Scopes/"`.
2. Проверять `realpath($filepath)` начинается с разрешённого префикса.
3. Использовать whitelist имён файлов или UUID.

---

### 3.4 🟠 `set_script_state` молча проглатывает невалидные значения

**Где:** `server/SHServ/Controllers/ScriptsRESTAPIController.php:110-120`

**Что:** Любое значение, не равное `"enable"`, трактуется как disable. Невалидный запрос (`"foobar"`) получает success.

**Фикс:**
```php
if (!in_array($state, ["enable", "disable"], true)) {
    return response_error("Invalid state", "invalid_state", 400);
}
```

---

### 3.5 🟡 Рейт-лимитинг

**Где:** `server/SHServ/Routes.php:58-82`

**Что:** Нет rate limiting. Можно спамить `do_device_action`, `/events/new`, сканирование.

**Фикс:** Простой in-memory rate limiter (на основе IP) или nginx `limit_req` для начала. Для критичных endpoint'ов — 10 req/sec per IP.

---

## Phase 4 — Коммуникация с устройствами

**Цель:** Сделать работу с устройствами отказоустойчивой и неблокирующей.

### 4.1 🟠 DeviceScanner — 253 одновременных cURL

**Где:** `server/SHServ/Tools/DeviceScanner.php:72-98`

**Что:** `scan_ips` создаёт curl handle для каждого IP диапазона. Для `/24` — 253 хендла одновременно. Может исчерпать fd и память.

**Фикс:** Batch-сканирование, sliding window (например, 32 параллельных хендла максимум). Остальные ставить в очередь.

---

### 4.2 🟠 Нет retry / exponential backoff на HTTP к устройствам

**Где:** `server/SHServ/Tools/DeviceAPI/Base.php:138-226`

**Что:** Один transient network failure = полный провал запроса. Нет повторных попыток.

**Фикс:** Добавить retry loop (3 попытки, backoff 100ms, 300ms). Только для идемпотентных операций (status, action). POST на `/setup` — без retry или с idempotency key.

---

### 4.3 🟠 Хардкод таймаутов на cURL

**Где:** `server/SHServ/Tools/DeviceAPI/Base.php:178-179`

**Что:** `CONNECTTIMEOUT => 1`, `TIMEOUT => 5` — не конфигурируются.

**Фикс:** Вынести в `FCONF['device_api_timeout']` или device-level config.

---

### 4.4 🟡 Синхронные blocking-вызовы к устройствам

**Где:**
- `server/SHServ/Controllers/DevicesRESTAPIController.php:177` — `device_status`
- `server/SHServ/RequiredControlScriptsScope.php:12-21` — `online` event handler вызывает `get_about()`

**Что:** PHP-поток блокируется на время cURL timeout. Если устройство offline, ждём полные 5 секунд.

**Фикс:**
1. Для bulk-операций — `curl_multi` (уже есть в `DeviceScanner`, переиспользовать).
2. Для event handler'ов — не блокировать: пометить device как "pending update" и обновить lazily.

---

### 4.5 🟡 `reset_device` игнорирует ответ устройства

**Где:** `server/SHServ/Controllers/DevicesRESTAPIController.php:382`

**Что:** Вызывает `$device->device_api()->reset()` и не проверяет результат. Если устройство offline, клиент получает "success".

**Фикс:** Проверять `http_code == 200`, иначе `device_request_fail`.

---

## Phase 5 — Качество кода и техдолг

**Цель:** Убрать мёртвый код, дедупликацию, странные сайд-эффекты.

> Можно выполнять параллельно с Phase 2–4, но не раньше Phase 1.

### 5.1 🟡 Сайд-эффект в конструкторе App

**Где:** `server/SHServ/App.php:35-36`

**Что:** На каждый HTTP-запрос принудительно включается `spotlights_off` action script. Это не должно жить в bootstrap.

**Фикс:** Вынести в миграцию/seed-скрипт, выполняемый один раз при установке.

---

### 5.2 🟡 Дедупликация `place_in_area` / `unassign_from_area`

**Где:**
- `server/SHServ/Controllers/DevicesRESTAPIController.php:238-279`
- `server/SHServ/Controllers/AreasRESTAPIController.php:108-129`

**Что:** Логика размещения устройства в area дублируется почти дословно.

**Фикс:** Вынести в сервис `AreaPlacementService` или shared validator.

---

### 5.3 🟡 `is_dir($item)` проверяет CWD, не Scopes/

**Где:** `server/SHServ/App.php:92`

**Что:**
```php
array_filter($scripts_dir, function($item) {
    return !is_dir($item) and ...;
});
```
`$item` — имя без пути, `is_dir` проверяет текущую директорию, а не `ControlScripts/Scopes/`.

**Фикс:**
```php
!is_dir(__DIR__ . "/../ControlScripts/Scopes/" . $item)
```

---

### 5.4 🟡 Переменная `$device` вместо `$script`

**Где:** `server/SHServ/Controllers/ScriptsRESTAPIController.php:143,146-148`

**Что:** Переменная называется `$device`, но содержит `Script`. На ошибке возвращается alias `"device_not_found"` вместо `"script_not_exists"`.

**Фикс:** Переименовать и исправить alias.

---

### 5.5 🟡 `User.php` конструирует `Profile` с `$uid`

**Где:** `server/SHServ/Entities/User.php:19`

**Что:** `new Profile($uid)` — вместо `profile_id` передаётся `uid`. TODO-комментарий подтверждает баг.

**Фикс:** Передать реальный `profile_id` (запросить из БД или взять из поля `profile_id`).

---

### 5.6 🟡 Hatch.php — undefined variable

**Где:** `server/SHServ/Tools/DeviceAPI/Hatch.php:21,29,37,45`

**Что:** Методы `is_opened`, `is_closed`, `is_opening`, `is_closing` ссылаются на `$status_response`, которой нет в scope.

**Фикс:** Использовать `$state` или корректное имя переменной.

---

### 5.7 🟡 Мёртвый код в Scope'ах

**Где:**
- `server/ControlScripts/Scopes/OfficeRoomScope.php:71-98`
- `server/ControlScripts/Scopes/SpotlightsScope.php:127-160`
- `server/ControlScripts/Scopes/TestScriptsScope.php:31-71`

**Что:** Большие закомментированные блоки.

**Фикс:** Удалить или перенести в `docs/notes.md`.

---

### 5.8 🟢 Hardcoded alias'ы в `ControlScripts/Common.php`

**Где:** `server/ControlScripts/Common.php:8-123`

**Что:** Десятки device alias'ов (`spotlight_main_back_1`, `buttons_backdoor`...) зашиты в код.

**Фикс:** Вынести sync map в БД или dedicated YAML/JSON конфиг.

---

### 5.9 🟢 `root_folder()` хрупкий

**Где:** `server/SHServ/App.php:63-65`

**Что:**
```php
list($root) = explode('SHServ', __DIR__);
```
Сломается, если директория переименуется.

**Фикс:**
```php
return dirname(__DIR__, 2);
```

---

### 5.10 🟢 `get_all()` magic number

**Где:** `server/SHServ/Models/Areas.php:92`

**Что:** Хардкод `limit 1000`.

**Фикс:** Константа `AREAS_MAX_LIMIT` или pagination.

---

### 5.11 🟢 Логи в web-root без защиты

**Где:** `server/Fury/Kernel/Logging.php:88-96`

**Что:** Логи пишутся в `SHServ/Logs/` (под web root), `chmod 0755`, нет `flock`.

**Фикс:**
1. Перенести логи за пределы document root.
2. `chmod 0640`.
3. Использовать `flock()` или log-rotate.

---

## Приложение: Файлы, требующие внимания

| Файл | Phase | Проблемы |
|------|-------|----------|
| `SHServ/config.php` | 1.3 | Secrets, debug mode |
| `SHServ/App.php` | 1.1, 2.3, 5.1, 5.3, 5.9 | Auth middleware, сет-эффект, is_dir, root_folder |
| `SHServ/Routes.php` | 1.1, 3.5 | Нет auth, нет rate limit |
| `SHServ/Sessions.php` | 1.5, 1.6 | Токены, cookie flags |
| `SHServ/Models/Example_Auth.php` | 1.4 | SHA1 |
| `SHServ/Controllers/ScriptsRESTAPIController.php` | 3.1, 3.2, 3.3, 3.4, 5.4 | Валидация, raw ответ, path traversal, alias |
| `SHServ/Controllers/DevicesRESTAPIController.php` | 3.1, 4.5 | IP валидация, reset ignore response |
| `SHServ/Controllers/AreasRESTAPIController.php` | 3.1 | Валидация area |
| `SHServ/Controllers/EventsController.php` | 3.1 | Валидация event |
| `SHServ/Models/Devices.php` | 2.3 | Нет транзакции |
| `SHServ/Models/Areas.php` | 5.10 | Magic number, пустой stub |
| `SHServ/Models/Scripts.php` | 1.7 | unlink без auth |
| `SHServ/Tools/DeviceScanner.php` | 4.1 | 253 concurrent curl |
| `SHServ/Tools/DeviceAPI/Base.php` | 4.2, 4.3 | Нет retry, hardcoded timeouts |
| `SHServ/Tools/DeviceAPI/Hatch.php` | 5.6 | Undefined variable |
| `Fury/Modules/ThinBuilder/ThinBuilder.php` | 1.2, 2.4 | SQL injection, silent null |
| `Fury/Modules/ThinBuilder/ThinBuilderProcessing.php` | 1.2 | SQL injection |
| `Fury/Modules/ErrorHandler/ErrorHandler.php` | 2.1, 2.2 | Инвертированная логика, закомментированный handler |
| `Fury/Kernel/Logging.php` | 5.11 | Логи в web root |
| `console.php` | 1.7 | Слив конфига |
