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-Tokenen 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$_POSTy solo una lista cerrada de acciones —descargas y lecturas— se acepta por$_GET. El interceptor se registra al cargar, no enDOMContentLoaded: jQuery dispara susreadyantes, y la primera petición de un plugin viajaba sin token. - ✅ Configuración sensible fuera del control de versiones (
config.phpen.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
.htaccessraí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:
- 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). - Permiso sobre la sección —
cms_can_manage_section()/cms_can_access_page(), con la listaaccess.roles_managedel 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. - 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 conRequire 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.phplo hace por todos:privateDir()reescribe el.htaccessen 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— ynewFileName()esrandom_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 -Indexesno basta, porque sólo esconde el listado.firma-electronicaenvió una versión guardandodocumento-7-firmado.pdfen 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-srcyconnect-srcestá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-srceimg-srcpermiten cualquier origenhttps:(máslocalhostpara desarrollo) porque el módulo Contenido libre (HTML, CSS y JS) y las páginas web embeben sitios e imágenes externas. Sinframe-srcexplícito la directiva caería endefault-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.clno es'self'para una página abierta enejemplo.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.phplo vigila.- Que un iframe siga sin cargar tras esto suele ser cosa del sitio remoto,
que prohíbe ser embebido con
X-Frame-Optionsoframe-ancestors(Google, bancos, muchos SaaS). Compruébalo concurl -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
.htaccesstrae 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.htaccesssólo corre las reglas del directorio más profundo conRewriteEngine On, así que una variable puesta en la raíz nunca llega a/cmsni 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-srcyconnect-src:img-srcyframe-srcya permiten cualquierhttps:. - El archivo es una petición, no un hecho: un hosting con
AllowOverride Noneignora el.htaccessy 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:
| Camino | Qué permitía | Dónde se cierra |
|---|---|---|
| Formulario de perfil | Tomaba 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 Usuarios | Comprobaba 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érica | Un 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 filarol_admin = superadminsó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)—superadminyadminno 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_adminytoken_exp_adminno viajan en una escritura genérica.id_role_adminno 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 decms_admin_role_names(). Lo llaman el instalador, el restablecimiento de fábrica ymigrations/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 conread = 0es 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:
- 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.
- La contraseña que funcionaba quedaba en un buzón para siempre.
- 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 elsuperadminque 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 deadmins: un residente jamás es una credencial del panel. - Alcance por unidad: la unidad activa sale de la sesión; un
idque 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.shpara crear los config y fijar permisos correctos. - Nunca subas
config.phpcon credenciales reales. - No elimines los
.htaccessque protegen directorios sensibles.