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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8
This commit is contained in:
Jordan Diaz
2026-09-12 22:50:20 +00:00
co-authored by Claude Fable 5.1
parent 273c3dd781
commit 3565abdd88
2 changed files with 43 additions and 10 deletions
+37 -9
View File
@@ -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;
}