Преглед изворни кода

Finalize validated backup access and improve customer summary layout

feature/client-backups-summary
psarria пре 7 часа
родитељ
комит
cc9ff51089
10 измењених фајлова са 144 додато и 66 уклоњено
  1. +1
    -1
      config.example.php
  2. +20
    -33
      docs/BACKUPS-REGRESSION.md
  3. +18
    -4
      docs/BACKUPS.md
  4. +15
    -1
      lib/BackupAccess.php
  5. +3
    -2
      lib/BackupHttp.php
  6. +1
    -1
      lib/Backups.php
  7. +31
    -16
      templates/backups.php
  8. +10
    -2
      tests/backup-whmcs-mocks.php
  9. +44
    -5
      tests/backups.php
  10. +1
    -1
      tests/run.php

+ 1
- 1
config.example.php Прегледај датотеку

@@ -4,7 +4,7 @@ return [
'enabled' => false,
// Entitlement is fixed in code: applicable Backup diario must be integer 1.
'endpoint' => '', // Fixed HTTPS CGI path without query, fragment or embedded credentials.
'username' => '', // HTTP Basic over verified TLS; confirm account permissions.
'username' => '', // HTTP Basic over verified TLS; prefer a dedicated integration identity; administrative privileges are an accepted residual risk.
'password' => '',
'source_timezone' => '', // Verified IANA identifier, never inferred.
'connect_timeout_seconds' => 2,


+ 20
- 33
docs/BACKUPS-REGRESSION.md Прегледај датотеку

@@ -1,48 +1,35 @@
# Informe de regresión — 19/09/2026
# Regresión de Copias de seguridad

Implementación local, desactivada. Contrato de activación observado en la instalación real; backend y página cliente completa siguen pendientes. Este informe no es una aceptación de producción.
## Código consolidado

## Base y cambios
La lógica de acceso, contratación, transporte, parser, caché, callback y plantilla Smarty coincide por SHA-256 con el runtime de la validación real. Se conserva CurrentUser::user()->getClientsByPermission('products'), exigiendo la cuenta actual antes de comprobar pertenencia del servicio. No se usa Client::hasPermission.

Base local y HEAD remoto consultado: `1174c120dec0982b4c628a353c66cdaeab894b1e` (`main`, limpio). Worktree separado en rama `feature/client-backups-summary`; el checkout original permanece en `main`. Sin push, merge ni despliegue.
Única diferencia visual posterior a esa validación: templates/backups.php añade tarjetas de resumen, etiquetas de tipo, tabla responsive, alineación numérica y estados de indisponibilidad/datos antiguos. CSS acotado a .o6h-copies, sin JavaScript ni peticiones nuevas. El diseño se probó localmente; todavía no se ha desplegado ni validado visualmente en el tema productivo.

Archivos de runtime añadidos: `lib/BackupAccess.php`, `BackupOption.php`, `BackupCache.php`, `BackupHttp.php`, `BackupParser.php`, `Backups.php`, `templates/backups.php`, `templates/backups.tpl`. Cambios de integración: require del componente, parámetro opcional en registro de botones, botón condicional y callback Backups; nueva sección privada en `config.example.php`. Pruebas y fixtures saneados bajo `tests/`; documentación bajo `docs/`. El auditor extiende a `tests/backups.php` las excepciones existentes para sentinelas `FAKE_` y URLs inválidas de pruebas con dominio `example.invalid`; no se han eximido archivos de runtime.

La opción configurable del módulo, sus posiciones y las implementaciones existentes de provisioning, suspensión/reactivación, panel, Graphite, snapshots, firewall y reinicio/parada/arranque no se modifican. Los cinco botones anteriores siguen registrados incluso sin parámetros/configuración. Las dependencias nuevas fallan de forma aislada/cerrada para copias.

## Verificaciones ejecutadas

Runtime: PHP CLI 8.1.2-1ubuntu2.26, DOM/libxml del paquete Ubuntu `php8.1-xml`, Node.js v22.11.0. PHP no estaba en PATH; se reutilizó el binario local de la entrega anterior y se extrajo DOM bajo `work/`, sin instalar paquetes del sistema. Las suites PHP usan `-n`, con DOM cargado expresamente para backups, sin cURL real. Los comandos siguientes usan `php` como abreviatura de ese runtime.
## Verificaciones ejecutadas en la consolidación

| Comprobación | Resultado |
|---|---|
| Antes del cambio: `php -n tests/run.php` | PASS: 192 aserciones, HTTP simulado |
| Antes del cambio: `node tests/frontend.cjs` | PASS: 24 aserciones |
| Después: `php -n -d extension=/ruta/dom.so tests/backups.php` | PASS: 192 existentes + 142 nuevas |
| Después: `node tests/frontend.cjs` | PASS: 24 aserciones |
| Diagnóstico separado: `php -n test-diagnostico.php /ruta/checkout` | PASS: 51 aserciones |
| `php -l` en los 22 PHP del checkout y del diagnóstico | PASS |
| `node --check assets/metrics.js` | PASS, JS sin cambios |
| `git diff --check` | PASS |
| `python3 tools/audit.py` | PASS heurístico; revisión manual descrita abajo |
| tests/run.php, PHP sin cURL real | PASS: 192 aserciones |
| tests/backups.php | PASS: 227 aserciones adicionales |
| tests/frontend.cjs | PASS: 24 aserciones |
| php -l | PASS: 19 archivos PHP |
| git diff --check | PASS |
| tools/audit.py | PASS heurístico, sin hallazgos |
| Render vacío/stale/indisponible | PASS local sin warnings PHP |

Cobertura nueva ejecutada: claves exactas y grupos aplicables, no herencia de opciones de otros grupos, duplicados ambiguos, conflicto de ambas claves/tipos con incidencia segura, regla estricta con valores observados integer 1/0, valores ausentes/nulos/desconocidos, host y límites; detalle correcto/equivocado/resumen/login; tablas y filas reordenadas/unión por ID; contadores/tamaños ausentes; cinco/seis/cero filas; Filled, idle y estado desconocido; fechas y DST ambiguo/inexistente; HTTP 200/301/302/401/403/404/500, retorno falso cURL para fallo TLS/timeout y aborto por tamaño; URL fija y política TLS/redirect; propietario/sin sesión/permiso delegado denegado y permitido con mocks; alteración de ID desde callback real; no consultas denegadas; caché aislada por servicio/host/configuración, revocación, TTL, stale y caducidad; escape y sentinelas públicos.
Mocks de WHMCS y cURL: los tests no ejecutan operaciones VPS ni consultas upstream. Las pruebas negativas incluyen deleteBackup, Start_Full_Backup, Start_Incr_Backup, Stop_Dequeue_Backup, keepBackup, browse y editConfig. Se rechazan host/endpoint con parámetros inyectados; GET exacto, sin cuerpo, sin redirects ni ejecución de enlaces/formularios. Permisos en otra cuenta o modelos inesperados no conceden acceso. Los fixtures incluidos son exclusivamente los HTML saneados y resultados esperados de prueba.

El caso TLS simula el retorno de error de cURL y verifica sus opciones; no realiza un handshake real. El caso de delegado simula el contrato de CurrentUser/permiso; no ejecuta WHMCS. La prueba de integración llama al callback de producción con la frontera de sesión y consultas Capsule simuladas, incluyendo servicio ajeno: no se limita a pasar parámetros que ya se suponen autorizados, pero no ejecuta el router cifrado real.
## Evidencia real y límites

## Renderizado y privacidad
PASS real: HTTPS/TLS/Basic/200 y parser sobre un host autorizado; callback con integer1; página con cinco filas; acceso no contratado sin datos, servicio ajeno redirigido y sin sesión enviado a login (evidencia manual/capturas). DOM inspeccionado directamente; Network/consola confirmados por administrador. Caché con directorio0700/archivos0600; aislamiento HTTP confirmado por administrador. Logs accesibles revisados sin secretos atribuibles al módulo durante la ventana.

Se ejecutó `Backups::render` desde el callback y se guardó la evidencia sintética `vista-backups-fixture.html` junto a la entrega. Contiene la vista propia dentro de un contenedor HTML representativo, no el tema completo de WHMCS. Se comprueba ausencia de proveedor, hosts, URL privada, credencial falsa, número interno, enlaces y conteos de ficheros; el contenido malicioso queda escapado. Smarty recibe un único campo HTML propio. No se ha ejecutado Smarty ni una sesión de navegador sobre WHMCS: no se declara validada la página completa real.
La sesión web de inspección usó Login as Client. Delegados NO APLICABLE en la instalación actual; deben probarse realmente si se incorporan. Revocación, vacío y stale conservan cobertura sintética. No se alteraron contrataciones. No se afirma revisión integral de emails/plantillas personalizadas ni de todos los logs/APM. El aspecto nuevo requiere comprobación en el tema real tras despliegue autorizado.

Los tres fixtures se copiaron exclusivamente del ZIP saneado proporcionado. Revisión manual: hosts `.invalid`, usuario de prueba e IP documental; referencias públicas al proyecto del proveedor conservadas solo como material de parser. No se han copiado exportaciones originales, capturas de clientes, configuración privada ni credenciales reales. El escáner inicialmente señaló dos sentinelas de pruebas, revisados y cubiertos con las mismas excepciones acotadas que la suite existente. No publicar ni desplegar fixtures bajo una ruta HTTP.
## Contrato operativo

## Pendientes reales — no son PASS
Cuenta administrativa upstream admitida como riesgo residual aceptado; preferir identidad separada de integración. Credenciales solo server-side y privadas. Única interfaz cerrada getHostSummary y único GET fijo con host administrativo validado tras autenticar, comprobar permiso/cuenta/pertenencia y Backup diario === integer1. NO_GO ante secretos expuestos, host/endpoint del cliente, proxy genérico, redirects, acciones inyectables, logs sensibles o consultas de servicios ajenos.

- Ya ejecutado (no pendiente): observación administrativa de casilla marcada/desmarcada, ambas con clave `Backup diario` presente, tipo `integer`, valores `1` y `0`. Regla exacta y tests incorporados. Diagnóstico deshabilitado y retirado; checksum del módulo original restaurado: `7bc076e93636f2126b28ddf79e24759e88b20da7abe4c700d581c2a13bfc1f5d`.
- Ya ejecutado: WHMCS 8.13.3-release.1, PHP real 8.1.22, autenticación administrativa real en el callback, ausencia de tabla con enabled=false, enlaces administrativos conservados y log PHP sin nuevas entradas durante las comprobaciones. No equivale a validar la autorización cliente/delegada.
- WHMCS/tema/Smarty reales: firma efectiva del callback de botones, permiso `products`, CurrentUser, enrutamiento, propietario/delegados e IDOR, formato horario y semántica del campo exclusivamente administrativo, ausencia en emails/pedido/factura/página completa.
- Cuenta del origen, permisos de solo lectura efectivos, mecanismo de autenticación, endpoint, TLS real, zona horaria y estructura de detalle de un host realmente sin copias.
- Caché fuera de todos los alias HTTP, permisos del usuario PHP, exclusión del CDN/caché de página y verificación entre sesiones.
- Regresión operativa en staging: Graphite/panel/snapshots/firewall/reiniciar/detener/iniciar/suspensión/reactivación. El código existente se conserva y sus pruebas simuladas pasan, pero no se han ejecutado órdenes reales ni se garantiza desde este entorno la instalación productiva.
No se incluyen controles temporales, observadores de validación, datos de servicios reales, credenciales, rutas privadas de evidencia ni HTML original. Los códigos administrativos fijos de errores forman parte del comportamiento permanente y no contienen respuestas o excepciones del origen.

Instalación y rollback: `BACKUPS.md`. Diagnóstico temporal separado y no integrado en runtime: carpeta `diagnostico` de la entrega, con instrucciones para revisión. La implementación no está habilitada para clientes. La pareja activo/inactivo queda confirmada; permanecen pendientes las verificaciones reales enumeradas del backend, permisos y superficie cliente.
Configuración de ejemplo enabled=false. No merge/push a main ni activación automática. Instalación y rollback: BACKUPS.md.

+ 18
- 4
docs/BACKUPS.md Прегледај датотеку

@@ -1,6 +1,6 @@
# Copias de seguridad: instalación controlada y contrato

Estado: implementación en rama de trabajo, integración cerrada por defecto. No desplegada.
Estado: lógica validada mediante instalación controlada; integración deshabilitada al cierre. Diseño visual posterior probado localmente y pendiente de desplegar. No activación automática.
Base: `1174c120dec0982b4c628a353c66cdaeab894b1e`, rama original `main`, árbol original limpio. El HEAD remoto coincidía al consultar `git ls-remote origin HEAD` el 19/09/2026. Rama de trabajo: `feature/client-backups-summary`. No se ha hecho push ni merge.

## Contratación y autorización
@@ -11,21 +11,31 @@ La regla fija exige integración habilitada con `enabled === true`, resolución

`BackupOption::applicable()` resuelve únicamente los grupos vinculados al producto del servicio autorizado mediante `tblhosting.packageid` → `tblproductconfiglinks.pid/gid` → `tblproductconfigoptions.gid`. No usa LIKE, ni el primer resultado global, ni IDs de opciones para conceder acceso. Las definiciones duplicadas o no Sí/No fallan cerradas. El nombre antiguo `Backup diario VPS` se conserva solo en la detección de definiciones/conflictos: no tiene una regla de activación validada y no concede acceso. Si ambas claves están presentes con valores no idénticos (`!==`), se registra `OPTION_CONFLICT` con el ID y se deniega, sin elegir arbitrariamente.

La entrada es una función de módulo invocada por WHMCS. El registro `ClientAreaCustomButtonArray` conserva los cinco botones existentes y añade Backups solo si es elegible. La función `vpsmanager_Backups` vuelve a comprobarlo. Antes de caché/HTTP, `BackupAccess` comprueba CurrentUser, cuenta de cliente, permiso `products`, y pertenencia del servicio en la base de datos. No confunde el usuario delegado con el ID de la cuenta cliente. Versiones sin estas interfaces fallan de forma cerrada. La autorización efectiva del router WHMCS y sus accesos delegados debe comprobarse en staging de la versión instalada.
La entrada es una función de módulo invocada por WHMCS. El registro `ClientAreaCustomButtonArray` conserva los cinco botones existentes y añade Backups solo si es elegible. La función `vpsmanager_Backups` vuelve a comprobarlo. Antes de caché/HTTP, `BackupAccess` comprueba CurrentUser, cuenta de cliente, permiso `products`, y pertenencia del servicio en la base de datos. No confunde el usuario delegado con el ID de la cuenta cliente. Versiones sin estas interfaces fallan de forma cerrada. La autorización efectiva del router WHMCS y sus accesos delegados debe comprobarse en la instalación real autorizada.

## Instalación, sin ejecución automática

1. Respaldar el directorio actual del módulo y la configuración privada, conservando permisos. Registrar el commit instalado y los checksums. No usar operaciones reales sobre VPS/copias como pruebas de regresión.
2. Revisar el diff de la rama frente al commit base. Requisitos: PHP compatible con el módulo (probado 8.1.2), extensiones DOM/libxml y cURL. No instalar tests, fixtures, documentación, herramientas ni `.git` bajo el directorio web. Copiar únicamente el módulo y sus dependencias runtime en una ventana controlada, con la integración desactivada.
3. Ampliar el archivo privado que ya carga `Config::load()` (variable `O6H_VPSMANAGER_CONFIG` o `/etc/open6hosting/whmcs-vpsmanager.php`) con la sección `backups` de `config.example.php`, manteniendo intactas las opciones de Graphite/API. Permisos ajustados al usuario PHP; nunca guardar credenciales en el repo o en URLs/comandos.
4. Configurar el endpoint HTTPS fijo del CGI, sin query/fragmento/credenciales embebidas, y autenticación HTTP Basic sobre TLS verificado. Si la instalación usa otro mecanismo, no activar hasta adaptar/probarlo. Confirmar permisos efectivos de la cuenta: no ser administrador no demuestra solo lectura. Si la interfaz concede acciones, restringir adicionalmente el acceso de integración a la ruta y consulta de host previstas mediante un proxy/política revisados; no se ha instalado tal restricción.
4. Configurar el endpoint HTTPS fijo del CGI, sin query/fragmento/credenciales embebidas, y autenticación HTTP Basic sobre TLS verificado. Si la instalación usa otro mecanismo, no activar hasta adaptar/probarlo. Preferir una cuenta separada de integración si es posible, aunque conserve privilegios administrativos; de lo contrario se admite la credencial administrativa existente. Esos privilegios son un riesgo residual aceptado, no un criterio de NO_GO.
5. Confirmar la zona horaria IANA del origen. Crear un directorio de caché privado, modo 0700, accesible al usuario PHP y fuera de todas las raíces/alias web; configurar `cache_directory`. Los archivos usan 0600. Directorios públicos o permisivos se rechazan; un error de caché no impide la consulta, pero registra `CACHE` y elimina la ventaja de caché entre peticiones. Programar limpieza administrativa de JSON antiguos en ese directorio si cambian muchos servicios/mapeos: no se incluye un cron automático.
6. Por producto, comprobar si ya existe `backuppc_host`. Crear solo si falta, sin duplicados ni migraciones de dominio: texto, exclusivamente administrativo, no pedido, no factura, no campo de comunicaciones. Guardar un mapeo explícito. El código exige `adminonly='on'`, `showorder=''`, `showinvoice=''`, tipo `text`; confirmar estos valores de esquema en la versión instalada. No modificar campos automáticamente. La plantilla completa y los emails pueden tener personalizaciones: verificar que nunca exponen el nombre ni el valor.
7. La pareja activo/inactivo ya se observó en esta instalación y el diagnóstico fue deshabilitado y retirado, restaurando el módulo original con checksum verificado. Antes de activar, comprobar que no ha cambiado el nombre, tipo de opción ni versión/comportamiento de WHMCS. Si cambia el contrato, mantener desactivado y repetir una observación administrativa acotada; no ampliar la regla por aproximación.
8. En staging, comprobar contratado/no contratado, servicio ajeno cambiando ID, sin sesión, delegado permitido y denegado, revocación, cambio de mapeo y conflicto de nombres. Comprobar la página completa, DOM, HTML, emails y solicitudes del navegador: ninguna referencia al proveedor, host o credenciales; solo rutas WHMCS para esta función.
8. En la instalación real autorizada, comprobar contratado/no contratado, servicio ajeno cambiando ID, sin sesión, delegado permitido y denegado, revocación, cambio de mapeo y conflicto de nombres. Comprobar la página completa, DOM, HTML, emails y solicitudes del navegador: ninguna referencia al proveedor, host o credenciales; solo rutas WHMCS para esta función.
9. Probar fallo del backend y aislamiento respecto a los controles existentes. Desactivar cualquier caché HTTP/CDN de páginas privadas y confirmar que nunca comparte respuestas entre cuentas. El callback añade `Cache-Control: private, no-store`.
10. Activar únicamente tras superar esas comprobaciones y la revisión operativa. No iniciar, borrar, parar, descargar ni restaurar copias para validar esta lectura.

## Contrato operativo de credenciales y lectura

La cuenta disponible tiene capacidades administrativas amplias. Se acepta expresamente el riesgo residual: una filtración o compromiso del servidor podría permitir acciones fuera de esta integración. Los controles del módulo limitan sus peticiones, no reducen los privilegios efectivos de esa cuenta. No se exige una cuenta de solo lectura como condición de GO.

Credenciales exclusivamente server-side, en configuración privada fuera de Git y de toda raíz/alias HTTP; nunca HTML, Smarty, JavaScript, navegador, URLs, logs ni mensajes de error. No volcar configuración, cabeceras Basic, argumentos o excepciones en instrumentación/APM; comprobar también la configuración efectiva de logs de la instalación. Preferir identidad de integración separada para poder revocarla independientemente.

La única interfaz de transporte es `BackupHttp::getHostSummary($configPrivada, $hostValidado)`: configuración interna, sin URL o parámetros arbitrarios desde el navegador. Genera exclusivamente `GET <endpoint-fijo>?host=<host-validado>`. No existe interfaz genérica request/action/proxy de copias. Autenticación, permiso, pertenencia y contratación estricta preceden la lectura del campo administrativo; el host de params/GET/POST no se utiliza. No implementa start, stop/dequeue, delete, keep, restore, browse, descarga ni edición. No ejecuta enlaces, formularios o acciones del HTML y no sigue redirects.

Son NO_GO: credenciales expuestas, endpoint controlado por petición, host controlado por cliente, proxy genérico, redirects, introducción de acciones, logs con credenciales o consulta de servicios ajenos. Mantener `backups.enabled=false` hasta completar las demás pruebas reales de WHMCS y HTTPS y revisar sus resultados. Este cambio de contrato no autoriza despliegue.

## Datos y comportamiento

Una sola petición a `endpoint?host=...`, exclusivamente el host administrativo validado con `\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z`. No se siguen redirecciones, enlaces ni formularios. TLS verificado, límite por fragmento recibido de 2 MiB, conexión 2 s y total 5 s por defecto. Códigos HTTP distintos de 200 fallan. No hay endpoint público nuevo ni consultas del navegador al origen.
@@ -53,3 +63,7 @@ Comprobar que desaparece Backups y que panel/gráficas y botones existentes cons
- https://developers.whmcs.com/classes/whmcs/user/client

Estas referencias no sustituyen la validación de la versión y tema instalados.

## Corrección tras callback WHMCS real

La petición real confirmó servicio y opción integer1, cliente y sesión correctos, pero Client::hasPermission lanzó una excepción. Se sustituye por CurrentUser::user()->getClientsByPermission('products'), exigiendo que el array de modelos WHMCS\User\Client incluya la cuenta actual, antes de verificar la pertenencia del servicio. API y resultado para el propietario comprobados en la instalación; no se omite autorización por ser propietario o administrador. Pruebas: 192 de regresión y 227 de backups PASS. La comprobación web posterior y delegados reales siguen pendientes.

+ 15
- 1
lib/BackupAccess.php Прегледај датотеку

@@ -13,11 +13,25 @@ final class BackupAccess
if (!$id || !class_exists('WHMCS\\Authentication\\CurrentUser')) return null;
$current = new \WHMCS\Authentication\CurrentUser();
$client = $current->client();
if (!$client || !$current->isAuthenticatedUser() || !$client->hasPermission('products')) return null;
if (!$client || !$current->isAuthenticatedUser()) return null;
$user = $current->user();
if (!$user) return null;
// Permissions belong to the authenticated user's account relationship.
// WHMCS 8.13 returns an array of Client models for this permission.
$permittedClients = $user->getClientsByPermission('products');
if (!is_array($permittedClients)) return null;
$permitted = false;
foreach ($permittedClients as $permittedClient) {
if ($permittedClient instanceof \WHMCS\User\Client
&& (string) $permittedClient->id === (string) $client->id) $permitted = true;
}
if (!$permitted) return null;
$service = \WHMCS\Database\Capsule::table('tblhosting')->where('id', $id)->where('userid', $client->id)->first();
if (!$service) return null;
try { $keys = BackupOption::applicable($service->packageid); }
catch (\Throwable $e) { Backups::diagnostic('OPTION_DEFINITION', $id); return null; }
// Do not even resolve the private mapping before entitlement is established.
if (!Backups::entitled($params, Config::load()['backups'] ?? [], $keys)) return null;
$fields = \WHMCS\Database\Capsule::table('tblcustomfields')
->where('type', 'product')->where('relid', $service->packageid)
->where('fieldname', 'backuppc_host')->get();


+ 3
- 2
lib/BackupHttp.php Прегледај датотеку

@@ -4,7 +4,8 @@ require_once __DIR__ . '/Config.php';

final class BackupHttp
{
public static function request(array $config, string $host): string
// Private server configuration only; no URL, action or arbitrary query arguments.
public static function getHostSummary(array $config, string $host): string
{
if (!Backups::validHost($host) || !function_exists('curl_init')) throw new \RuntimeException('TRANSPORT');
$endpoint = Config::url($config, 'endpoint');
@@ -16,7 +17,7 @@ final class BackupHttp
if ($ch === false) throw new \RuntimeException('TRANSPORT');
try {
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => false, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPGET => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => Backups::limit($config, 'connect_timeout_seconds', 2, 1, 10),
CURLOPT_TIMEOUT => Backups::limit($config, 'timeout_seconds', 5, 1, 30),


+ 1
- 1
lib/Backups.php Прегледај датотеку

@@ -83,7 +83,7 @@ final class Backups
$age = is_int($entry['obtained_at'] ?? null) ? $now - $entry['obtained_at'] : PHP_INT_MAX;
if ($age >= 0 && $age < min($ttl, $stale) && is_array($entry['data'] ?? null)) return self::publicView($entry, false, $config, $now);
try {
$body = $transport ? $transport($config, $context['host']) : BackupHttp::request($config, $context['host']);
$body = $transport ? $transport($config, $context['host']) : BackupHttp::getHostSummary($config, $context['host']);
if (!is_string($body) || strlen($body) > self::limit($config, 'max_response_bytes', 2097152, 1024, 2097152)) throw new \RuntimeException('SIZE');
$data = BackupParser::parse($body, $context['host'], $config['source_timezone'] ?? '');
$entry = ['obtained_at' => $now, 'data' => $data];


+ 31
- 16
templates/backups.php Прегледај датотеку

@@ -1,23 +1,38 @@
<?php if (!defined('WHMCS')) { http_response_code(403); exit; } ?>
<section aria-label="Copias de seguridad">
<style>
.o6h-copies{color:#334555;line-height:1.5}.o6h-copies h2{font-size:25px;font-weight:400;margin:0 0 8px}.o6h-copies .copies-intro{color:#687b89;margin-bottom:24px}.o6h-copies .copies-cards{display:flex;flex-wrap:wrap;gap:14px;margin:0 0 26px}.o6h-copies .copies-card{flex:1 1 220px;border:1px solid #dfe6ec;border-radius:8px;padding:19px;background:linear-gradient(120deg,#f7fafc,#fff)}.o6h-copies .copies-label{font-size:12px;color:#596f7e;margin-bottom:12px}.o6h-copies .copies-value{font-size:24px;font-weight:600;color:#164f6d}.o6h-copies .copies-date{font-size:18px}.o6h-copies .copies-detail{font-size:12px;color:#596f7e;margin-top:8px}.o6h-copies .copies-badge{display:inline-block;border-radius:4px;padding:3px 8px;font-size:12px;white-space:nowrap;background:#eef1f4;color:#52616f}.o6h-copies .copies-full{background:#e7effa;color:#305e94}.o6h-copies .copies-incremental{background:#e9f4f4;color:#346c71}.o6h-copies .copies-panel{border:1px solid #dfe6ec;border-radius:8px;overflow:hidden}.o6h-copies .copies-panel-heading{padding:18px 20px;display:flex;justify-content:space-between;gap:12px;align-items:center}.o6h-copies .copies-panel-heading h3{margin:0;font-size:16px;font-weight:500}.o6h-copies .copies-panel-heading span{font-size:12px;color:#657885}.o6h-copies .table-responsive{margin:0;border:0}.o6h-copies table{width:100%;margin:0;border-collapse:collapse;font-size:13px}.o6h-copies th{background:#f5f8fa;color:#596f7e;font-size:12px;font-weight:600;text-align:left;padding:12px 18px}.o6h-copies td{padding:17px 18px;border-top:1px solid #edf0f3;vertical-align:middle}.o6h-copies tbody tr:first-child{background:#fafcfe}.o6h-copies .copies-num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.o6h-copies .copies-latest{display:block;color:#59748b;font-size:11px;margin-top:3px}.o6h-copies .copies-none{color:#467056}.o6h-copies .copies-present{color:#87551c}.o6h-copies .copies-footer{padding:13px 20px;border-top:1px solid #e6ecf0;background:#fbfcfd;color:#596f7e;font-size:12px}.o6h-copies .copies-note{font-size:12px;color:#596f7e;margin-top:18px}.o6h-copies .copies-message{padding:18px 20px;border:1px solid #dfe6ec;background:#f7fafc;border-radius:8px;margin:16px 0}.o6h-copies .copies-warning{background:#fff8e8;border-color:#e6cd91;color:#795519}@media(max-width:600px){.o6h-copies .copies-card{flex-basis:100%}.o6h-copies td,.o6h-copies th{padding:12px}.o6h-copies .copies-panel-heading{align-items:flex-start}}
</style>
<section class="o6h-copies" aria-label="Copias de seguridad">
<h2>Copias de seguridad</h2>
<p class="copies-intro">Consulta las copias disponibles de tu servicio.</p>
<?php if (empty($view['available'])): ?>
<p>La información de las copias de seguridad no está disponible temporalmente.</p>
<?php else: ?>
<?php if ($view['stale']): ?><p role="status">No se ha podido actualizar la información. Se muestran los últimos datos disponibles.</p><?php endif; ?>
<p>Datos obtenidos: <?= $escape($view['updated']) ?>. Zona horaria: <?= $escape($view['timezone']) ?>.</p>
<?php if ($view['activity'] !== null): ?><p>Actividad actual: <?= $escape($view['activity']) ?>.</p><?php endif; ?>
<p>Copias disponibles: <?= $escape($view['total']) ?> — <?= $escape($view['full']) ?> completas y <?= $escape($view['incremental']) ?> incrementales.</p>
<div class="copies-message" role="status">La información de las copias de seguridad no está disponible temporalmente.</div>
<?php else:
$typeClass = static fn($type) => ['Completa' => 'copies-full', 'Incremental' => 'copies-incremental'][$type] ?? '';
$incidentClass = static fn($status) => ['Sin incidencias registradas' => 'copies-none', 'Con incidencias' => 'copies-present'][$status] ?? '';
?>
<?php if ($view['stale']): ?><div class="copies-message copies-warning" role="status">No se ha podido actualizar la información. Se muestran los últimos datos disponibles.</div><?php endif; ?>
<div class="copies-cards">
<div class="copies-card"><div class="copies-label">Copias disponibles</div><div class="copies-value"><?= $escape($view['total']) ?></div><div class="copies-detail"><?= $escape($view['full']) ?> completas · <?= $escape($view['incremental']) ?> incrementales</div></div>
<?php if ($view['total'] > 0): ?>
<div class="copies-card"><div class="copies-label">Inicio de la última copia</div><div class="copies-value copies-date"><?= $escape($view['rows'][0]['start']) ?></div><div class="copies-detail"><span class="copies-badge <?= $typeClass($view['rows'][0]['type']) ?>"><?= $escape($view['rows'][0]['type']) ?></span> · Hace <?= $escape($view['age']) ?> horas</div></div>
<div class="copies-card"><div class="copies-label">Inicio de la última completa</div><div class="copies-value copies-date"><?= $escape($view['last_full']) ?></div></div>
<?php endif; ?>
</div>
<?php if ($view['activity'] !== null): ?><p class="copies-detail">Actividad actual: <?= $escape($view['activity']) ?>.</p><?php endif; ?>
<div class="copies-panel">
<?php if ($view['total'] === 0): ?>
<p>Todavía no hay copias disponibles.</p>
<div class="copies-message" role="status">Todavía no hay copias disponibles.</div>
<?php else: ?>
<p>Inicio de la última copia: <?= $escape($view['rows'][0]['start']) ?>. Tipo: <?= $escape($view['rows'][0]['type']) ?>.</p>
<p>Antigüedad desde el inicio: <?= $escape($view['age']) ?> horas. Incidencias: <?= $escape($view['rows'][0]['incidents']) ?>.</p>
<p>Inicio de la última completa: <?= $escape($view['last_full']) ?>.</p>
<h3>Últimas copias disponibles</h3>
<div class="table-responsive"><table class="table"><thead><tr><th scope="col">Inicio</th><th scope="col">Tipo</th><th scope="col">Duración</th><th scope="col">Tamaño de datos</th><th scope="col">Incidencias</th></tr></thead><tbody>
<?php foreach ($view['rows'] as $row): ?><tr><?php foreach (['start', 'type', 'duration', 'size', 'incidents'] as $column): ?><td><?= $escape($row[$column]) ?></td><?php endforeach; ?></tr><?php endforeach; ?>
<div class="copies-panel-heading"><h3>Últimas copias disponibles</h3><span><?= $escape(count($view['rows'])) ?> de <?= $escape($view['total']) ?></span></div>
<div class="table-responsive"><table><thead><tr><th scope="col">Inicio</th><th scope="col">Tipo</th><th scope="col" class="copies-num">Duración</th><th scope="col" class="copies-num">Tamaño de datos</th><th scope="col">Incidencias</th></tr></thead><tbody>
<?php foreach ($view['rows'] as $index => $row): ?>
<tr><td><strong><?= $escape($row['start']) ?></strong><?php if ($index === 0): ?><span class="copies-latest">Última</span><?php endif; ?></td><td><span class="copies-badge <?= $typeClass($row['type']) ?>"><?= $escape($row['type']) ?></span></td><td class="copies-num"><?= $escape($row['duration']) ?></td><td class="copies-num"><?= $escape($row['size']) ?></td><td><span class="<?= $incidentClass($row['incidents']) ?>"><?= $escape($row['incidents']) ?></span></td></tr>
<?php endforeach; ?>
</tbody></table></div>
<p>Las fechas indican el inicio. Los tamaños son datos lógicos aproximados. La lista muestra copias retenidas, no todos los intentos ni una verificación de restauración.</p>
<?php endif; endif; ?>
<?php endif; ?>
<div class="copies-footer">Datos obtenidos: <?= $escape($view['updated']) ?> · Zona horaria: <?= $escape($view['timezone']) ?>.</div>
</div>
<p class="copies-note">Las fechas indican el inicio. Los tamaños son datos lógicos aproximados. La lista muestra copias retenidas, no todos los intentos ni una verificación de restauración.</p>
<?php endif; ?>
</section>

+ 10
- 2
tests/backup-whmcs-mocks.php Прегледај датотеку

@@ -1,8 +1,10 @@
<?php
// Representative WHMCS boundary only: not an implementation of the encrypted router.
namespace WHMCS\User { class Client {} }
namespace WHMCS\Authentication {
class CurrentUser {
public function isAuthenticatedAdmin() { return $GLOBALS['isAdmin'] ?? false; }
public function user() { return new \BackupTestUser(); }
public function client() { return $GLOBALS['actor'] ?? null; }
public function isAuthenticatedUser() { return isset($GLOBALS['actor']); }
}
@@ -25,10 +27,16 @@ namespace WHMCS\Database {
}
}
namespace {
class BackupTestClient {
class BackupTestClient extends \WHMCS\User\Client {
public $id; public $permitted;
public function __construct($id, $permitted = true) { $this->id = $id; $this->permitted = $permitted; }
public function hasPermission($permission) { return $permission === 'products' && $this->permitted; }
public function hasPermission($permission) { throw new \RuntimeException('Unsupported client permission API'); }
}
class BackupTestUser {
public function getClientsByPermission($permission) {
if (isset($GLOBALS['permissionClients'])) return $GLOBALS['permissionClients'];
$c=$GLOBALS['actor']??null; return $permission==='products' && $c && $c->permitted ? [$c] : [];
}
}
function logActivity($message) { $GLOBALS['safeLogs'][] = $message; }
}

+ 44
- 5
tests/backups.php Прегледај датотеку

@@ -111,16 +111,50 @@ try {
rejects(fn() => BackupParser::timestamp('2026-02-30 01:00', new DateTimeZone('Europe/Madrid')), 'invalid date');
check(BackupParser::timestamp('2026-03-29 03:30', new DateTimeZone('Europe/Madrid')) > 0, 'valid DST');
$GLOBALS['response'] = ['status' => 200, 'body' => $html];
check(BackupHttp::request($config, $host) === $html, 'HTTP valid');
check(BackupHttp::getHostSummary($config, $host) === $html, 'HTTP valid');
$call = end($GLOBALS['calls']);
check($call->url === $config['endpoint'] . '?host=' . $host, 'one fixed host read only query');
check($call->options[CURLOPT_FOLLOWLOCATION] === false && $call->options[CURLOPT_SSL_VERIFYPEER] === true && $call->options[CURLOPT_SSL_VERIFYHOST] === 2, 'redirect TLS policy');
check($call->options[CURLOPT_TIMEOUT] === 5 && $call->options[CURLOPT_CONNECTTIMEOUT] === 2, 'bounded time');
foreach ([301, 302, 401, 403, 404, 500] as $status) { $GLOBALS['response']['status'] = $status; rejects(fn() => BackupHttp::request($config, $host), 'HTTP error'); }
foreach (['timeout', 'TLS'] as $failure) { $GLOBALS['response'] = ['timeout' => true]; rejects(fn() => BackupHttp::request($config, $host), $failure . ' curl false'); }
foreach ([301, 302, 401, 403, 404, 500] as $status) { $GLOBALS['response']['status'] = $status; rejects(fn() => BackupHttp::getHostSummary($config, $host), 'HTTP error'); }
foreach (['timeout', 'TLS'] as $failure) { $GLOBALS['response'] = ['timeout' => true]; rejects(fn() => BackupHttp::getHostSummary($config, $host), $failure . ' curl false'); }
$GLOBALS['response'] = ['status' => 200, 'body' => str_repeat('x', 2097153)];
rejects(fn() => BackupHttp::request($config, $host), 'streaming size limit');
foreach (['http://example.invalid/cgi', 'https://u:p@example.invalid/cgi', 'https://example.invalid/cgi?action=delete'] as $url) rejects(fn() => BackupHttp::request(array_replace($config, ['endpoint' => $url]), $host), 'unsafe endpoint');
rejects(fn() => BackupHttp::getHostSummary($config, $host), 'streaming size limit');
foreach (['http://example.invalid/cgi', 'https://u:p@example.invalid/cgi', 'https://example.invalid/cgi?action=delete'] as $url) rejects(fn() => BackupHttp::getHostSummary(array_replace($config, ['endpoint' => $url]), $host), 'unsafe endpoint');
// All upstream action injection vectors must be rejected or ignored.
check(get_class_methods(BackupHttp::class) === ['getHostSummary'], 'closed summary-only transport API');
$actions = ['deleteBackup', 'Start_Full_Backup', 'Start_Incr_Backup', 'Stop_Dequeue_Backup', 'keepBackup', 'browse', 'editConfig'];
foreach ($actions as $action) {
$requestsBefore = count($GLOBALS['calls']);
foreach (['&action=', '?action=', '%26action%3D'] as $separator) {
rejects(fn() => BackupHttp::getHostSummary($config, $host . $separator . $action), 'host action injection denied');
}
rejects(fn() => BackupHttp::getHostSummary(array_replace($config, ['endpoint' => $config['endpoint'] . '?action=' . $action]), $host), 'endpoint action injection denied');
check(count($GLOBALS['calls']) === $requestsBefore, 'invalid inputs cause no upstream request');
$_GET = $_POST = $_REQUEST = ['action' => $action, 'host' => 'attacker.invalid', 'endpoint' => 'https://attacker.invalid/cgi', 'num' => '1', 'type' => 'full', 'keep' => '1'];
$injected = $params + $_GET;
$injected['customfields']['backuppc_host'] = 'attacker.invalid';
$uncached = array_replace($config, ['cache_ttl_seconds' => 0]);
writeConfig($temp, ['backups' => $uncached]);
$GLOBALS['response'] = ['status' => 200, 'body' => $html . '<a href="?action=' . $action . '">link</a><form action="?action=' . $action . '"></form>'];
$page = vpsmanager_Backups($injected);
check(count($GLOBALS['calls']) === $requestsBefore + 1, 'exactly one summary request; HTML actions never followed');
$call = end($GLOBALS['calls']);
check($call->url === $config['endpoint'] . '?host=' . $host, 'browser host endpoint and all action parameters ignored');
check($call->options[CURLOPT_HTTPGET] === true && !isset($call->options[CURLOPT_POSTFIELDS]), 'GET only without request body');
check(strpos($page['vars']['copiesHtml'], $action) === false, 'upstream action absent from public view');
$denied = $injected; $denied['configoptions']['Backup diario'] = 0;
check(BackupAccess::context($denied) === null, 'no mapping before entitlement');
vpsmanager_Backups($denied);
$denied['serviceid'] = 43;
vpsmanager_Backups($denied);
check(count($GLOBALS['calls']) === $requestsBefore + 1, 'uncontracted and foreign services cannot propagate actions');
}
$_GET = $_POST = $_REQUEST = [];
writeConfig($temp, ['backups' => $config]);
foreach (['FAKE_USER', 'FAKE_SECRET_SENTINEL', 'PRIVATE-ENDPOINT'] as $secret) {
check(strpos(implode(' ', $GLOBALS['safeLogs']), $secret) === false, 'no credentials or endpoint in logs');
}
// Invoke the production callback with representative WHMCS session/database boundaries.
$GLOBALS['response'] = ['status' => 200, 'body' => $html];
$_GET = ['id' => 43, 'host' => 'OTHER_CUSTOMER.invalid', 'action' => 'deleteBackup']; $_POST = $_GET;
@@ -139,6 +173,11 @@ try {
$GLOBALS['actor'] = new BackupTestClient(7, true);
check(isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'delegated account context with permission');
check(count($GLOBALS['calls']) === $requests, 'denied requests never query backend');
$GLOBALS['permissionClients'] = [new BackupTestClient(8)];
check(!isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'permission on another account does not grant access');
$GLOBALS['permissionClients'] = [(object)['id'=>7]];
check(!isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'unexpected permission model denied');
unset($GLOBALS['permissionClients']);
$GLOBALS['db']['tblcustomfields'][0]->adminonly = '';
check(!isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'non-admin field refused');
check(strpos(vpsmanager_Backups($params)['vars']['copiesHtml'], 'temporalmente') !== false, 'invalid mapping generic');


+ 1
- 1
tests/run.php Прегледај датотеку

@@ -4,7 +4,7 @@ if (extension_loaded('curl')) {
fwrite(STDERR, "Run with php -n (without the cURL extension).\n");
exit(1);
}
foreach (['CURLOPT_RETURNTRANSFER', 'CURLOPT_CONNECTTIMEOUT', 'CURLOPT_TIMEOUT', 'CURLOPT_SSL_VERIFYPEER',
foreach (['CURLOPT_HTTPGET', 'CURLOPT_RETURNTRANSFER', 'CURLOPT_CONNECTTIMEOUT', 'CURLOPT_TIMEOUT', 'CURLOPT_SSL_VERIFYPEER',
'CURLOPT_SSL_VERIFYHOST', 'CURLOPT_POSTFIELDS', 'CURLOPT_HTTPHEADER', 'CURLOPT_FOLLOWLOCATION',
'CURLOPT_PROTOCOLS', 'CURLPROTO_HTTPS', 'CURLOPT_WRITEFUNCTION', 'CURLINFO_HTTP_CODE', 'CURLOPT_HTTPAUTH', 'CURLAUTH_BASIC', 'CURLOPT_USERNAME', 'CURLOPT_PASSWORD'] as $i => $name) {
define($name, $i + 1);


Loading…
Откажи
Сачувај