Saltar al contenido principal

Seguridad

Mecanismos del framework​

  • ✅ API Keys para autenticar peticiones a la API.
  • ✅ Tokens JWT con expiración (1 día por defecto) y validación en BD.
  • ✅ Contraseñas encriptadas (bcrypt/Blowfish).
  • ✅ CSRF: el CMS envía X-CSRF-Token en cada petición AJAX; el servidor lo valida (exime GET). Corolario que hay que tener presente: como el guard exime GET por método, una acción de escritura alcanzable por GET queda sin ninguna protección. Por eso la acción se lee de $_POST y solo una lista cerrada de acciones —descargas y lecturas— se acepta por $_GET. El interceptor se registra al cargar, no en DOMContentLoaded: jQuery dispara sus ready antes, y la primera petición de un plugin viajaba sin token.
  • ✅ Configuración sensible fuera del control de versiones (config.php en .gitignore) + soporte de variables de entorno.
  • ✅ CORS configurado.
  • ✅ Validación de tablas y columnas antes de operar (identificadores saneados, prepared statements).
  • ✅ Sistema de permisos por rol en el CMS.
  • ✅ Cabeceras de seguridad en el .htaccess raíz: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, HSTS (salvo en localhost) y una Content-Security-Policy.

Endpoints de plugins: los tres gates​

El ajax.php de un plugin es una URL propia. El CMS ya filtra la página por rol (cms/views/template.php), pero eso no protege el endpoint: quien no ve la sección en el menú puede llamarlo igual. Por eso cada uno decide por sí mismo:

  1. Sesión — isset($_SESSION['admin']), respondiendo 401 (no 200 con un cuerpo de error: el código de estado es lo que leen los proxies, el monitoreo y el propio interceptor de sesión del panel).
  2. Permiso sobre la sección — cms_can_manage_section() / cms_can_access_page(), con la lista access.roles_manage del plugin. Para las escrituras destructivas, por acción: cms_can_do($seccion, 'delete'). cms_can_manage_section() resuelve por lectura, así que sin esto una cuenta con solo «Leer» borra.
  3. CSRF — SessionController::validateCsrfRequest() en toda escritura.

Faltando el gate 2 no falla nada visible: el panel se ve correcto y el endpoint responde a cualquier cuenta con sesión, incluidas las cuentas restringidas del portal de empleados. tests/plugin_security_test.php verifica los tres en todos los plugins, y php tools/make-plugin.php genera el ajax.php ya cerrado.

Tres reglas que acompañan a las anteriores:

  • Hay escrituras que no bastan con el permiso de la sección. Cuando una acción reemplaza la instalación entera o crea estructura nueva, el gate sube a superadmin, se conceda lo que se conceda en Roles y Permisos: restaurar la plataforma, el restablecimiento de fábrica, importar contenido (cms/ajax/content-transfer.ajax.php, crea secciones y tablas) y todo el taller de clientes (plugins/license-server/ajax.php, reemplaza el sitio de la máquina). Exportar, que solo lee, se queda en el permiso de la sección.

  • Permiso sobre la sección ≠ permiso sobre el registro. Un id que llega por POST se valida contra quién lo manda. Una sesión de admin no es autoridad para actuar en nombre de otra persona.

  • Los archivos que genera un plugin no se sirven desde uploads/. El directorio se cierra con Require all denied, el nombre no se deriva de un id correlativo, y la descarga pasa por un endpoint que verifica quién pregunta. core/pdf_maker.php lo hace por todos: privateDir() reescribe el .htaccess en cada llamada, no «si falta» —el que importa es el que está ahora, y una restauración o un despliegue descuidado se lo llevan sin que nada avise— y newFileName() es random_bytes(16).

    Tres directorios viven así hoy: comprobantes de transferencia (plugins/gastos-comunes/uploads/comprobantes), certificados (uploads/gc-certificates) y actas enviadas a firmar (uploads/asm-actas). Los tres llevan el nombre, el RUT o la deuda de una persona: Options -Indexes no basta, porque sólo esconde el listado. firma-electronica envió una versión guardando documento-7-firmado.pdf en un directorio servido — recorrer ids entregaba todos los contratos firmados de la instalación.

El wrapper también decide​

