XSS
What it is
Stored XSS happens when you can save HTML/JavaScript into the application and it executes for anyone who later views that content — not just for you.
<img src=x onerror=alert(document.domain)> saved as a project name, firing whenever someone else opens that project, is the classic shape of it.Why it matters
It's worse than reflected XSS because it's persistent, hits other users (sometimes admins), can fire repeatedly, and — this is the part people miss — the payload is bound to the resource, not the attacker's account. Revoking the attacker's access doesn't remove the payload.
Where to look
- Name / title / description / comment / bio fields
- Company, job title, tags, filenames, project/campaign/template/workflow names
- Especially in SaaS apps with collaboration, invites, shared workspaces, or resource duplication
How I test it
- List every field you can write to.
- Confirm HTML actually renders with a harmless probe like
.<b>test</b> - Try a non-destructive payload, and variants for different contexts: inside an attribute (
), inside a JS string ("><img src=x onerror=alert(1)>
), or after a broken tag.'-alert(1)-' - Save, then test persistence: reload, log out and back in, open the resource from a different account, share it, duplicate/export/import it, check notifications and any emails it triggers.
- Check every view the field surfaces in — detail page, list, search, admin panel, preview, edit modal, history. A field sanitized in one view is often raw in another.
Hunting checklist
- Mapped every writable field
- Confirmed HTML rendering with a harmless probe
- Tried a basic payload and an attribute-breaking variant
- Reloaded and re-opened from a different account
- Tried duplicate/export/import flows
- Checked if the payload survives revoking the attacker's access
- Checked innerHTML / dangerouslySetInnerHTML / v-html usage if source is visible
Evidence to collect
- Vulnerable field
- Payload used
- Where it's stored
- Where it fires
- Attacker account
- Victim account
- Proof it runs in the victim's session
- Proof of persistence across reload/duplicate/revoke
Recommended fix
- Escape HTML on output
- Sanitize input if HTML is intentionally allowed
- Use
instead oftextContentinnerHTML - Strong CSP
- Validate on both frontend and backend
- Re-sanitize on duplicate/export/import, not just on first save
Qué es
Un Stored XSS ocurre cuando consigues guardar HTML/JavaScript en la aplicación y se ejecuta para cualquiera que vea después ese contenido — no solo para ti.
<img src=x onerror=alert(document.domain)> guardado como nombre de un proyecto, disparándose cada vez que otro abre ese proyecto, es la forma clásica.Por qué importa
Es peor que un XSS reflejado porque es persistente, afecta a otros usuarios (a veces admins), puede dispararse repetidamente y — esto es lo que se pasa por alto — el payload queda vinculado al recurso, no a la cuenta del atacante. Revocar el acceso del atacante no elimina el payload.
Dónde buscar
- Campos de nombre / título / descripción / comentario / bio
- Empresa, cargo, etiquetas, nombres de archivo, nombres de proyecto/campaña/plantilla/workflow
- Especialmente en SaaS con colaboración, invitaciones, workspaces compartidos o duplicado de recursos
Cómo lo pruebo
- Haz una lista de todos los campos donde puedas escribir.
- Confirma que el HTML se renderiza con una prueba inofensiva como
.<b>test</b> - Prueba un payload no destructivo, y variantes para distintos contextos: dentro de un atributo (
), dentro de un string JS ("><img src=x onerror=alert(1)>
), o tras una etiqueta rota.'-alert(1)-' - Guarda y prueba persistencia: recarga, cierra sesión y vuelve a entrar, abre el recurso desde otra cuenta, compártelo, duplícalo/expórtalo/impórtalo, revisa notificaciones y los emails que dispare.
- Revisa cada vista donde aparezca el campo — detalle, listado, buscador, panel admin, preview, modal de edición, historial. Un campo saneado en una vista muchas veces está en crudo en otra.
Checklist de hunting
- Mapeados todos los campos editables
- Confirmado el renderizado de HTML con una prueba inofensiva
- Probado un payload básico y una variante que rompe atributo
- Recargado y reabierto desde otra cuenta
- Probado duplicar/exportar/importar
- Comprobado si el payload sobrevive a revocar el acceso del atacante
- Revisado el uso de innerHTML / dangerouslySetInnerHTML / v-html si hay código visible
Evidencias para el reporte
- Campo vulnerable
- Payload usado
- Dónde se guarda
- Dónde se ejecuta
- Cuenta atacante
- Cuenta víctima
- Prueba de que se ejecuta en la sesión de la víctima
- Prueba de persistencia tras recarga/duplicado/revocación
Mitigación
- Escapar el HTML en la salida
- Sanear el input si el HTML es intencional
- Usar
en vez detextContentinnerHTML - CSP fuerte
- Validar en frontend y backend
- Re-sanear en duplicado/exportación/importación, no solo al guardar la primera vez