CreaRack-SL

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

  1. 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)
  2. seriesName sin 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)
  3. discovery_method y vendor_slug sin escapar en la tabla de resultados de Auto-Provision; vendor_slug además se interpolaba crudo dentro de un atributo data-args — un slug con comilla rompía el HTML. (#17)
  4. Nombre de grupo sin escapar en las <option> del filtro de Signage Content Publish, duplicado en dos sitios del fichero. (#21)
  5. El guard CSRF same-origin solo cubría post: put, patch, delete y upload de ApiService.js seguían mandando el token a cualquier destino, y ApiService-global.js (script clásico de 5 plantillas) no lo tenía en absoluto. (#4, #5)
  6. Forma del error inconsistente: según el endpoint, unos call-sites leían .error y otros .message del objeto que lanza ApiService, y uno de los dos podía salir undefined. (#7)
  7. Fechas de los gráficos fijas en es-ES aunque la interfaz estuviera en otro idioma. (#3)
  8. 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)
  9. IP del discovery individual sin validar antes de mandarla al Agente local, y sin encodeURIComponent en la URL. (#15)
  10. checkCollision con 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)
  11. Variable de bucle t sombreando la función de traducción t() dentro de un .map() en results.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: el if de exención solo cubre /api/ — /client/ entra en la CSP base. client_portal.html: nonce="{{ csp_nonce }}" en los dos <script> inline; los json_script no lo necesitan (son bloques de datos, no ejecutables). 9 tests nuevos en tests/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) y escHtml(p.seriesName); nueva _locale() que usa document.documentElement.lang || navigator.language en vez de 'es-ES' fijo.
  • results.js: esc() sobre discovery_method; vendor_slug serializado con JSON.stringify + escAttr para el atributo data-args; variable de bucle renombrada de t a tabName.
  • SignageContentPublish.js: nueva función groupOptionsHtml() con escAttr(g.id) + esc(g.name), sustituye dos bloques inline duplicados sin escapar.
  • ApiService.js: nuevo _csrfHeaders(url) aplicado a put/patch/delete/upload (mismo guard que ya tenía post); errorShape() unifica .error y .message con el mismo texto y prioriza el status HTTP sobre el del cuerpo.
  • ApiService-global.js: el mismo guard _csrfHeaders replicado a mano — no puede delegar en ApiService.js sin 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 con isValidIPv4() antes de llamar al Agente; IP y subred viajan con encodeURIComponent en la URL.
  • devices.js: checkCollision pierde 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 (post primero, 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.js con 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) y frontend de la cola MEDIA/BAJA de la Auditoría Suprema 2 quedan cerrados; ver supercontext/auditoria-suprema-2/PLAN_ARREGLOS.md para 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]]