El wrapper de cms/views/pages/custom/<slug>/ requiere el controller del plugin directamente, saltándose PluginsLoader, así que necesita su propio gate aunque el router ya filtre la sección: si la página muestra una credencial o maneja la flota de licencias, «alguien le dio esta sección a este rol» no alcanza. Además consulta cms_plugin_entitled($slug) en los plugins comerciales, o la sección se abre en una instalación que no la tiene licenciada. tests/plugin_security_test.php lo verifica en todos los wrappers.

Superficies de lectura​

Un dashboard o un reporte son otra forma de mirar una sección, así que responden al permiso de la sección dueña del dato (cms_page_of_table() + cms_can_do($pagina, 'read')), no solo al de la propia herramienta. Sin eso son una puerta lateral a datos que la persona no puede abrir de frente. La lista negra de tablas del núcleo se aplica en las consultas, no solo al ofrecer el selector.

Archivos que llegan de afuera​

core/upload_guard.php: lista blanca de extensiones —una lista negra tiene que enumerar todas las peligrosas y siempre deja alguna— más comprobación de que el contenido corresponda a la extensión. La comprobación solo bloquea ante una contradicción: si el detector no puede clasificar el archivo (application/octet-stream) se admite, porque «no sé» no es «es otra cosa» y rechazarlo tiraría archivos legítimos. Las extensiones ejecutables y las que corren en el navegador (.svg, .html) se rechazan aunque se configuren.

Canales públicos​

Todo endpoint sin sesión (public.php, la API de licencias) pasa por ApiRateLimiter::allow($canal, $limites), con presupuesto propio por canal para que agotar uno no deje sin servicio a los demás.

Exportaciones​

Una celda de CSV que empieza con =, +, -, @, tabulador o retorno la ejecuta la planilla que la abre. Se antepone una comilla (ReportEngine::csvSafeCell()).

Checklist completa: .claude/skills/create-plugin/SECURITY-CHECKLIST.md.

Content-Security-Policy y contenido embebido​

La CSP vive en el .htaccess de la raíz. Puntos a tener en cuenta:

  • script-src y connect-src están restringidos a orígenes conocidos: es la parte que impide inyectar código o filtrar datos, y no debe ampliarse a la ligera.
  • frame-src e img-src permiten cualquier origen https: (más localhost para desarrollo) porque el módulo Contenido libre (HTML, CSS y JS) y las páginas web embeben sitios e imágenes externas. Sin frame-src explícito la directiva caería en default-src 'self' y todo iframe externo quedaría en blanco, sin más pista que un error en la consola del navegador.
  • 'self' es un nombre de servidor, no «este sitio». www.ejemplo.cl no es 'self' para una página abierta en ejemplo.cl, así que un recurso del propio sitio escrito con el dominio adentro se cae entero el día que alguien llega por el otro nombre: la página carga, y sus hojas de estilo y sus scripts se rechazan en silencio. Por eso los recursos del sitio público salen como ruta, sin esquema ni host (core/asset_base.php), y lo que sí tiene que nombrar el sitio entero —<link rel="canonical">, og:url, og:image— se deja intacto. tests/asset_base_test.php lo vigila.
  • Que un iframe siga sin cargar tras esto suele ser cosa del sitio remoto, que prohíbe ser embebido con X-Frame-Options o frame-ancestors (Google, bancos, muchos SaaS). Compruébalo con curl -sI https://el-sitio | grep -i "x-frame\|content-security".

Las etiquetas de Google son un interruptor, no una excepción​

Una etiqueta de Google Tag Manager o Analytics pegada en «Etiquetas del <head>» se imprimía y no corría: script-src no la permite, y el navegador la descarta sin decir nada en pantalla. Ampliar script-src para todos sería el arreglo fácil y el equivocado —un contenedor de GTM ejecuta JavaScript arbitrario—, así que sólo lo pagan las instalaciones que lo piden.

  • El .htaccess trae dos políticas: la estricta, y la misma más los orígenes de medición de Google. La segunda lleva la condición "expr=-f '%{DOCUMENT_ROOT}/web/partials/analytics.on'".
  • Ese archivo lo escribe y lo borra el panel: Páginas Web → Lo que es del sitio → Etiquetas del <head>. Sin archivo, política estricta.
  • La condición va en la cabecera, no en un RewriteRule [E=...]. mod_rewrite en un .htaccess sólo corre las reglas del directorio más profundo con RewriteEngine On, así que una variable puesta en la raíz nunca llega a /cms ni a /web —donde están las páginas—. mod_headers sí hereda del padre, que es por lo que la política misma funciona desde ahí.
  • Sólo se agregan hosts de Google, y sólo a script-src y connect-src: img-src y frame-src ya permiten cualquier https:.
  • El archivo es una petición, no un hecho: un hosting con AllowOverride None ignora el .htaccess y el interruptor no hace nada. Por eso el panel le pregunta al sitio después de encenderlo (wsa_googleTagsActive(), lee la cabecera que vuelve) y avisa si el servidor lo está ignorando. Sin esa comprobación el operador ve el interruptor encendido y Analytics sin visitas, que es el mismo silencio de antes.

