Description
Stored XSS occurs when stored values, such as filenames or post titles, are rendered as HTML without suitable output encoding. An attacker can store malicious markup that runs when another user later opens the page. For example, a directory listing that inserts uploaded filenames directly into HTML may execute attacker-controlled content.
Potential impact
- Scripts may steal accessible cookies or tokens and compromise login sessions.
- An attacker may abuse a user's or administrator's permissions.
- Altered pages and deceptive forms may collect sensitive information.
- Scripts may invoke internal APIs or submit state-changing requests with the browser's authority.
- Stored content may affect many users repeatedly until it is removed.
Remediation
- Encode stored values for their output context before inserting them into HTML text, attributes or URLs.
- Enable output escaping in template engines such as EJS, Pug or Nunjucks; do not concatenate untrusted values directly into HTML.
- Where practical, return data with
res.jsonrather than constructing HTML. - Validate and normalize filenames and other stored input using allowed characters appropriate to the application.
- Use CSP,
X-Content-Type-Optionsand suitableHttpOnly,SecureandSameSitecookie attributes to limit harm. - Use established libraries such as
escape-html,heor the applicablevalidatorfunctions for encoding and validation.
Examples
Before
javascript
const express = require("express");
const fs = require("fs");
const app = express();
// Render stored filenames directly as HTML
app.get("/files", (req, res) => {
fs.readdir("/var/app/uploads", (err, names) => {
if (err) return res.status(500).send("error");
const body =
"<h2>Files</h2><ol>" +
names.map((n) => "<li>" + n + "</li>").join("") +
"</ol>";
// BAD: tags such as <script> in n can execute
res.send(body);
});
});
app.listen(3000);
After
javascript
const express = require("express");
const fs = require("fs");
const escapeHtml = require("escape-html"); // Or he, validator, etc.
const app = express();
// Encode for the HTML output context
app.get("/files", (req, res) => {
fs.readdir("/var/app/uploads", (err, names) => {
if (err) return res.status(500).send("error");
const safeItems = names
.map((n) => "<li>" + escapeHtml(n) + "</li>")
.join("");
const body = "<h2>Files</h2><ol>" + safeItems + "</ol>";
res.set("Content-Type", "text/html; charset=utf-8");
res.send(body);
});
});
// Alternative: return JSON and display it with safe text APIs on the client
app.get("/files.json", (req, res) => {
fs.readdir("/var/app/uploads", (err, names) => {
if (err) return res.status(500).json({ error: "error" });
res.json({ files: names });
});
});
app.listen(3000);
Explanation:
- Before: Untrusted filenames from
fs.readdirare concatenated into HTML and sent withres.send. A stored filename containing malicious HTML can cause XSS when a user opens the listing. - After:
escape-htmlencodes each filename so it is displayed as text rather than interpreted as tags. The JSON alternative separates data from the HTML response, but the client must not reinterpret those values as HTML.