diff --git a/.gitignore b/.gitignore index 0e975f4..8c30bf1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ server/SHServ/Logs -server/SHServ/config.php +server/SHServ/.env diff --git a/docs/server-audit.md b/docs/server-audit.md new file mode 100644 index 0000000..d22f8b6 --- /dev/null +++ b/docs/server-audit.md @@ -0,0 +1,516 @@ +# Аудит сервера 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 ` или `Cookie: auth_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": " 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 | Слив конфига | diff --git a/server/.gitignore b/server/.gitignore index f2da39d..9386190 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,4 +1,5 @@ .phpintel/ SHServ/Logs/ +SHServ/.env *.pyc diff --git a/server/Fury/Modules/ThinBuilder/ThinBuilder.php b/server/Fury/Modules/ThinBuilder/ThinBuilder.php index 1d13d08..3892238 100644 --- a/server/Fury/Modules/ThinBuilder/ThinBuilder.php +++ b/server/Fury/Modules/ThinBuilder/ThinBuilder.php @@ -24,7 +24,6 @@ return $sql; } - // TODO: if result of query() == false - we have error about trying call fetch func $response = $this -> pdo -> query($sql); if(!$response) { $result = null; @@ -43,65 +42,110 @@ return $result; } - // $where = [ [], 'AND', [], 'OR', [] ] public function select(String $tablename, $fields = [], $where = [], $order_fields = [], String $order_sort = 'DESC', $limit = []){ - list($fields, $where, $order_fields, $limit) = $this -> select_data_preprocessing($fields, $where, $order_fields, $limit); + $this -> validate_identifier($tablename); + list($fields_sql, $where_sql, $order_sql, $limit_sql, $params) = $this -> select_data_preprocessing($fields, $where, $order_fields, $limit); - if($order_fields != ''){ - $order_fields .= " {$order_sort}"; + if($order_sql != ''){ + $order_sql .= " {$order_sort}"; } - $sql = "SELECT {$fields} FROM `{$tablename}` {$where} {$order_fields} {$limit}"; - return $this -> query($sql, 'fetchAll', \PDO::FETCH_ASSOC); + $sql = "SELECT {$fields_sql} FROM `{$tablename}` {$where_sql} {$order_sql} {$limit_sql}"; + + if($this -> gen_sql_only) { + $this -> gen_sql_only = false; + return $sql; + } + + $stmt = $this -> pdo -> prepare($sql); + $stmt -> execute($params); + $result = $stmt -> fetchAll(\PDO::FETCH_ASSOC); + + if($this -> history_enabled){ + $this -> history -> add($sql, $result); + } + + if($this -> driver){ + $this -> driver -> event_query($sql, $result); + } + + return $result; } public function insert(String $tablename, Array $data){ - $tablename = addslashes($tablename); - $data = $this -> escape_string_in_arr($data); + $this -> validate_identifier($tablename); + $fields = array_keys($data); + foreach ($fields as $f) { + $this -> validate_identifier($f); + } + $placeholders = array_fill(0, count($fields), '?'); - $fields = '`' . implode('`,`', array_keys($data)) . '`'; - $values = "'" . implode("','", array_values($data)) . "'"; - $sql = "INSERT INTO `{$tablename}` ({$fields}) VALUES ($values)"; + $sql = "INSERT INTO `{$tablename}` (`" . implode('`,`', $fields) . "`) VALUES (" . implode(',', $placeholders) . ")"; - if($this -> query($sql)){ - $id = $this -> pdo -> lastInsertId(); - $this -> history -> add($sql, $id); - return $id; + if($this -> gen_sql_only) { + $this -> gen_sql_only = false; + return $sql; } - return false; + $stmt = $this -> pdo -> prepare($sql); + $stmt -> execute(array_values($data)); + $id = $this -> pdo -> lastInsertId(); + + if($this -> history_enabled){ + $this -> history -> add($sql, $id); + } + + return $id; } public function update(String $tablename, Array $data, $where = []){ - $where = $this -> where_processing($where); - $data = $this -> escape_string_in_arr($data); - $tablename = addslashes($tablename); + $this -> validate_identifier($tablename); + list($where_sql, $where_params) = $this -> where_processing($where); - $pdata = []; - foreach ($data as $field => $value) { - $pdata[] = "`{$field}`='{$value}'"; + $set_sql = []; + foreach (array_keys($data) as $field) { + $this -> validate_identifier($field); + $set_sql[] = "`{$field}` = ?"; } - $sql = "UPDATE `{$tablename}` SET " . implode(',', $pdata) . " {$where}"; - return $this -> query($sql); + $sql = "UPDATE `{$tablename}` SET " . implode(',', $set_sql) . " {$where_sql}"; + + if($this -> gen_sql_only) { + $this -> gen_sql_only = false; + return $sql; + } + + $stmt = $this -> pdo -> prepare($sql); + $stmt -> execute(array_merge(array_values($data), $where_params)); + + return $stmt -> rowCount(); } public function delete(String $tablename, $where = []){ - $tablename = addslashes($tablename); - $where = $this -> where_processing($where); + $this -> validate_identifier($tablename); + list($where_sql, $where_params) = $this -> where_processing($where); - $sql = "DELETE FROM `{$tablename}` {$where}"; - return $this -> query($sql); + $sql = "DELETE FROM `{$tablename}` {$where_sql}"; + + if($this -> gen_sql_only) { + $this -> gen_sql_only = false; + return $sql; + } + + $stmt = $this -> pdo -> prepare($sql); + $stmt -> execute($where_params); + + return $stmt -> rowCount(); } public function drop(String $tablename){ - $tablename = addslashes($tablename); + $this -> validate_identifier($tablename); $sql = "DROP TABLE `{$tablename}`"; return $this -> query($sql); } public function truncate(String $tablename){ - $tablename = addslashes($tablename); + $this -> validate_identifier($tablename); $sql = "TRUNCATE TABLE `{$tablename}`"; return $this -> query($sql); } @@ -123,14 +167,14 @@ 'can_be_null' => false ], ] */ - - $tablename = addslashes($tablename); - $fields = $this -> escape_string_in_arr($fields); - $primary_key = addslashes($primary_key); - $engine = addslashes($engine); + + $this -> validate_identifier($tablename); + $this -> validate_identifier($primary_key); + $this -> validate_identifier($engine); $fields_str_arr = []; foreach ($fields as $name => $options) { + $this -> validate_identifier($name); $length = (isset($options['length']) and !is_null($options['length'])) ? "({$options['length']})" : ''; if(isset($options['default'])){ @@ -141,7 +185,7 @@ $auto_increment = (isset($options['auto_increment']) and $options['auto_increment']) ? 'AUTO_INCREMENT' : ''; $can_be_null = (isset($options['can_be_null']) and $options['can_be_null']) ? 'NULL' : 'NOT NULL'; - + $fields_str_arr[] = "`{$name}` {$options['type']}{$length} {$can_be_null} {$default} {$auto_increment}"; } @@ -152,7 +196,7 @@ } public function table_fields(String $tablename){ - $tablename = addslashes($tablename); + $this -> validate_identifier($tablename); $sql = "SHOW COLUMNS FROM `{$tablename}`"; $result = $this -> query($sql, 'fetchAll', \PDO::FETCH_NUM); @@ -179,10 +223,14 @@ } public function count(String $tablename, $where = []){ - $tablename = addslashes($tablename); - $where = $this -> where_processing($where); - $sql = "SELECT COUNT(*) FROM `{$tablename}` {$where}"; - $result = $this -> query($sql, 'fetch', \PDO::FETCH_NUM); + $this -> validate_identifier($tablename); + list($where_sql, $where_params) = $this -> where_processing($where); + $sql = "SELECT COUNT(*) FROM `{$tablename}` {$where_sql}"; + + $stmt = $this -> pdo -> prepare($sql); + $stmt -> execute($where_params); + $result = $stmt -> fetch(\PDO::FETCH_NUM); + return $result ? intval($result[0]) : 0; } @@ -194,4 +242,4 @@ $this -> gen_sql_only = true; return $this; } -} \ No newline at end of file +} diff --git a/server/Fury/Modules/ThinBuilder/ThinBuilderProcessing.php b/server/Fury/Modules/ThinBuilder/ThinBuilderProcessing.php index 84ff97e..1e987a2 100644 --- a/server/Fury/Modules/ThinBuilder/ThinBuilderProcessing.php +++ b/server/Fury/Modules/ThinBuilder/ThinBuilderProcessing.php @@ -34,9 +34,17 @@ protected function create_connect($db_conf){ $dblib = "{$db_conf['dblib']}:host={$db_conf['host']};dbname={$db_conf['dbname']};charset={$db_conf['charset']}"; $pdo = new \PDO($dblib, $db_conf['user'], $db_conf['password']); + $pdo -> setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); return $pdo; } + protected function validate_identifier(String $name): String { + if(preg_match('/^[a-zA-Z0-9_]+$/', $name) !== 1){ + throw new \Exception("Invalid SQL identifier: {$name}"); + } + return $name; + } + protected function escape_string_in_arr($arr){ $result = []; foreach ($arr as $key => $value) { @@ -52,58 +60,85 @@ protected function select_data_preprocessing($fields, $where, $order_fields, $limit){ // FIELDS PREPROCESSING if(count($fields)){ - $fields = $this -> escape_string_in_arr($fields); - $fields = '`' . implode('`,`', $fields) . '`'; + foreach ($fields as $f) { + $this -> validate_identifier($f); + } + $fields_sql = '`' . implode('`,`', $fields) . '`'; }else{ - $fields = '*'; + $fields_sql = '*'; } // ORDER PREPROCESSING if(count($order_fields)){ - $order_fields = $this -> escape_string_in_arr($order_fields); - $order_fields = 'ORDER BY `' . implode("`,`", $order_fields) . '`'; + foreach ($order_fields as $f) { + $this -> validate_identifier($f); + } + $order_sql = 'ORDER BY `' . implode("`,`", $order_fields) . '`'; }else{ - $order_fields = ''; + $order_sql = ''; } // WHERE PREPROCESSING - $where = $this -> where_processing($where); + list($where_sql, $where_params) = $this -> where_processing($where); // LIMIT PREPROCESSING if(count($limit)){ - $limit = $this -> escape_string_in_arr($limit); - $limit = 'LIMIT ' . implode(',', $limit); + $limit_sql = 'LIMIT ' . implode(',', array_map('intval', $limit)); }else{ - $limit = ''; + $limit_sql = ''; } - return [$fields, $where, $order_fields, $limit]; + return [$fields_sql, $where_sql, $order_sql, $limit_sql, $where_params]; + } + + protected function normalize_where($where) { + if(!count($where)){ + return []; + } + + // Если первый элемент — не массив и не логический оператор, + // значит where передан как плоский массив одного условия: ['field', '=', 'value'] + if(!is_array($where[0]) && !in_array(strtoupper($where[0]), ['AND', 'OR'])) { + if(count($where) === 2) { + return [ [$where[0], '=', $where[1]] ]; + } + return [ [$where[0], $where[1], $where[2]] ]; + } + + return $where; } protected function where_processing($where){ + $where = $this -> normalize_where($where); + if(!count($where)){ - return ''; + return ['', []]; } - $where = $this -> escape_string_in_arr($where); + $sql_parts = []; + $params = []; foreach ($where as $i => $w_item) { if(is_array($w_item)){ if(count($w_item) === 2){ $w_item = [$w_item[0], '=', $w_item[1]]; } - $w_item[0] = "`{$w_item[0]}`"; - if($w_item[1] != 'IN'){ - $w_item[2] = "'{$w_item[2]}'"; - }else{ - $w_item[2] = '(\'' . implode("','", $w_item[2]) . '\')'; + $field = "`" . $this -> validate_identifier($w_item[0]) . "`"; + $operator = strtoupper($w_item[1]); + if($operator == 'IN'){ + $placeholders = array_fill(0, count($w_item[2]), '?'); + $sql_parts[] = "{$field} IN (" . implode(',', $placeholders) . ")"; + $params = array_merge($params, $w_item[2]); + } else { + $sql_parts[] = "{$field} {$operator} ?"; + $params[] = $w_item[2]; } - - $where[$i] = implode(' ', $w_item); + } else { + $sql_parts[] = $w_item; } } - $where = 'WHERE ' . implode(' ', $where); - return $where; + $sql = 'WHERE ' . implode(' ', $sql_parts); + return [$sql, $params]; } -} \ No newline at end of file +} diff --git a/server/SHServ/.env.example b/server/SHServ/.env.example new file mode 100644 index 0000000..2f5b2ef --- /dev/null +++ b/server/SHServ/.env.example @@ -0,0 +1,14 @@ +APP_NAME=SHServ +APP_VERSION=0.3 dev +DEBUG=false +DEVMODE=false + +DB_DRIVER=mysql +DB_HOST=localhost +DB_NAME=smart-home-server +DB_CHARSET=utf8 +DB_USER=root +DB_PASSWORD=changeme + +DEVICE_IP_RANGE_START=192.168.2.2 +DEVICE_IP_RANGE_END=192.168.2.254 diff --git a/server/SHServ/App.php b/server/SHServ/App.php index 9508c86..c052469 100644 --- a/server/SHServ/App.php +++ b/server/SHServ/App.php @@ -59,6 +59,35 @@ $this -> factory = new Factory(); } + public function api_auth_guard(): void { + $uri = $_SERVER['REQUEST_URI'] ?? ''; + if (strpos($uri, '/api/v1/') !== 0) { + return; + } + + $token = null; + + $auth_header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; + if (strpos($auth_header, 'Bearer ') === 0) { + $token = substr($auth_header, 7); + } + + if (!$token && isset($_COOKIE['auth_token'])) { + $token = $_COOKIE['auth_token']; + } + + if (!$token || !app() -> sessions -> get_session_by_token($token)) { + header('Content-Type: application/json'); + http_response_code(401); + echo json_encode([ + 'status' => false, + 'error_alias' => 'unauthorized', + 'msg' => 'Authentication required' + ]); + exit; + } + } + public function root_folder(): String { list($root) = explode('SHServ', __DIR__); return $root; diff --git a/server/SHServ/Entities/Area.php b/server/SHServ/Entities/Area.php index d152089..5abd85b 100644 --- a/server/SHServ/Entities/Area.php +++ b/server/SHServ/Entities/Area.php @@ -265,26 +265,18 @@ } public function remove(): Bool { - $result = app() -> thin_builder -> update( + app() -> thin_builder -> update( Device::$table_name, ["area_id" => 0], [[ "area_id", "=", $this -> id() ]] ); - if(!$result) { - return false; - } - - $result = app() -> thin_builder -> update( + app() -> thin_builder -> update( self::$table_name, ["parent_id" => 0], [[ "parent_id", "=", $this -> id() ]] ); - if(!$result) { - return false; - } - $this -> remove_entity(); return true; } diff --git a/server/SHServ/EventsHandlers.php b/server/SHServ/EventsHandlers.php index 915e53f..80c1478 100644 --- a/server/SHServ/EventsHandlers.php +++ b/server/SHServ/EventsHandlers.php @@ -7,6 +7,7 @@ events() -> handler('kernel:Bootstrap.ready_app', function(Array $params) { app() -> routes -> routes_init(); if(!app() -> console_flag) { + app() -> api_auth_guard(); app() -> router -> start_routing(); } }); diff --git a/server/SHServ/Factory/Creator.php b/server/SHServ/Factory/Creator.php index 6e0c5bb..8a076e5 100644 --- a/server/SHServ/Factory/Creator.php +++ b/server/SHServ/Factory/Creator.php @@ -8,7 +8,7 @@ class Creator { public function create_user(String $alias, String $email, String $password) { - $password_hash = sha1($password); + $password_hash = password_hash($password, PASSWORD_ARGON2ID); $uid = app() -> thin_builder -> insert(User::$table_name, [ "alias" => $alias, diff --git a/server/SHServ/Middleware/Entity.php b/server/SHServ/Middleware/Entity.php index 5c29eeb..064537f 100644 --- a/server/SHServ/Middleware/Entity.php +++ b/server/SHServ/Middleware/Entity.php @@ -64,21 +64,18 @@ return $this -> entity_id; } - public function update(): Array|Bool { + public function update(): Bool { if(!count($this -> modified_fields)){ - return []; + return true; } $where = [ ["id", "=", $this -> entity_id] ]; $this -> modified_fields[$this -> field_name_of_update_at] = date("Y-m-d H:i:s"); - if(!$this -> thin_builder() -> update($this -> entity_tablename, $this -> modified_fields, $where)) { - return false; - } + $this -> thin_builder() -> update($this -> entity_tablename, $this -> modified_fields, $where); - $result = $this -> modified_fields; $this -> modified_fields = []; - return $result; + return true; } public static function get_fields(): Array { diff --git a/server/SHServ/Models/Example_Auth.php b/server/SHServ/Models/Example_Auth.php index 5a54fb3..b9e1b0e 100644 --- a/server/SHServ/Models/Example_Auth.php +++ b/server/SHServ/Models/Example_Auth.php @@ -21,14 +21,28 @@ } public function signin(String $email, String $password) { - $password = sha1($password); $user = app() -> factory -> getter() -> get_user_by("email", $email); - if(!$user or $user -> get("password") != $password) { + if(!$user) { return false; } - return app() -> sessions -> init_session($user -> id()); + $hash = $user -> get("password"); + + if(password_verify($password, $hash)) { + if(password_needs_rehash($hash, PASSWORD_ARGON2ID)) { + $user -> set("password", password_hash($password, PASSWORD_ARGON2ID)) -> update(); + } + return app() -> sessions -> init_session($user -> id()); + } + + // Fallback для legacy SHA1 хешей (прозрачная миграция) + if(sha1($password) === $hash) { + $user -> set("password", password_hash($password, PASSWORD_ARGON2ID)) -> update(); + return app() -> sessions -> init_session($user -> id()); + } + + return false; } public function signout() { diff --git a/server/SHServ/Models/MetaManager.php b/server/SHServ/Models/MetaManager.php index c8e29b1..65672fd 100644 --- a/server/SHServ/Models/MetaManager.php +++ b/server/SHServ/Models/MetaManager.php @@ -46,14 +46,15 @@ } public function remove_all_by_entity(String $assignment, int $ent_id): bool { - return $this -> thin_builder() -> delete( + $this -> thin_builder() -> delete( Meta::$table_name, [ [ "assignment", "=", $assignment ], "AND", [ "ent_id", "=", $ent_id ] ] - ) ? true : false; + ); + return true; } public function create(String $name, String $value, String $assignment, int $ent_id): int { diff --git a/server/SHServ/Sessions.php b/server/SHServ/Sessions.php index 1f6eefa..268462c 100644 --- a/server/SHServ/Sessions.php +++ b/server/SHServ/Sessions.php @@ -10,7 +10,7 @@ protected $table_name = "sessions"; public function create(Int $uid) { - $token = uniqid($uid . time()); + $token = bin2hex(random_bytes(32)); $result = app() -> thin_builder -> insert($this -> table_name, [ "uid" => $uid, "token" => $token, @@ -35,7 +35,13 @@ } public function set_session(String $token) { - setcookie("auth_token", $token, time() + 3600 * 24 * 30, "/"); + setcookie("auth_token", $token, [ + 'expires' => time() + 3600 * 24 * 30, + 'path' => '/', + 'httponly' => true, + 'secure' => true, + 'samesite' => 'Strict' + ]); } public function init_session(Int $uid) { diff --git a/server/SHServ/config.php b/server/SHServ/config.php new file mode 100644 index 0000000..8566a43 --- /dev/null +++ b/server/SHServ/config.php @@ -0,0 +1,47 @@ + $env['APP_NAME'] ?? "SHServ", + "version" => $env['APP_VERSION'] ?? "0.3 dev", + "debug" => ($env['DEBUG'] ?? "false") === "true", + "default_db_wrap" => false, + "db" => [ + "dblib" => $env['DB_DRIVER'] ?? "mysql", + "host" => $env['DB_HOST'] ?? "localhost", + "dbname" => $env['DB_NAME'] ?? "smart-home-server", + "charset" => $env['DB_CHARSET'] ?? "utf8", + "user" => $env['DB_USER'] ?? "root", + "password" => $env['DB_PASSWORD'] ?? "" + ], + "app_file" => "App.php", + "templates_folder" => "Templates", + "logs_enable" => true, + "logs_folder" => "SHServ/Logs", + "devmode" => ($env['DEVMODE'] ?? "false") === "true", + + "controllers_folder" => "Controllers", + "text_msgs" => require_once("SHServ/text-msgs.php"), + + "error_handler" => [ + "important_errors" => ["E_WARNING", "E_ERROR", "E_CORE_ERROR", "EXCEPTION"] + ], + + "device_ip_range" => [ + $env['DEVICE_IP_RANGE_START'] ?? "192.168.2.2", + $env['DEVICE_IP_RANGE_END'] ?? "192.168.2.254" + ] +]; diff --git a/server/console.php b/server/console.php index 44c0d06..c8a9653 100644 --- a/server/console.php +++ b/server/console.php @@ -8,7 +8,10 @@ switch($argv[1]) { case "get.config": - echo json_encode(FCONF); + $config = FCONF; + unset($config['db']['password']); + unset($config['db']['user']); + echo json_encode($config); break; default: echo "\nNo command"; } diff --git a/webclient/config.php b/webclient/config.php new file mode 100644 index 0000000..12293ba --- /dev/null +++ b/webclient/config.php @@ -0,0 +1,19 @@ + "0.3 dev", + // "server" => "http://smart-home-serv.local", + "server" => "http://192.168.1.101", + // Какие пути разрешены (белый список) — подстрой под себя + "allowed_prefixes" => [ + "/api/v1/", + ], + "proxy" => [ + // Кто может обращаться (CORS) + "allowed_origins" => [ + 'http://localhost:5173', + 'http://127.0.0.1:5173', + // 'https://your-frontend-domain.com', + ], + ], +]; diff --git a/webclient/proxy.php b/webclient/proxy.php new file mode 100644 index 0000000..c47a766 --- /dev/null +++ b/webclient/proxy.php @@ -0,0 +1,168 @@ + false, + 'message' => $message, + ], $extra), JSON_UNESCAPED_UNICODE); + exit; +} + +function get_request_headers_lower() { + $headers = []; + foreach (getallheaders() as $k => $v) { + $headers[strtolower($k)] = $v; + } + return $headers; +} + +function cors_headers($origin, $allowed_origins) { + if ($origin && in_array($origin, $allowed_origins, true)) { + header("Access-Control-Allow-Origin: {$origin}"); + header("Vary: Origin"); + } else { + // Если хочешь разрешить всем — раскомментируй (но лучше белый список) + header("Access-Control-Allow-Origin: *"); + } + + header("Access-Control-Allow-Credentials: true"); + header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS"); + header("Access-Control-Allow-Headers: Authorization, Content-Type, Accept, X-Requested-With"); + header("Access-Control-Max-Age: 86400"); +} + +// ========================= +// CORS preflight +// ========================= +$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; +cors_headers($origin, $allowed_origins); + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + +// ========================= +// Валидация входа +// ========================= + +// Ожидаем ?path=/api/v1/... +$path = $_GET['path'] ?? ''; +if (!$path || $path[0] !== '/') { + send_json_error(400, 'Missing or invalid "path" parameter. Example: ?path=/api/v1/scripts/actions/list'); +} + +// белый список путей +$ok = false; +foreach ($allowed_prefixes as $p) { + if (str_starts_with($path, $p)) { + $ok = true; + break; + } +} +if (!$ok) { + send_json_error(403, 'Path not allowed', ['path' => $path]); +} + +// Собираем URL апстрима + query string (кроме path) +$query = $_GET; +unset($query['path']); +$qs = http_build_query($query); +$upstream_url = rtrim($upstream_base_url, '/') . $path . ($qs ? ('?' . $qs) : ''); + +// ========================= +// Проксирование через cURL +// ========================= +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$headers_in = get_request_headers_lower(); + +$ch = curl_init($upstream_url); +curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); +curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); +curl_setopt($ch, CURLOPT_HEADER, true); // чтобы разделить headers/body +curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); +curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); +curl_setopt($ch, CURLOPT_TIMEOUT, 30); + +// Проксируем body для методов кроме GET/HEAD +$body = file_get_contents('php://input'); +if (!in_array($method, ['GET', 'HEAD'], true)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); +} + +// Заголовки, которые прокидываем +$forward_headers = []; + +// Authorization +if (!empty($headers_in['authorization'])) { + $forward_headers[] = 'Authorization: ' . $headers_in['authorization']; +} + +// Content-Type +if (!empty($headers_in['content-type'])) { + $forward_headers[] = 'Content-Type: ' . $headers_in['content-type']; +} + +// Accept +if (!empty($headers_in['accept'])) { + $forward_headers[] = 'Accept: ' . $headers_in['accept']; +} + +// Можно добавить свой заголовок, например X-Proxy +$forward_headers[] = 'X-Proxy: php'; + +curl_setopt($ch, CURLOPT_HTTPHEADER, $forward_headers); + +$response = curl_exec($ch); +if ($response === false) { + $err = curl_error($ch); + curl_close($ch); + send_json_error(502, 'Upstream request failed', ['details' => $err]); +} + +$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE); +curl_close($ch); + +$raw_headers = substr($response, 0, $header_size); +$resp_body = substr($response, $header_size); + +// ========================= +// Отдаём ответ клиенту +// ========================= +http_response_code($http_code); + +// Проксируем часть заголовков ответа (без CORS/опасных) +$lines = preg_split("/\r\n|\n|\r/", trim($raw_headers)); +foreach ($lines as $line) { + if (stripos($line, 'HTTP/') === 0) continue; + + $pos = strpos($line, ':'); + if ($pos === false) continue; + + $name = trim(substr($line, 0, $pos)); + $value = trim(substr($line, $pos + 1)); + + $name_l = strtolower($name); + + // не прокидываем hop-by-hop и то, что конфликтует + if (in_array($name_l, ['transfer-encoding', 'content-length', 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'upgrade'], true)) { + continue; + } + + // CORS мы уже выставили сами + if (str_starts_with($name_l, 'access-control-')) { + continue; + } + + header($name . ': ' . $value, false); +} + +echo $resp_body;