guest@websec: ~/websec/04-xss-and-other-injection — zsh — 100×40 by SaKKo ENTH code:

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.
sim: the self-healing payloada blocklist that removes <script>

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.

sim: XSS playgroundwhat the browser would do with a comment

Type a comment as an attacker. We'll show what a browser would do with it, without running anything.

payloads:

raw: element.innerHTML = comment

escaped: template auto-escape

sanitized: allowlist b, i, a[href=https]

rendered

Countermeasures

  1. Escape output. Templates do it for you. Bugs come from the escape hatches: raw, html_safe, innerHTML.
  2. Match the context. Never drop data into <script> or onclick; use data-* attributes.
  3. Need user HTML? Sanitize with a library (Rails' sanitize, DOMPurify). Never with regex.
  4. Check URLs start with http(s).
  5. 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:

sim: one value, five contextswhat the template produces and whether it's safe
try:
contexttemplateoutputverdict
<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 contentinnerHTML, 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
sim: source → sinkfront-end code reading location.hash

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.

sim: user profile themeraw CSS vs generated CSS

"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.

sim: Markdown renderermini renderer: **bold**, [links](url), raw 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
Use 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.

sim: thumbnail generatorhow a shell would split the command
try:

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.

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.

sim: raw HTTP responseuser input lands in a Location header

Modern frameworks reject line breaks in headers. Custom header code should too.

Rails and modern app servers like Puma already reject line breaks in header values.

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.