Stored XSS
Stored XSS happens when an app saves attacker input (a comment, profile bio, product review) and renders it back to other users as HTML. One persisted payload owns every visitor who loads the page — this is the highest-impact XSS variant.
Vulnerable vs safe rendering
EXAMPLE
// SCENARIO — a forum where users post comments
// The vulnerability is on the SERVER's render path.
// We show defenses, not weaponized payloads.
// ─── VULNERABLE ───────────────────────────────────────────────
// Express + a raw template
app.post('/comments', async (req, res) => {
await db.comments.insert({ body: req.body.text, user: req.user.id });
res.redirect('/post/' + req.body.postId);
});
app.get('/post/:id', async (req, res) => {
const comments = await db.comments.find({ post: req.params.id });
res.send(`
<h1>Comments</h1>
${comments.map((c) => `<div class="comment">${c.body}</div>`).join('')}
`);
// ❌ c.body is interpolated as RAW HTML. Any saved <script> or onerror=
// runs in every visitor's browser, with the victim's session cookie.
});
// ─── FIX 1 — Encode on output ──────────────────────────────────
function escapeHTML(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
res.send(comments.map((c) => `<div class="comment">${escapeHTML(c.body)}</div>`).join(''));
// Even better: use a template engine that escapes by default.
// • Handlebars { { body } } (escaped) vs { { { body } } } (raw — avoid)
// • EJS <%= body %> (escaped) vs <%- body %> (raw — avoid)
// • React {body} (escaped automatically)
// • Blade { { '{ { ' } } $body { { ' } } ' } } (escaped) vs {!! $body !!} (raw — avoid)
// ─── FIX 2 — Sanitize on input if you genuinely need rich text ─
// e.g. a blog editor that allows <b>, <i>, links
import sanitizeHtml from 'sanitize-html';
const clean = sanitizeHtml(req.body.text, {
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'code', 'pre'],
allowedAttributes: { a: ['href', 'title', 'rel', 'target'] },
allowedSchemes: ['http', 'https', 'mailto'],
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer', target: '_blank' }),
},
});
await db.comments.insert({ body: clean, user: req.user.id });
// IMPORTANT: still escape on output even when you sanitize on input.
// Sanitize-on-input + escape-on-output is defense in depth.
// If you decide to store HTML (not plain text), flag the column so any
// new render path KNOWS to keep escaping.
// ─── FIX 3 — Content Security Policy ───────────────────────────
// Even if an injection slips through, CSP can stop it executing.
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self' 'nonce-' + res.locals.nonce", // generate per request
"img-src 'self' data: https:",
"style-src 'self' 'unsafe-inline'", // tighten later
"object-src 'none'",
"base-uri 'self'",
].join('; '));
next();
});
// Inline scripts in templates must use the nonce:
// <script nonce="${nonce}">…</script>
// Without the nonce, the browser refuses to execute — even if XSS injected it.
// ─── FIX 4 — Cookies the attacker payload can't read ───────────
res.cookie('session', token, {
httpOnly: true, // JS can't read it → stored XSS can't steal sessions
secure: true,
sameSite: 'lax',
});
// ─── DETECTION ─────────────────────────────────────────────────
// Server side
function looksLikeHtml(s) {
return /<[a-z][\s\S]*?>/i.test(s);
}
if (looksLikeHtml(req.body.text) && !user.canPostHtml) {
log.warn({ user: req.user.id, snippet: req.body.text.slice(0, 80) }, 'html-in-text-field');
}
// Browser side — CSP report-uri / report-to
res.setHeader('Content-Security-Policy-Report-Only',
"default-src 'self'; report-to csp-endpoint");
// ─── CHECKLIST ─────────────────────────────────────────────────
// 1. Escape on output by default (template engine that does it for you)
// 2. Sanitize on input ONLY if rich text is required, with an allowlist
// 3. Strict CSP with nonces, no 'unsafe-inline' in script-src
// 4. httpOnly + Secure + SameSite=Lax cookies
// 5. Audit every res.send`<html>${...}</html>` for unescaped vars
// 6. Log + alert on HTML-in-text-field signals
// 7. Regression-test with known XSS pattern strings as input fixtures
Why it matters
Stored XSS is the worst variant because the payload runs for every visitor. The fix is layered: escape on output (always), sanitize on input only when HTML is genuinely needed, deploy a strict CSP, and lock cookies down with HttpOnly so a successful injection can’t lift sessions.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// VULNERABLE: render a comment as HTML
document.getElementById('feed').innerHTML = comment.body;
// SAFE: render as text
document.getElementById('feed').textContent = comment.body;
// If you MUST allow rich text, sanitise with DOMPurify first:
feed.innerHTML = DOMPurify.sanitize(comment.body);
Try it Yourself »
Discussion
Loading…