4 Commits
Author SHA1 Message Date
Jordan DiazandClaude Fable 5.1 e198bd780a fix(plugins): _removeFile solo borra contenido, nunca el motor
La regla anterior permitia cualquier ruta dentro de la raiz de la web, y eso
incluye cms/lib, lib o los PHP del motor. Ahora en modo fichero solo se admite
algo bajo cms/uploads/, template/, hooks/ o cache/; en modo plugin, una carpeta
directa de pluginsdir. Lo demas, 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8
2026-09-12 22:59:07 +00:00
Jordan DiazandClaude Fable 5.1 ea8eb6dbcb fix(plugins): red de seguridad ante exit() en un gancho y globales del CMS
Tras la revision de codigo de 273c3dd:
- Un exit()/die() dentro de un gancho no es una excepcion y dejaba un cuerpo
  que no era JSON. register_shutdown_function en _pluginLifecycle responde
  {ok:false, error, messages:[lo impreso]} si el gancho no llego al final.
- event/plugin/origin se castean solo si son escalares (un array producia un
  Warning delante del JSON).
- Los ganchos ven CURRENT_USER, menu, action, TABLE_PREFIX y SETTINGS como
  un checkpoint desde addPlugins(); documentado en el contrato.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8
2026-09-12 22:51:56 +00:00
Jordan DiazandClaude Fable 5.1 3565abdd88 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
2026-09-12 22:50:20 +00:00
Jordan DiazandClaude Fable 5.1 273c3dd781 feat(plugins): ganchos de ciclo de vida y checkpoints boot_plugins y post_variables
Fase 0 del instalador de plugins (docs/plugins/fase0-instalador-diseno.md del
repo de Forge). Un plugin puede llevar install.php, uninstall.php, enable.php,
disable.php y status.php; los ejecuta runPluginLifecycle() dentro de la web
por action_ws=pluginLifecycle, sea Forge o el CMS legacy quien instale.
showInstall() reutiliza el mismo nucleo (installPluginArtifacts) en vez de
duplicar install.sql/schemaPresets.

Checkpoints nuevos, sin efecto hasta que un plugin los declare:
- boot_plugins: en init.php, con la BD ya conectada.
- post_variables: tras cada require de lib/variables.php (slug.php, sesion.php,
  lib/recursos.php), una vez por peticion. Un plugin llega DESPUES del bloque
  de cache de variables.php y puede pisarlo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPL66q9F33YZxbLPUT7KW8
