From 3565abdd880aeb96d6eb066dcfb512171157d0fb Mon Sep 17 00:00:00 2001 From: Jordan Diaz Date: Sat, 12 Sep 2026 22:50:20 +0000 Subject: [PATCH] fix(plugins): endurecer pluginLifecycle y sanear _removeFile Tras la revision de seguridad de 273c3dd: - pluginLifecycle exige POST con cuerpo JSON (token, tokenHash, event, plugin), como _saveFile: el token no viaja en la query string. - La excepcion de un gancho se loguea entera en el servidor y al cliente va sin rutas absolutas. - _removeFile (preexistente): la ruta la mandaba el cliente sin sanear y _deleteAll borraba cualquier cosa alcanzable, ../ incluido. Ahora en modo plugin solo una carpeta directa de pluginsdir y en modo fichero solo dentro de la raiz de la web; nunca la raiz ni pluginsdir, nunca fuera por symlink. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8 --- cms/lib/handlers/handler_ws.php | 46 ++++++++++++++++++++++++++------- cms/lib/plugins/index.php | 7 ++++- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/cms/lib/handlers/handler_ws.php b/cms/lib/handlers/handler_ws.php index 4c42433..53d4ada 100755 --- a/cms/lib/handlers/handler_ws.php +++ b/cms/lib/handlers/handler_ws.php @@ -310,9 +310,12 @@ function _removeFile() { die(json_encode(['error' => ['message' => 'Datos no enviados', 'code' => 403]])); } - $filePath = $fileData['file']; - if (isset($fileData['plugin'])) { - $filePath = $GLOBALS['APP']['pluginsdir'].'/'.$filePath; + // Saneado (2026-09-12): la ruta la manda el cliente. En modo plugin solo se + // borra una carpeta DIRECTA de pluginsdir; en modo fichero, algo dentro de la + // raiz de la web. Nunca la raiz ni pluginsdir en si, nunca fuera por ../ o symlink. + $filePath = _removeFileRutaSegura((string) $fileData['file'], isset($fileData['plugin'])); + if ($filePath === false) { + die(json_encode(['error' => ['message' => 'Ruta no permitida', 'code' => 403]])); } $deleted = _deleteAll($filePath); @@ -393,23 +396,48 @@ function _pluginLifecycle() { // Fase 0 (2026-09-12): ejecuta los ganchos de ciclo de vida de un plugin // (install/uninstall/enable/disable/status) DENTRO de la web. Unico punto // de entrada para Forge y para el CMS legacy. Misma autenticacion que coreUpdate. - CocoWS::setToken(@$_REQUEST["token"]); - if (!CocoWS::validateUploadToken(@$_REQUEST["tokenHash"])) { + $datosAuth = json_decode(file_get_contents('php://input'), true); + CocoWS::setToken(@$datosAuth["token"]); + if (!CocoWS::validateUploadToken(@$datosAuth["tokenHash"])) { die(json_encode(['error' => ['message' => 'Token no válido', 'code' => 403]])); } + // Solo body JSON (como _saveFile): el token no debe viajar en la query string. + $datos = json_decode(file_get_contents('php://input'), true); + if (!is_array($datos) || empty($datos["tokenHash"])) { + die(json_encode(['error' => ['message' => 'pluginLifecycle exige POST con cuerpo JSON', 'code' => 400]])); + } header('Content-Type: application/json'); if (!function_exists("runPluginLifecycle")) { // El cargador de plugins de esta web es anterior a los ganchos: el sync // respeta ficheros personalizados y puede haber entregado este y no aquel. die(json_encode(["ok" => false, "error" => "El cargador de plugins de esta web no soporta ganchos (cms/lib/plugins/index.php desactualizado)"])); } - $event = (string) @$_REQUEST["event"]; - $plugin = (string) @$_REQUEST["plugin"]; + $event = (string) @$datos["event"]; + $plugin = (string) @$datos["plugin"]; $context = array( - "origin" => in_array(@$_REQUEST["origin"], array("forge", "legacy"), true) ? $_REQUEST["origin"] : "desconocido", - "dropData" => filter_var(@$_REQUEST["dropData"], FILTER_VALIDATE_BOOLEAN), + "origin" => in_array(@$datos["origin"], array("forge", "legacy"), true) ? $datos["origin"] : "desconocido", + "dropData" => filter_var(@$datos["dropData"], FILTER_VALIDATE_BOOLEAN), // Misma señal de entorno que header.php: el .acai solo existe en las copias locales. "mode" => file_exists(__DIR__ . "/../../../.acai") ? "local" : "production", ); die(json_encode(runPluginLifecycle($plugin, $event, $context))); } + +function _removeFileRutaSegura($ruta, $esPlugin) { + $pluginsDir = realpath($GLOBALS['APP']['pluginsdir']); + $raizWeb = $pluginsDir ? realpath($pluginsDir . '/../../..') : false; + if (!$pluginsDir || !$raizWeb || $ruta === '') return false; + if ($esPlugin) { + $nombre = basename($ruta); + if ($nombre !== $ruta || $nombre === '.' || $nombre === '..') return false; + $base = $pluginsDir; + $real = realpath($pluginsDir . '/' . $nombre); + } else { + if (strpos($ruta, "\0") !== false) return false; + $base = $raizWeb; + $real = realpath((substr($ruta, 0, 1) === '/') ? $ruta : $raizWeb . '/' . $ruta); + } + if (!$real || $real === $base || $real === $pluginsDir || $real === $raizWeb) return false; + if (strpos($real, $base . DIRECTORY_SEPARATOR) !== 0) return false; + return $real; +} diff --git a/cms/lib/plugins/index.php b/cms/lib/plugins/index.php index d91a3dc..8e7a5a4 100644 --- a/cms/lib/plugins/index.php +++ b/cms/lib/plugins/index.php @@ -465,8 +465,13 @@ function runPluginLifecycle($folder, $event, $context = array()) { $res["ok"] = $hookOk; if (!$hookOk && !$res["error"]) $res["error"] = "El gancho " . $event . ".php ha devuelto ok=false"; } catch (Throwable $e) { + // El detalle completo (ruta, linea) va al log del servidor; al cliente, + // el mensaje sin rutas absolutas. + error_log("[pluginLifecycle] " . $folder . "/" . $event . ".php: " . $e->getMessage() + . " en " . $e->getFile() . ":" . $e->getLine()); + $raiz = realpath($GLOBALS["APP"]["pluginsdir"] . "/../../.."); $res["ok"] = false; - $res["error"] = "Excepcion en " . $event . ".php: " . $e->getMessage(); + $res["error"] = "Excepcion en " . $event . ".php: " . ($raiz ? str_replace($raiz, "", $e->getMessage()) : $e->getMessage()); } return $res; }