Sanitisation
An allow-list applied to markup that was already safe.
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
| Category | Tags |
|---|---|
| Text | p, em, strong, code, pre, blockquote, hr, br, span |
| Headings | h1 through h6 |
| Lists | ul, ol, li |
| Tables | table, thead, tbody, tfoot, tr, th, td |
| Links and structure | a, 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
| Attribute | Allowed on | Constraint |
|---|---|---|
href | a | Must pass the URL policy |
id | h1โh6 | Lowercase letters, digits and hyphens |
class | code, pre | Must begin language- |
class | span | Must begin tok- |
class | th, td | Must begin align- |
class | div | Must be exactly table-scroll |
scope | th, td | col 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.