Stored cross-site scripting in directory listings

Stored cross-site scripting in directory listings

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.json rather than constructing HTML.
  • Validate and normalize filenames and other stored input using allowed characters appropriate to the application.
  • Use CSP, X-Content-Type-Options and suitable HttpOnly, Secure and SameSite cookie attributes to limit harm.
  • Use established libraries such as escape-html, he or the applicable validator functions 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.readdir are concatenated into HTML and sent with res.send. A stored filename containing malicious HTML can cause XSS when a user opens the listing.
  • After: escape-html encodes 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.

References