fix(core): los ganchos de ciclo de vida de plugins funcionan en las webs locales

_pluginLifecycle validaba el token SOLO contra el WS central, que unicamente
conoce webs de produccion: en una copia local de Forge (standalone o de test)
el token nunca le valida y instalar, desinstalar, activar o desactivar un
plugin moria con "Token no valido", un mensaje que no tenia nada que ver con
el token.

Segunda via para esas copias: si el central rechaza y la web tiene .acai con
mode != production, se compara el par token+tokenHash recibido con el que la
propia web guarda en su .acai (hash_equals). No es un bypass — sin .acai, en
produccion o sin coincidencia exacta no autoriza nada —, y el secreto es el
mismo que Forge ya usa para hablar con la web.

Probado en superadmin/claude-es-mi-amigo: con el token del .acai el gancho
corre (ok:true); con un token falso o vacio, 403 como siempre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jordan Diaz
2026-09-22 16:27:18 +00:00
co-authored by Claude Opus 5
parent 0bbf053703
commit a9ecad4ee1
+33 -1
View File
@@ -399,13 +399,45 @@ function _renameFile() {
}
/**
* Segunda via de autenticacion del ciclo de vida, para las copias LOCALES de
* Forge (standalone o de test). El WS central solo conoce webs de produccion:
* el token de una web sin dominio real nunca le valida, asi que los ganchos
* (install/uninstall/enable/disable) quedaban inservibles ahi con un
* "Token no valido" que no tenia nada que ver con el token.
*
* No es un bypass: se compara el par token+tokenHash recibido con el que la
* propia web guarda en su .acai, que es exactamente el que Forge envia. Sin
* .acai (produccion), con mode=production, o sin coincidencia exacta, no
* autoriza nada.
*/
function _lifecycleTokenLocalValido($datosAuth) {
$acaiPath = __DIR__ . "/../../../.acai";
if (!is_file($acaiPath)) {
return false;
}
$acai = @json_decode(@file_get_contents($acaiPath), true);
if (!is_array($acai) || @$acai["mode"] === "production") {
return false;
}
$token = (string) @$datosAuth["token"];
$hash = (string) @$datosAuth["tokenHash"];
$tokenLocal = (string) @$acai["token"];
$hashLocal = (string) @$acai["tokenHash"];
if ($token === "" || $hash === "" || $tokenLocal === "" || $hashLocal === "") {
return false;
}
return hash_equals($tokenLocal, $token) && hash_equals($hashLocal, $hash);
}
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"])) {
if (!CocoWS::validateUploadToken(@$datosAuth["tokenHash"])
&& !_lifecycleTokenLocalValido($datosAuth)) {
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.