2026-09-12 22:40:42 +00:00
6 changed files with 241 additions and 7 deletions
+80 -3
View File
@@ -30,6 +30,7 @@ if (isset($_REQUEST["action_ws"])){
case "coreUpdate": _coreUpdate(realpath($GLOBALS["APP"]["pluginsdir"]."/../../lib")); break;
case "pluginsUpdate": _coreUpdate(realpath($GLOBALS["APP"]["pluginsdir"]),true); break;
case "removeFile": _removeFile(); break;
case "pluginLifecycle": _pluginLifecycle(); break;
case "clearCache": _clearCache(); break;
case "renameFile": _renameFile();
default : die(json_encode(["result" => 0,"error" => "Método no permitido"]));
@@ -309,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);
@@ -386,3 +390,76 @@ function _renameFile() {
die(json_encode(['success' => file_exists($newFile)]));
}
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.
$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)"]));
}
// Red de seguridad: exit()/die() dentro de un gancho no es una excepcion y
// se saltaria el die(json_encode(...)) de abajo dejando un cuerpo que no es
// JSON. Si al cerrar no hemos respondido, respondemos nosotros.
$GLOBALS["_pluginLifecycleRespondido"] = false;
register_shutdown_function(function () {
if (!empty($GLOBALS["_pluginLifecycleRespondido"])) return;
$salida = "";
while (ob_get_level() > 0) { $salida .= (string) ob_get_clean(); }
echo json_encode(array(
"ok" => false,
"error" => "El gancho ha terminado con exit()/die() sin devolver resultado",
"messages" => $salida !== "" ? array(trim($salida)) : array(),
));
});
$event = is_scalar(@$datos["event"]) ? (string) $datos["event"] : "";
$plugin = is_scalar(@$datos["plugin"]) ? (string) $datos["plugin"] : "";
$origin = is_scalar(@$datos["origin"]) ? (string) $datos["origin"] : "";
$context = array(
"origin" => in_array($origin, array("forge", "legacy"), true) ? $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",
);
$resultado = runPluginLifecycle($plugin, $event, $context);
$GLOBALS["_pluginLifecycleRespondido"] = true;
die(json_encode($resultado));
}
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 {
// Lista blanca: contenido de la web, nunca su motor ni su configuracion.
if (strpos($ruta, "\0") !== false || substr($ruta, 0, 1) === '/') return false;
$base = false;
foreach (array('cms/uploads', 'template', 'hooks', 'cache') as $permitido) {
if (strpos($ruta, $permitido . '/') === 0) { $base = realpath($raizWeb . '/' . $permitido); break; }
}
if (!$base) return false;
$real = realpath($raizWeb . '/' . $ruta);
}
if (!$real || $real === $base || $real === $pluginsDir || $real === $raizWeb) return false;
if (strpos($real, $base . DIRECTORY_SEPARATOR) !== 0) return false;
return $real;
}
+8
View File
@@ -27,6 +27,14 @@ displaySocketErrors();
if ($SETTINGS['isInstalled']) {
connectToMySQL();
}
// Checkpoint `boot_plugins` (2026-09-12): el CMS esta cargado y la BD conectada.
// Los plugins que lo declaren en [checkPoints] se inicializan aqui en cada
// peticion (instalar/reconciliar lo suyo, autodiagnostico). Ningun plugin del
// catalogo lo declaraba al crearlo: no cambia nada hasta que uno lo use.
if (function_exists('addPlugins')) {
$bootPluginsVar = array();
addPlugins('boot_plugins', $bootPluginsVar);
}
function enableErrorReporting() {
if (!defined('E_STRICT')) { define('E_STRICT', 2048); } // define E_STRICT for PHP < 5
error_reporting(E_ALL | E_STRICT); // display all errors
+128 -3
View File
@@ -75,9 +75,8 @@ function showInstall($path){
<div id="page-content">
<h3>Instalando el plugin</h3>
<? $borro = true; ?>
<? if (installSQL($path)){ if ($borro) rename($path."/install.sql",$path."/install.sql.bck"); ?><p>Instalando tablas necesarias</p><?}else{?><p class="text-danger">Error instalando la base de datos</p><?}?>
<? if (installSchemas($path)){ if ($borro) rename($path."/schemaPresets",$path."/schemaPresets.bck"); ?><p>Instalando Schemas</p><?}else{?><p class="text-danger">Error instalando los esquemas</p><?}?>
<? $mensajesInstall = []; installPluginArtifacts($path, $mensajesInstall); ?>
<? foreach ($mensajesInstall as $mensajeInstall): ?><p><?=htmlspecialchars($mensajeInstall);?></p><? endforeach; ?>
<? if ($result==2){?>
<p>&nbsp;</p>
<a href="">Recarga para cargar el plugin</a>
@@ -356,4 +355,130 @@ function query($sql)
}
}
// ---------------------------------------------------------------------------
// Ciclo de vida de plugins (fase 0, 2026-09-12).
//
// Ganchos OPCIONALES en la carpeta del plugin: install.php, uninstall.php,
// enable.php, disable.php y status.php. Los ejecuta runPluginLifecycle()
// DENTRO de la web, instale quien instale (Forge o el CMS legacy), a traves de
// action_ws=pluginLifecycle (cms/lib/handlers/handler_ws.php).
//
// Contrato de un gancho: se incluye en un ambito propio con $plugin, $pluginDir,
// $event, $context y $dropData; debe ser IDEMPOTENTE (puede ejecutarse dos veces);
// puede devolver ["ok" => bool, "messages" => [..], "status" => [..]] o
// simplemente terminar. status.php devuelve semaforos:
// [["clave" => "...", "estado" => "ok|warn|error", "texto" => "..."], ...].
// Lo que el gancho imprima se recoge como mensaje, nunca sale por la respuesta.
// El gancho ve $CURRENT_USER, $menu, $action, $TABLE_PREFIX y $SETTINGS como un
// checkpoint; cualquier otro global lo declara el. NUNCA exit()/die(): no es una
// excepcion, se salta el resultado y solo la red de seguridad de handler_ws.php
// evita una respuesta que no sea JSON.
// ---------------------------------------------------------------------------
function pluginLifecycleEvents() {
return array("install", "uninstall", "enable", "disable", "status");
}
function pluginFolderIsValid($folder) {
return is_string($folder) && $folder !== "" && $folder !== "." && $folder !== ".."
&& preg_match('/^[a-zA-Z0-9._-]+$/', $folder) === 1;
}
function pluginDirResolved($folder) {
// Ruta real del plugin, o false si no existe o se sale de pluginsdir.
$pluginsDir = realpath($GLOBALS["APP"]["pluginsdir"]);
if (!$pluginsDir || !pluginFolderIsValid($folder)) return false;
$dir = realpath($pluginsDir . "/" . $folder);
if (!$dir || !is_dir($dir)) return false;
if (strpos($dir, $pluginsDir . DIRECTORY_SEPARATOR) !== 0) return false;
return $dir;
}
function installPluginArtifacts($path, &$messages) {
// Lo que hacia showInstall() sin el HTML: install.sql y schemaPresets/ una
// sola vez (se renombran a .bck, como siempre). false si el SQL fallo.
$ok = true;
if (file_exists($path . "/install.sql")) {
if (installSQL($path)) {
@rename($path . "/install.sql", $path . "/install.sql.bck");
$messages[] = "install.sql ejecutado";
} else {
$ok = false;
$messages[] = "install.sql ha fallado";
}
}
if (file_exists($path . "/schemaPresets")) {
installSchemas($path); // renombra schemaPresets -> schemaPresets.bck
$messages[] = "schemaPresets instalados";
}
return $ok;
}
function _includePluginHook($hookFile, $plugin, $pluginDir, $event, $context, $dropData) {
// Ambito propio: el gancho no ve las variables de quien lo llama, pero si
// los mismos globales del CMS que ve un checkpoint desde addPlugins().
global $CURRENT_USER, $menu, $action, $TABLE_PREFIX, $SETTINGS;
ob_start();
$ret = include $hookFile;
$salida = trim((string) ob_get_clean());
return array("ret" => $ret, "output" => $salida);
}
function _pluginCoreStatus($folder, $pluginDir) {
$schemas = getSchemaPlugins(true);
$enabled = !empty($schemas[$folder]["enabled"]);
$pendiente = file_exists($pluginDir . "/install.sql") || file_exists($pluginDir . "/schemaPresets");
$status = array(
array("clave" => "instalado", "estado" => "ok", "texto" => "Plugin instalado en la web"),
array("clave" => "activado", "estado" => $enabled ? "ok" : "warn",
"texto" => $enabled ? "Plugin activado" : "Plugin desactivado"),
array("clave" => "core_ganchos", "estado" => "ok", "texto" => "El core de esta web soporta ganchos de plugins"),
);
if ($pendiente) {
$status[] = array("clave" => "instalacion_pendiente", "estado" => "warn",
"texto" => "install.sql o schemaPresets pendientes de ejecutar");
}
return $status;
}
function runPluginLifecycle($folder, $event, $context = array()) {
$res = array("ok" => false, "event" => $event, "plugin" => $folder, "ran" => false,
"messages" => array(), "status" => array(), "error" => null);
if (!pluginFolderIsValid($folder)) { $res["error"] = "Nombre de plugin no valido"; return $res; }
if (!in_array($event, pluginLifecycleEvents(), true)) { $res["error"] = "Evento no valido"; return $res; }
$pluginDir = pluginDirResolved($folder);
if (!$pluginDir) { $res["error"] = "El plugin no existe en esta web"; return $res; }
if (!file_exists($pluginDir . "/schema.ini.php")) { $res["error"] = "El plugin no tiene schema.ini.php"; return $res; }
$dropData = !empty($context["dropData"]);
try {
if ($event === "install" && !installPluginArtifacts($pluginDir, $res["messages"])) {
$res["error"] = "install.sql ha fallado";
return $res;
}
if ($event === "status") $res["status"] = _pluginCoreStatus($folder, $pluginDir);
$hookOk = true;
$hookFile = $pluginDir . "/" . $event . ".php";
if (file_exists($hookFile)) {
$res["ran"] = true;
$hook = _includePluginHook($hookFile, $folder, $pluginDir, $event, $context, $dropData);
if ($hook["output"] !== "") $res["messages"][] = $hook["output"];
if (is_array($hook["ret"])) {
if (array_key_exists("ok", $hook["ret"]) && !$hook["ret"]["ok"]) $hookOk = false;
foreach ((array) @$hook["ret"]["messages"] as $m) $res["messages"][] = (string) $m;
foreach ((array) @$hook["ret"]["status"] as $st) if (is_array($st)) $res["status"][] = $st;
if (!empty($hook["ret"]["error"])) $res["error"] = (string) $hook["ret"]["error"];
}
}
$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: " . ($raiz ? str_replace($raiz, "", $e->getMessage()) : $e->getMessage());
}
return $res;
}
?>
+8
View File
@@ -1,5 +1,13 @@
<?
require_once "variables.php";
// Checkpoint `post_variables` (2026-09-12): variables.php ya ha corrido. Un plugin
// puede pisar aqui lo que decida la web (p.ej. la cache) antes de resolver la
// pagina. Una sola vez por peticion aunque se cargue desde varios sitios.
if (function_exists("addPlugins") && !defined("POST_VARIABLES_DISPARADO")) {
define("POST_VARIABLES_DISPARADO", true);
$postVariablesVar = array();
addPlugins("post_variables", $postVariablesVar);
}
require_once "FileJoiner.php";
$fileType = "css";
+8
View File
@@ -4,6 +4,14 @@ if (session_status() == PHP_SESSION_NONE) { session_start(); }
require_once(dirname(__FILE__)."/lib/Module.php");
require_once(dirname(__FILE__)."/lib/Resource.class.php");
require_once(dirname(__FILE__)."/lib/variables.php");
// Checkpoint `post_variables` (2026-09-12): variables.php ya ha corrido. Un plugin
// puede pisar aqui lo que decida la web (p.ej. la cache) antes de resolver la
// pagina. Una sola vez por peticion aunque se cargue desde varios sitios.
if (function_exists("addPlugins") && !defined("POST_VARIABLES_DISPARADO")) {
define("POST_VARIABLES_DISPARADO", true);
$postVariablesVar = array();
addPlugins("post_variables", $postVariablesVar);
}
require_once(dirname(__FILE__)."/lib/minifier.php");
if (file_exists(dirname(__FILE__)."/hooks/autorequire-files")){
+8
View File
@@ -5,6 +5,14 @@ if (@$sep[1]){
header("Location: http://".$_SERVER["HTTP_HOST"].$sep[0]."");
}
require_once "lib/variables.php";
// Checkpoint `post_variables` (2026-09-12): variables.php ya ha corrido. Un plugin
// puede pisar aqui lo que decida la web (p.ej. la cache) antes de resolver la
// pagina. Una sola vez por peticion aunque se cargue desde varios sitios.
if (function_exists("addPlugins") && !defined("POST_VARIABLES_DISPARADO")) {
define("POST_VARIABLES_DISPARADO", true);
$postVariablesVar = array();
addPlugins("post_variables", $postVariablesVar);
}
$sufijo = ".html";
if (@$_REQUEST["tipo"]=="barra") $sufijo="";