Auditoría Suprema 2 · Cola frontend: CSP del portal de cliente, escapes y CSRF same-origin
Cuándo
05-09-2026 · commit 944eb5a1 (PR #508, v1.115.0). Cierra el dominio frontend de la cola MEDIA/BAJA de la Auditoría Suprema 2 (task #286): del catálogo de 23 hallazgos, 7 ya estaban cerrados por versiones anteriores (#1, #6, #8-#11, #19), 12 se arreglan en este commit y 3 quedan diferidos con motivo. Agente Opus en worktree con las decisiones fijadas por hallazgo; el agente no pudo ejecutar nada (pérdida de aislamiento del worktree cuando el padre hace cd, footgun ya documentado) y biome, vitest, pytest y mypy los corrió la sesión principal. Click-test en Edge pendiente tras el deploy (portal de cliente, gráficos del Observatory, wizard Auto-Provision, credenciales, editor).
Síntomas visibles
- Portal público de cliente sin CSP (
core/middleware/csp.py,templates/signage/client_portal.html):/client/estaba exento ENTERO de la cabecera de seguridad, y sus dos<script>inline no llevaban nonce — la única ruta anónima de la app quedaba sin la defensa que el resto de páginas ya tenía. (#20, cubre también #22) seriesNamesin escapar en el tooltip y en la tabla del toolbox de los gráficos ECharts compartidos — dato que viene del tenant (hostname/target de un dispositivo). (#2)discovery_methodyvendor_slugsin escapar en la tabla de resultados de Auto-Provision;vendor_slugademás se interpolaba crudo dentro de un atributodata-args— un slug con comilla rompía el HTML. (#17)- Nombre de grupo sin escapar en las
<option>del filtro de Signage Content Publish, duplicado en dos sitios del fichero. (#21) - El guard CSRF same-origin solo cubría
post:put,patch,deleteyuploaddeApiService.jsseguían mandando el token a cualquier destino, yApiService-global.js(script clásico de 5 plantillas) no lo tenía en absoluto. (#4, #5) - Forma del error inconsistente: según el endpoint, unos call-sites leían
.errory otros.messagedel objeto que lanzaApiService, y uno de los dos podía salirundefined. (#7) - Fechas de los gráficos fijas en
es-ESaunque la interfaz estuviera en otro idioma. (#3) - Secretos descifrados sobreviviendo al cierre del modal de
CredentialManager.js: el DOM del modal se inyecta una sola vez, así que cerrar sin cambiar de credencial dejaba las contraseñas en los inputs. (#12) - IP del discovery individual sin validar antes de mandarla al Agente local, y sin
encodeURIComponenten la URL. (#15) checkCollisioncon un 6º argumento fantasma (depth) que dos llamadas seguían pasando y un comentario que prometía un bloqueo por profundidad que el backend (racks/utils/placement.py) ya no aplica. (#13)- Variable de bucle
tsombreando la función de traducciónt()dentro de un.map()enresults.js— nada de ese bloque se traducía. (#18)
Causa raíz
- La exención de
/client/en la CSP se decidió cuando el portal servía scripts inline sin infraestructura de nonce; nunca se revisó al añadir el nonce al resto de la app en la Tanda 3. - Puntos de interpolación de datos del tenant en HTML/atributos añadidos sin pasar todos por el helper único de escape (
esc/escHtml/escAttr) — el mismo patrón de fondo que ya motivó [[incident—20260831—auditoria-suprema-2-tanda3-xss-signage-editor]]. - Un guard de seguridad (
_isSameOrigin) se añadió primero al método que lo necesitó (post, fe7-G2) y no se propagó al resto de métodos del mismo objeto, ni a la copia global del servicio. - Un comentario de código seguía prometiendo un comportamiento que el backend ya había retirado — el drift solo se destapa releyendo el código vivo del otro lado, no el comentario.
Fix aplicado
Commit 944eb5a1 (PR #508):
core/middleware/csp.py: elifde exención solo cubre/api/—/client/entra en la CSP base.client_portal.html:nonce="{{ csp_nonce }}"en los dos<script>inline; losjson_scriptno lo necesitan (son bloques de datos, no ejecutables). 9 tests nuevos entests/test_csp_client_portal.py. Marcha atrás de una línea si el click-test destapa un error de CSP.OptionBuilders.js:escHtml(s.name)yescHtml(p.seriesName); nueva_locale()que usadocument.documentElement.lang || navigator.languageen vez de'es-ES'fijo.results.js:esc()sobrediscovery_method;vendor_slugserializado conJSON.stringify+escAttrpara el atributodata-args; variable de bucle renombrada detatabName.SignageContentPublish.js: nueva funcióngroupOptionsHtml()conescAttr(g.id)+esc(g.name), sustituye dos bloques inline duplicados sin escapar.ApiService.js: nuevo_csrfHeaders(url)aplicado aput/patch/delete/upload(mismo guard que ya teníapost);errorShape()unifica.errory.messagecon el mismo texto y prioriza elstatusHTTP sobre el del cuerpo.ApiService-global.js: el mismo guard_csrfHeadersreplicado a mano — no puede delegar enApiService.jssin convertir a módulo las 5 plantillas que lo cargan con<script>clásico (deuda declarada en la cabecera del propio fichero).CredentialManager.js:_clearSecrets()vacía los inputs de secretos (community, claves v3, password SSH/enable, password HTTP) al cerrar el modal.discovery.js: valida conisValidIPv4()antes de llamar al Agente; IP y subred viajan conencodeURIComponenten la URL.devices.js:checkCollisionpierde el 6º argumento fantasma; comentario corregido para reflejar que frontal y trasera son independientes.- 8 ficheros de test vitest nuevos en
frontend/src/__tests__/static/(74 tests estáticos) + los 9 pytest de la CSP.
Lecciones
- Un guard de seguridad añadido método a método (
postprimero, por ser el más usado) no se propaga solo a sus hermanos del mismo servicio — cuando el guard es por-método, hay que revisar TODOS los métodos del objeto, no solo el primero que lo necesitó. - Un dato “solo interno” (
vendor_slug,discovery_method) sigue siendo dato devuelto por una API sobre el tenant en el momento en que se pinta en HTML — se escapa por origen del dato, no por la confianza percibida en el campo. - Un comentario que documenta un comportamiento del backend cachea ese comportamiento en el tiempo: si el backend cambia y el comentario no, el código que lo cita (aquí, un parámetro fantasma) sobrevive por inercia.
Preventivos futuros
- 3 hallazgos diferidos con motivo (detalle en CHANGELOG v1.115.0): #14
editor/devices.jscon 1027 líneas — partirlo es un refactor con su propio click-test; #16 la cascada “All saved credentials” — decisión de diseño con memoria propia (ADR R3); #23 i18n del portal de cliente — fase F4, página anónima sin catálogo de traducción. - Click-test en Edge pendiente tras el deploy: portal de cliente, gráficos del Observatory, wizard Auto-Provision, credenciales, editor.
- Con esta entrega, los dominios
racks,network,monitoring(A-D) yfrontendde la cola MEDIA/BAJA de la Auditoría Suprema 2 quedan cerrados; versupercontext/auditoria-suprema-2/PLAN_ARREGLOS.mdpara el estado del resto de dominios.
Véase también
- [[incident—20260831—auditoria-suprema-2-tanda3-xss-signage-editor]]
- [[incident—20260901—auditoria-suprema-2-tanda9-ssrf-ipv6-xss-stencils]]
- [[incident—20260904—auditoria-suprema-2-cola-racks-libreria-backup-restore]]
- [[incident—20260904—auditoria-suprema-2-cola-network-agente-offline-y-whitelist-vendor]]
- [[incident—20260904—auditoria-suprema-2-cola-monitoring-a-sondas-y-targets]]
- [[incident—20260904—auditoria-suprema-2-cola-monitoring-d-deep-discovery-wireless-ups]]
- [[feature—security—auditoria-suprema-2-tanda-1-core]]
- [[crearack—signage—client-portal]]