La cuenta del proveedor no se toca desde el panel del cliente​

Toda instalación se entrega con dos autoridades: el superadmin del proveedor —una sola cuenta, creada al instalar— y un rol de la matriz llamado «Administrador», que es con el que trabaja el cliente. El Administrador usa todo lo que su rol tenga marcado (regla #11) y nunca alcanza la cuenta del proveedor.

Tres caminos lo permitían, y los tres se encontraron repitiendo una petición de verdad contra el entorno QA:

CaminoQué permitíaDónde se cierra
Formulario de perfilTomaba id_admin del POST y corre en cada carga del panel: cualquier sesión mandaba el id del superadmin con una clave suya. Una cajera lo hizo.AdminsController::updateAdmin() — la cuenta sale de la sesión; un id ajeno se rechaza y se registra
Grid de UsuariosComprobaba sección y acción, nunca la fila. El superadmin sólo está oculto del listado. Repetir la petición con su id lo borró.dynamic.controller.php y dynamic-tables.ajax.php
API genéricaUn token auténtico escribía cualquier fila de cualquier tabla.api/routes/services/{put,delete,post}.php

La decisión vive en core/account_guard.php, pura y probada sin base de datos (tests/account_guard_test.php):

  • cms_admin_row_writable($fila, $actor) — una fila rol_admin = superadmin sólo la escribe su propio dueño. Toda otra fila sigue siendo trabajo corriente del Administrador: blindar al proveedor no puede volverse «no puede trabajar».
  • cms_admin_role_value_allowed($valor) — superadmin y admin no se asignan desde ningún formulario ni por la API. El primero se define al instalar; el segundo precede a la matriz y la ignora entera. Las cuentas que ya los tengan siguen funcionando: lo que se niega es escribir el valor.
  • cms_admin_protected_columns() — rol_admin, permissions_admin, token_admin y token_exp_admin no viajan en una escritura genérica. id_role_admin no está protegida a propósito: repartir roles es para lo que existe la pantalla de Usuarios.

Y la vuelta que quedaba abierta: negar el valor no impedía agregar rol_admin como columna del módulo Usuarios y escribirlo ahí. modules.controller.php lo rechaza.

Las dos pantallas del proveedor​

cms_reserved_pages() devuelve [license-server, administradores]. No es una restricción nueva —el wrapper de las dos ya exigía ser el proveedor— sino que la matriz dejó de ofrecerlas: marcar una casilla que la pantalla iba a rechazar es el origen de «le di acceso y sigue sin poder entrar». Todo lo demás sigue delegable, Empaquetado y Actualizaciones incluidos: son la instalación del cliente.

El rol «Administrador» se siembra, y recibe lo que se instala después​

core/admin_role.php:

  • cms_admin_role_seed($link) lo crea si la instalación no tiene ninguno de los nombres de cms_admin_role_names(). Lo llaman el instalador, el restablecimiento de fábrica y migrations/seed_administrator_role.sql. Un rol existente no se pisa: sus casillas son decisiones de alguien.
  • cms_admin_role_grant_page($url, $link) concede una sección nueva a los roles de administrador que no hayan decidido sobre ella. Ausente del mapa lee como «sin acceso», así que un módulo instalado el mes pasado era invisible para quien lo instaló. Presente con read = 0 es un desmarcado deliberado y se respeta. Una pantalla del proveedor nunca se concede.

Vivía dentro de community-manager, así que sólo funcionaba donde ese plugin estuviera. CommunityIntegrationsEngine::grantPageToAdminRoles() sigue siendo su punto de entrada y ahora delega en el núcleo.

Contraseñas, enlaces de recuperación y secretos guardados​

Recuperar una contraseña no es regalar una​

El flujo anterior cambiaba la contraseña al pedir el restablecimiento y mandaba la nueva en texto plano. Tres consecuencias, y la primera es la que importa:

  1. Cualquiera que supiera un correo dejaba fuera a su dueño. Sin confirmar nada y sin abrir ningún enlace: la contraseña ya era otra, y la persona se enteraba al no poder entrar.
  2. La contraseña que funcionaba quedaba en un buzón para siempre.
  3. La pantalla decía si el correo existía — una manera de listar quién tiene cuenta.

Ahora: PasswordResetController::request() no toca la cuenta. Guarda el hash del token (un respaldo filtrado de password_resets no es un juego de enlaces que funcionan), vale una vez y una hora, y al completarse quema todos los enlaces pendientes de esa cuenta. La respuesta al usuario es idéntica exista o no el correo, y hay un tope de cinco por hora y por cuenta.

La pantalla nueva-clave se resuelve antes que la sesión: tiene que servir a quien no puede entrar, y también a quien abre el correo en el navegador donde ya está conectado — que es lo que hace la mayoría, y antes recibía un 404.

La regla de contraseña es el LARGO​

PasswordPolicy (core/password_policy.php), mínimo 10 caracteres contados en caracteres, no en bytes (contar bytes acepta en silencio una más corta de quien escribe con tildes). No se exige composición a propósito: mayúscula + dígito + símbolo empuja a «Password1!», que es de lo primero que prueba cualquier lista. Se aplica en el perfil, en el grid genérico (crear y editar) y al elegir una contraseña nueva. El minlength del campo es cortesía.

Un secreto guardado no se lee desde la base​

Secrets (core/secrets.php), AES-256-GCM con llave derivada por HKDF del jwt.secret de api/config.php. Importa porque este proyecto reparte bases de datos a propósito: el paquete de cliente lleva la base y deja los config atrás, el Taller trae la instalación de un cliente a la máquina del proveedor, y un soporte empieza con alguien mandando un volcado. Las tres movían tokens de Mercado Pago vivos.

Dos propiedades que no son obvias y sin las cuales esto rompe cosas:

  • Leer tolera el texto plano. Todo lo guardado antes está en claro, y una actualización que lo volviera ilegible cortaría los cobros de un cliente justo al actualizar. reveal() devuelve lo que recibió cuando no es nuestro, y el valor se re-cifra la próxima vez que se guarda.
  • Sin llave NO se escribe algo ilegible. protect() devuelve el valor tal cual: un token de cobro que no se puede recuperar rompe los cobros en silencio, que es peor que el problema que se estaba resolviendo.

La API deja rastro​

El panel registraba crear, editar y borrar; la API escribe en las mismas tablas y no registraba nada, así que «quién cambió esta fila» sólo tenía respuesta si el cambio pasó por una pantalla. api/routes/services/audit.php lo registra, con la cuenta sacada del token porque no hay sesión que preguntar (logActivity acepta ahora un quinto argumento opcional).

Ojo con la ruta: la primera versión usaba dirname(__DIR__, 2), que desde api/routes/services da api/. El require fallaba, el helper se tragaba el error por diseño y ningún test estático lo vio. Lo vio contar los registros antes y después de una llamada real.

Multi-tenant: aislamiento por comunidad (suite de condominios)​

Una instalación de la suite de condominios hospeda la cartera de una empresa administradora —o de varias, sobre el mismo servidor—. El aislamiento entre clientes es el control de seguridad más importante, y descansa en distinguir dos preguntas que no son la misma:

  • cms_is_provider_account() — ¿es el superadmin que instaló el sistema? Decide el alcance total: sólo el proveedor alcanza todas las comunidades, fija los cupos y crea las cuentas de Administrador.
  • cms_is_admin_account() — ¿es un administrador? Decide otra cosa: que a un administrador no se le capa con los permisos internos de un módulo (regla #11).

CommunityContext::allowedFor() resuelve el alcance: sólo el proveedor ve todo; un Administrador cliente ve únicamente lo asignado en cm_community_admins (crear una comunidad la auto-asigna a su creador; el proveedor puede entregar una existente), y un conserje ve sólo la comunidad de su mesón. Confundir las dos preguntas fue una fuga real: cualquier cuenta cuyo rol se llamara «Administrador» veía todas las comunidades de la instalación, así que dos clientes distintos se veían entre sí.

Calcular bien la lista no basta: la puerta tiene que mirarla. La misma fuga volvió por la otra mitad. allowedFor() ya devolvía la lista correcta —y por eso las pantallas se veían bien—, pero CommunityEngine::allows() respondía true antes de consultarla si la cuenta era «administrador»: un Administrador con cero comunidades concedidas alcanzaba todas nombrando el id en el request. El veredicto de canAccess() se decide con cms_is_provider_account(), nunca con cms_is_admin_account(), y tests/community_scope_test.php lo vigila leyendo el cuerpo de la función.

Una comunidad suspendida se cierra en la puerta, no en cada pantalla: canAccess() exige además que esté abierta (CommunityEngine::isOpen()), y el portal del residente —la otra puerta— exige c.status = 'activa' en CommunityPortal::account(). Suspender y reactivar es del proveedor.

Un permiso o un rol NUNCA se deriva de texto que escribe el usuario. El cargo del personal (cm_positions) era texto libre y el rol de un cargo se buscaba por nombre: un Administrador podía crear un cargo llamado «Administrador» y reutilizar su propio rol potente, entregándole a un conserje acceso total. El rol de un cargo se decide por el acceso (un conjunto cerrado que ofrece el módulo, con un nombre reservado propio), jamás por el nombre libre, y el endpoint que concede el acceso re-deriva el rol del acceso en vez de confiar en un id_role guardado.

Secciones de toda la instalación fuera del rol del cliente. Empaquetado (exporta la base entera, todos los clientes), Archivos (almacenamiento compartido), Usuarios, Módulos, Actualizaciones y Apariencia son del proveedor; el rol de un Administrador cliente no las lleva. Los endpoints se defienden solos (cms_can_access_page('packaging'), etc.), así que quitar el permiso los cierra por URL y por AJAX, no sólo en el menú.

Una sola puerta de acceso (login del panel + portal)​

Todos entran por /cms/login. Cuando el panel rechaza las credenciales, se le ofrecen las mismas al portal del residente antes de responder «error», así el residente nunca aprende una segunda dirección. Dos cuidados:

  • El portal responde con las comunidades de un correo antes de comprobar la clave (para que la pantalla de elección no confirme que la clave era correcta). Por eso el login verifica la clave contra una candidata antes de redirigir: sin eso, cualquier correo con cualquier clave revelaría si la cuenta existe —un oráculo—.
  • Una clave equivocada y un correo inexistente responden igual.

Portal del residente​

  • Identidad y sesión propias (cm_portal_accounts), fuera de admins: un residente jamás es una credencial del panel.
  • Alcance por unidad: la unidad activa sale de la sesión; un id que llegue en el request se compara contra las de la cuenta y nunca la amplía. Pedir la colilla o la unidad de un vecino de la misma comunidad da 403 / rechazado.
  • CSRF en toda escritura (campo _t); una reserva sin token no crea nada.
  • Bloqueo por fuerza bruta: a los intentos fallidos configurados, la cuenta queda bloqueada unos minutos (failed_attempts / locked_until).

Páginas generadas (Generador de Páginas)​

  • Los datos de registros se escapan (htmlspecialchars) al renderizar; el HTML/CSS/JS del autor se emite tal cual (es su propio sitio).
  • Las páginas públicas solo crean registros desde formularios; no pueden editar registros existentes vía ?id (evita modificación no autorizada / IDOR).
  • Las páginas privadas validan login contra los admins (password_verify) y regeneran el id de sesión al iniciar (anti session-fixation).
  • El acceso se restringe por rol y/o usuario; los no autorizados no ven el contenido.
  • Subidas desde formularios: extensiones permitidas en lista blanca, nombres aleatorios, guardadas en web/uploads/.

Buenas prácticas al desplegar​

  • Ejecuta sudo ./setup.sh para crear los config y fijar permisos correctos.
  • Nunca subas config.php con credenciales reales.
  • No elimines los .htaccess que protegen directorios sensibles.