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:
co-authored by
Claude Fable 5.1
parent
273c3dd781
commit
3565abdd88
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user