guest@websec ~ % man 04-xss-and-other-injection
04 · XSS and Other Injection
Section 02 covered SQL. The same bug shows up anywhere data gets run as code: HTML and JavaScript (XSS), CSS, Markdown, shell commands and HTTP headers. The fix always depends on where the data lands.
4.1 Allowlists vs blocklists
Always list what's allowed, not what's forbidden. A blocklist has to predict every attack ever. An allowlist just describes what's normal.
- Require login everywhere by default, then list the few public pages. Not the other way round. Rails:
before_action :require_login, except: [:index]. - Allow a few safe HTML tags instead of trying to strip
<script>. - Don't "fix" bad input, reject it. Strip
<script>from<sc<script>ript>and you've built a new one.
blocklist: remove "<script>" / "</script>" once
allowlist: escape everything, allow nothing
4.2 Cross-site scripting (XSS)
XSS means an attacker's JavaScript runs on our site in someone else's browser. It can do anything that user can. It's one of the most common web bugs, and it beats CSRF protection too, because the script can read the CSRF token from the page.
Entry points
Anything a user can influence that ends up on a page: form fields, URL params, filenames, even headers shown in admin. Three kinds:
- Stored: saved (like a comment) and runs for everyone who views it.
- Reflected: hidden in a link, runs for whoever clicks.
- DOM-based: our own JavaScript copies attacker input into the page, so the server never sees it (covered further down).
What injected script can do
Steal cookies, unless they're HttpOnly. Even then the script can act as the user.
Fake the page: drop a fake login box on our real domain.
Type a comment as an attacker. We'll show what a browser would do with it, without running anything.
raw: element.innerHTML = comment
escaped: template auto-escape
sanitized: allowlist b, i, a[href=https]
rendered
Countermeasures
- Escape output. Templates do it for you. Bugs come from the escape hatches:
raw,html_safe,innerHTML. - Match the context. Never drop data into
<script>oronclick; usedata-*attributes. - Need user HTML? Sanitize with a library (Rails'
sanitize, DOMPurify). Never with regex. - Check URLs start with http(s).
- Add CSP as a safety net.
<%= comment.body %> <%# escaped automatically %> <%= raw comment.body %> <%# XSS %> <%= comment.body.html_safe %> <%# XSS: html_safe means "trust me" %> <%== comment.body %> <%# same as raw %> <%# HTML required: Rails' allowlist sanitizer (rails-html-sanitizer / Loofah) %> <%= sanitize comment.body, tags: %w[b i em strong a p], attributes: %w[href] %> <%# user-supplied links: validate the scheme yourself %> <%= link_to "website", user.website if user.website.to_s.match?(%r{\Ahttps?://}) %>
// browser: textContent never parses HTML li.textContent = comment.body; li.innerHTML = comment.body; // XSS // when HTML is required: DOMPurify (browser) or sanitize-html (Node) import DOMPurify from "dompurify"; div.innerHTML = DOMPurify.sanitize(userHtml, { ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p"], ALLOWED_ATTR: ["href"] }); // server templates (EJS): <%= x %> escapes, <%- x %> does NOT
# Jinja2 / Django templates escape {{ x }} automatically. {{ comment.body|safe }} {# XSS #} # when HTML is required: nh3 (fast, Rust-based ammonia binding) import nh3 clean = nh3.clean(user_html, tags={"b", "i", "em", "strong", "a", "p"}, attributes={"a": {"href"}}, url_schemes={"http", "https"}) # escaping by hand outside templates from markupsafe import escape html = f"<li>{escape(name)}</li>"
<%= %> escapes for you. Watch for raw, html_safe and <%==. link_to doesn't block javascript: URLs, and Rails can't protect JS you write yourself. Grep for html_safe and innerHTML.Obfuscation and encoding tricks
Browsers are forgiving: they decode entities, ignore odd casing, run handlers on dozens of tags. String filters always miss something. Real sanitizers parse the HTML first.
Output encoding depends on context
There's no single "escape" function. Encoding depends on where the data lands. Try it:
| context | template | output | verdict |
|---|
<p><%= name %></p> <%# HTML body: ERB escapes %> <div title="<%= name %>"> <%# quoted attribute: escaped. Always quote! %> <%= tag.div data: { user: @user.as_json } %> <%# pass data to JS via data-* (JSON, escaped) %> <a href="/search?q=<%= ERB::Util.url_encode(q) %>"> <%# URL parameter: url-encode the value %> <script>const n = "<%= j name %>"</script> <%# works, but a fragile context: avoid %> <div style="color: <%= color %>"> <%# CSS: allowlist values only %>
// browser: pick the API that treats data as data el.textContent = name; // HTML body el.setAttribute("title", name); // attribute (fixed name) el.dataset.user = JSON.stringify(user); // data for scripts url.searchParams.set("q", q); // URL parameter (encodeURIComponent) el.style.setProperty("color", allowedColors.includes(c) ? c : "black"); // CSS via CSSOM + allowlist el.setAttribute("onclick", userCode); // event handlers: never
Contexts encoding can't save
Some places are never safe for user data, however you encode: inside <script>, <style>, event handlers or eval(). Put the data in a data attribute and read it from there.
DOM-based XSS: sources and sinks
Some XSS happens entirely in the browser: our JS reads the URL and writes it into the page. The part after # never reaches the server, so server-side defenses never see it.
| sources (attacker can control) | dangerous sinks (parse or run code) | safe sinks (treat input as text) |
|---|---|---|
location.hash, location.search, document.URL, document.referrer, window.name, postMessage data, localStorage, API responses with user content | innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, setTimeout(string), new Function, el.href/src = "javascript:…", jQuery $(html) / .html(), createElement(userTag) | textContent, insertAdjacentText, createTextNode, value, className, setAttribute with a fixed, non-event attribute name |
el.innerHTML = "Hi " + hash
el.textContent = "Hi " + hash
Trusted Types
Turn it on via CSP and the browser refuses plain strings in innerHTML. Only code that goes through your approved policy (say, DOMPurify) gets in.
# config/initializers/content_security_policy.rb
policy.require_trusted_types_for :script
policy.trusted_types "dompurify", "default"
// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types dompurify const policy = trustedTypes.createPolicy("dompurify", { createHTML: (s) => DOMPurify.sanitize(s), }); el.innerHTML = policy.createHTML(userHtml); // allowed el.innerHTML = userHtml; // TypeError: requires TrustedHTML
Anti-patterns to avoid
- Relying on CSP alone. It's a safety net, not the fix.
- "Cleaning" all input on save. Wrong context, double-encoding, misses browser-only XSS. Escape at output instead.
- Trusting a WAF. Nice extra, never the fix.
- Editing HTML after sanitizing. Sanitize last.
Examples from the underground
Two famous ones:
- Yahoo! Mail worm (2006): an email that mailed itself to all your contacts when opened. The filter removed the bad bit once, which rebuilt it.
- MySpace "Samy" worm (2005): a million profiles infected in about 20 hours, using script hidden in CSS.
4.3 CSS injection
Letting users paste raw CSS is risky, even though CSS isn't code:
- It can leak data, one character at a time, via selectors and background URLs.
- It can fake the page: hide things, overlay a fake login.
Fix: offer choices (color picker, font list) and generate the CSS ourselves.
"paste your CSS"
pick a theme
4.4 Markup injection (Markdown, Textile, BBCode)
Most Markdown renderers let raw HTML through and turn [x](javascript:…) into a working link. Render the Markdown, then sanitize the HTML.
rendered HTML
verdict
# gem "commonmarker": raw HTML is omitted by default (unsafe: false) html = Commonmarker.to_html(params[:body]) safe = Rails::HTML5::SafeListSanitizer.new.sanitize(html) # render, then sanitize <%= sanitize markdown_html %> <%# same idea in views %>
import { marked } from "marked";
import DOMPurify from "isomorphic-dompurify";
const html = DOMPurify.sanitize(marked.parse(userMarkdown)); // render, then sanitize
import markdown, nh3
html = nh3.clean(markdown.markdown(user_markdown)) # render, then sanitize
sanitize on Markdown output. Action Text sanitizes for you. Don't enable raw-HTML options in Markdown gems.4.5 Ajax / API response injection
XSS sneaks in where templates aren't used: an endpoint that builds HTML by gluing strings, or JS that puts API data into innerHTML.
# hand-built HTML string: no escaping happens here render html: "<li>#{user.display_name}</li>".html_safe # render a partial (escaped) or return JSON render partial: "users/chip", locals: { user: } # <li><%= user.display_name %></li> render json: { name: user.display_name } # client uses textContent # plain Ruby: helpers.tag.li(user.display_name) or ERB::Util.html_escape(name)
// server builds HTML by hand, client inserts it app.get("/api/user-chip", (req, res) => res.send(`<li>${user.displayName}</li>`)); list.insertAdjacentHTML("beforeend", await (await fetch("/api/user-chip")).text()); // return data, let the client build DOM safely app.get("/api/user", (req, res) => res.json({ name: user.displayName })); const li = document.createElement("li"); li.textContent = (await (await fetch("/api/user")).json()).name; list.append(li);
# returns hand-built HTML: no auto-escaping happens here @app.get("/api/user-chip") def chip(): return f"<li>{user.display_name}</li>" # either escape explicitly… from markupsafe import escape return f"<li>{escape(user.display_name)}</li>" # …or return JSON and build DOM with textContent on the client return jsonify(name=user.display_name)
render json: is safe. render html: with interpolated strings isn't.4.6 Command-line injection
Build a shell command as one string and the shell reads ;, | and $( ) as "run more stuff". photo.jpg; rm -rf / is two commands.
Fix: skip the shell. Pass the program and its arguments separately.
exec("convert " + name + " -resize 200 thumb.png")
execFile("convert", ["--", name, "-resize", "200", "thumb.png"])
system("convert #{name} -resize 200 thumb.png") # one string → /bin/sh `convert #{name}` / %x(convert #{name}) / open("| #{cmd}") # shell system("convert", name, "-resize", "200", "thumb.png") # argv, no shell out, status = Open3.capture2e("convert", name, "-resize", "200", "thumb.png") File.open(path) # not Kernel#open(path), which runs "| cmd"
const { execFile } = require("node:child_process");
exec(`convert ${name} -resize 200 thumb.png`); // shell parses name
execFile("convert", [name, "-resize", "200", "thumb.png"], cb); // no shell
// also unsafe: spawn(cmd, { shell: true }), execSync(string)
import subprocess os.system(f"convert {name} -resize 200 thumb.png") # shell subprocess.run(f"convert {name} ...", shell=True) # shell subprocess.run(["convert", name, "-resize", "200", "thumb.png"], check=True) # no shell # if you truly need a shell string, quote each value: shlex.quote(name)
system("convert", name) is safe. system("convert #{name}"), backticks and %x() are not.Sneaky one: Kernel#open
open("| ls") runs ls. So "opening a file" with a user-supplied name can run commands. Ruby 3.3 deprecated this, but plenty of apps still run older Ruby. Use File.open.
4.7 Header injection
Headers are lines of text. If user input with a line break lands in one, the attacker can add their own headers, or even a whole fake page.
Modern frameworks reject line breaks in headers. Custom header code should too.
DNS rebinding and Host header attacks
Apps often build email links from the request's Host header, which the client controls. Send Host: evil.test with a reset request and the victim gets a real email with a link to evil.test.
DNS rebinding is a related trick. The attacker's domain first points at their own server, then switches to an internal address like 127.0.0.1, so a victim's browser ends up talking to a service on the victim's own machine or network. Those requests still carry the attacker's hostname in Host.
Fix for both: allowlist your hostnames (reject unknown Host values), and build email links from config.
# config/environments/production.rb: HostAuthorization (dev-only by default) config.hosts = ["app.example.com", "www.example.com"] config.host_authorization = { exclude: ->(req) { req.path == "/up" } } # health check # build email links from config, never from request.host config.action_mailer.default_url_options = { host: "app.example.com", protocol: "https" }
const ALLOWED = new Set(["app.example.com", "www.example.com"]);
app.use((req, res, next) => ALLOWED.has(req.hostname) ? next() : res.sendStatus(400));
// build links from config, never from req.headers.host
const resetUrl = new URL(`/reset?token=${token}`, process.env.APP_BASE_URL);
# Django settings.py ALLOWED_HOSTS = ["app.example.com", "www.example.com"] # build links from config, not request.get_host() reset_url = f"{settings.APP_BASE_URL}/reset?token={token}"
config.hosts is only on in development. Set it in production.rb, along with action_mailer.default_url_options.