Sanitisation

An allow-list applied to markup that was already safe.

Updated 3 min read

Every rendered document passes through a sanitiser before it is stored. This is defence in depth, not the primary defence: the renderer already escapes every value it writes. The sanitiser exists so that a bug in the renderer cannot become a cross-site scripting vulnerability.

The allow-list

CategoryTags
Textp, em, strong, code, pre, blockquote, hr, br, span
Headingsh1 through h6
Listsul, ol, li
Tablestable, thead, tbody, tfoot, tr, th, td
Links and structurea, div

Anything else is dropped. script, style, iframe, object, embed, svg, math, noscript and template are dropped with their contents, because their contents are not text a browser would merely display.

The attribute rules

AttributeAllowed onConstraint
hrefaMust pass the URL policy
idh1โ€“h6Lowercase letters, digits and hyphens
classcode, preMust begin language-
classspanMust begin tok-
classth, tdMust begin align-
classdivMust be exactly table-scroll
scopeth, tdcol or row

Every other attribute is dropped, which covers every on* handler without needing to enumerate them. An attribute value containing a control character, a quote, < or > is dropped whatever its name.

The URL policy

proc safe(raw: string): string {
  const value = trim(raw);
  if value.isEmpty() || value.numBytes > 2048 then return "";

  for i in 0..<value.numBytes {
    const c = value.byte(i);
    if c < 33 || c == 34 || c == 39 || c == 60 || c == 62 || c == 92 ||
       c == 96 || c == 127 then return "";
  }

  const folded = foldAscii(value);
  if folded.byte(0) == 35 then return value;
  if folded.byte(0) == 47 then
    return if folded.numBytes > 1 && folded.byte(1) == 47 then "" else value;
  if matchesAt(folded, "https://", 0) then return value;
  if matchesAt(folded, "http://", 0) then return value;
  if matchesAt(folded, "mailto:", 0) then return value;
  return "";
}

It is an allow-list of schemes, not a block-list of dangerous ones. javascript:, data: and vbscript: are refused because they are not on the list, which means the next scheme nobody has thought of yet is refused too. Protocol-relative //host URLs are refused as well, since they inherit whatever scheme the page was loaded with.

Balanced output

The sanitiser tracks its own tag stack. A closing tag with no matching open is dropped, and any tag left open at the end is closed. Its output is therefore well-formed regardless of what it was given, and running it twice over the same input produces the same string.

The cost

Zero per request. Sanitisation happens once per document at start-up; a request copies a string that was checked before the socket was bound.