diff --git a/docs/server-audit.md b/docs/server-audit.md index 11b8604..724b63f 100644 --- a/docs/server-audit.md +++ b/docs/server-audit.md @@ -128,10 +128,12 @@ --- -## Phase 2 — Целостность данных и обработка ошибок +## Phase 2 — Целостность данных и обработка ошибок ✅ Выполнена **Цель:** Убрать silent failures, сделать транзакции атомарными, починить инвертированную логику error handler'а. +**Коммит:** `1a30037` (ветка `dev`) + > **Блокер:** пока error handler инвертирован, трудно доверять логам и репортам. ### 2.1 🟠 Инвертированный ErrorHandler @@ -221,10 +223,12 @@ --- -## Phase 3 — Укрепление API +## Phase 3 — Укрепление API ✅ Выполнена **Цель:** Валидация входных данных, единообразные ответы, защита от abuse. +**Коммит:** `35f9ec8` (ветка `dev`) + > **Блокер:** валидация входных данных бессмысленна, если за ней всё равно стоит уязвимый ThinBuilder (Phase 1). Поэтому Phase 3 идёт **после** Phase 1. ### 3.1 🟠 Валидация входных данных diff --git a/server/SHServ/App.php b/server/SHServ/App.php index c052469..b1f39bb 100644 --- a/server/SHServ/App.php +++ b/server/SHServ/App.php @@ -65,6 +65,20 @@ return; } + // Rate limiting: 60 req/min per IP + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $rate_limiter = new \SHServ\Tools\RateLimiter(60, 60); + if (!$rate_limiter -> check($ip)) { + header('Content-Type: application/json'); + http_response_code(429); + echo json_encode([ + 'status' => false, + 'error_alias' => 'rate_limit_exceeded', + 'msg' => 'Too many requests' + ]); + exit; + } + $token = null; $auth_header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; diff --git a/server/SHServ/Controllers/AreasRESTAPIController.php b/server/SHServ/Controllers/AreasRESTAPIController.php index a7a917c..a8e3b84 100644 --- a/server/SHServ/Controllers/AreasRESTAPIController.php +++ b/server/SHServ/Controllers/AreasRESTAPIController.php @@ -52,6 +52,10 @@ public function new_area($type, $alias, $display_name) { $areas_model = new Areas(); + if(!preg_match('/^[a-z0-9_]+$/', $alias) || strlen($alias) > 255) { + return $this -> utils() -> response_error("invalid_alias", ["alias"]); + } + if(!$areas_model -> alias_is_uniq($alias)) { return $this -> utils() -> response_error("alias_already_exists", ["alias"]); } @@ -171,8 +175,8 @@ return $this -> utils() -> response_error("invalid_id", ["area_id"]); } - if(!strlen($new_alias)) { - return $this -> utils() -> response_error("empty_field", ["new_alias"]); + if(!preg_match('/^[a-z0-9_]+$/', $new_alias) || strlen($new_alias) > 255) { + return $this -> utils() -> response_error("invalid_alias", ["new_alias"]); } $areas_model = new Areas(); diff --git a/server/SHServ/Controllers/DevicesRESTAPIController.php b/server/SHServ/Controllers/DevicesRESTAPIController.php index 94e4b36..906620d 100644 --- a/server/SHServ/Controllers/DevicesRESTAPIController.php +++ b/server/SHServ/Controllers/DevicesRESTAPIController.php @@ -29,15 +29,15 @@ public function setup_new_device($device_ip, $alias, $name, $description) { $devices_model = new \SHServ\Models\Devices(); - if(strlen($device_ip) < 7) { - return $this -> utils() -> response_error("invalid_ip"); + if(!filter_var($device_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $this -> utils() -> response_error("invalid_ip", ["device_ip"]); } - if(!strlen($alias)) { - return $this -> utils() -> response_error("empty_field", ["alias"]); + if(!preg_match('/^[a-z0-9_]+$/', $alias) || strlen($alias) > 255) { + return $this -> utils() -> response_error("invalid_alias", ["alias"]); } - if(!strlen($name)) { + if(!strlen($name) || strlen($name) > 255) { return $this -> utils() -> response_error("empty_field", ["name"]); } @@ -211,6 +211,22 @@ return $this -> utils() -> response_error("device_not_found"); } + if(is_string($params)) { + $decoded = json_decode($params, true); + if(json_last_error() !== JSON_ERROR_NONE) { + return $this -> utils() -> response_error("invalid_json", ["params"]); + } + $params = $decoded; + } + + if(!is_array($params)) { + return $this -> utils() -> response_error("invalid_params", ["params"]); + } + + if(!preg_match('/^[a-z0-9_]+$/', $action) || strlen($action) > 255) { + return $this -> utils() -> response_error("invalid_action", ["action"]); + } + $result = $device -> device_api() -> post_action($action, $params); $device_response = $result["data"] ?? []; @@ -356,6 +372,10 @@ $devices_model = new \SHServ\Models\Devices(); + if(!preg_match('/^[a-z0-9_]+$/', $new_alias) || strlen($new_alias) > 255) { + return $this -> utils() -> response_error("invalid_alias", ["new_alias"]); + } + if(!$devices_model -> alias_is_uniq($new_alias)) { return $this -> utils() -> response_error("alias_already_exists", ["new_alias"]); } diff --git a/server/SHServ/Controllers/ScriptsRESTAPIController.php b/server/SHServ/Controllers/ScriptsRESTAPIController.php index 7d6d7d0..9021c35 100644 --- a/server/SHServ/Controllers/ScriptsRESTAPIController.php +++ b/server/SHServ/Controllers/ScriptsRESTAPIController.php @@ -17,6 +17,22 @@ class ScriptsRESTAPIController extends \SHServ\Middleware\Controller { public function run_action_script($alias, $params) { + if(is_string($params)) { + $decoded = json_decode($params, true); + if(json_last_error() !== JSON_ERROR_NONE) { + return $this -> utils() -> response_error("invalid_json", ["params"]); + } + $params = $decoded; + } + + if(!is_array($params)) { + return $this -> utils() -> response_error("invalid_params", ["params"]); + } + + if(!preg_match('/^[a-z0-9_]+$/', $alias) || strlen($alias) > 255) { + return $this -> utils() -> response_error("invalid_alias", ["alias"]); + } + $result = ControlScripts::run_action_script($alias, $params); if(!$result) { @@ -75,7 +91,7 @@ return $this -> utils() -> response_error("scope_not_found"); } - return $file; + return $this -> utils() -> response_success(["source" => $file]); } public function scope_update($name, $path, $file) { @@ -88,15 +104,19 @@ return $this -> utils() -> response_error("scope_not_found"); } - $filepath = "{$path}/{$name}"; - $filepath = strpos($filepath, ".php") ? $filepath : $filepath . ".php"; + $allowed_dir = realpath(__DIR__ . "/../../ControlScripts/Scopes/"); + $filepath = realpath("{$path}/{$name}") ?: realpath("{$path}/{$name}.php"); + + if(!$filepath || strpos($filepath, $allowed_dir) !== 0) { + return $this -> utils() -> response_error("invalid_path", ["path"]); + } if(!file_exists($filepath)) { return $this -> utils() -> response_error("file_not_exists"); } $result = file_put_contents($filepath, $file); - return $result + return $result ? $this -> utils() -> response_success(["result" => true]) : $this -> utils() -> response_error("undefined_error"); } @@ -108,6 +128,10 @@ } protected function set_script_state(String $type, String $uniq_name, String $state) { + if(!in_array($state, ["enable", "disable"], true)) { + return $this -> utils() -> response_error("invalid_state", ["state"]); + } + if($state == "enable") { $result = (new Scripts()) -> enable_script($type, $uniq_name); } else { diff --git a/server/SHServ/Tools/RateLimiter.php b/server/SHServ/Tools/RateLimiter.php new file mode 100644 index 0000000..fd855c2 --- /dev/null +++ b/server/SHServ/Tools/RateLimiter.php @@ -0,0 +1,33 @@ + maxRequests = $maxRequests; + $this -> windowSeconds = $windowSeconds; + } + + public function check(String $key): bool { + $now = time(); + if(!isset(self::$requests[$key])) { + self::$requests[$key] = []; + } + + // Удаляем устаревшие записи + self::$requests[$key] = array_filter(self::$requests[$key], function($timestamp) use ($now) { + return $now - $timestamp < $this -> windowSeconds; + }); + + if(count(self::$requests[$key]) >= $this -> maxRequests) { + return false; + } + + self::$requests[$key][] = $now; + return true; + } +}