cipher.ink Write a message

Learn · Technologies we use

Sanitizing HTML you don't trust

Our 22-tag allowlist and why each tag is on it, the link rule, three layers of sanitizing, mutation XSS and the test that attacks us.

A cipher.ink message isn't plain text. The sender writes it in a small rich-text editor, and what travels, encrypted, is HTML. When you open it, your browser puts that HTML into the page, and the sender could be anyone, including someone who wrote the HTML by hand and skipped the editor. HTML you didn't write is hostile until proven otherwise: a <script> tag, an onerror handler on a broken image, a javascript: link, a stylesheet that leaks what it can see. Sanitizing means cutting that HTML down to a short list of things known to be harmless before it's shown. We'll walk through our list, why each item is on it, and what stands behind it when the sanitizer fails.

Allowlist, not blocklist

There are two ways to clean HTML. A blocklist removes the dangerous things: scripts, event handlers, javascript: links. It fails the moment someone finds a dangerous thing you didn't list, and HTML has far more of them than anyone remembers: <svg onload>, <math> tricks, <iframe srcdoc>, <meta http-equiv>, CSS expression() in older browsers, and on it goes. An allowlist flips the question. Keep only what you've decided is safe and drop everything else, including whatever gets invented next year. cipher.ink uses an allowlist, enforced by DOMPurify, a widely used sanitizing library maintained by Cure53. In numbers, that's DOMPurify 3.1.6, 22 allowed tags, 1 attribute (href). Here is the entire policy, taken from app.js as served:

From app.js: the allowlist and the link rule.

var TAGS = ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'h1', 'h2', 'h3',
  'blockquote', 'ul', 'ol', 'li', 'pre', 'code', 'a', 'hr', 'div'];
if (root.DOMPurify) {
  DOMPurify.addHook('afterSanitizeAttributes', function (n) {
    if (n.tagName !== 'A') return;
    var h = n.getAttribute('href') || '';
    if (!/^(https?:|mailto:)/i.test(h)) n.removeAttribute('href');
    else { n.setAttribute('target', '_blank'); n.setAttribute('rel', 'noopener noreferrer nofollow'); }
  });
}
App.sanitize = function (html) {
  if (!root.DOMPurify) throw new Error('sanitizer missing');
  return DOMPurify.sanitize(html, { ALLOWED_TAGS: TAGS, ALLOWED_ATTR: ['href'] });
};

Why each tag is on the list

Every tag answers a button in the editor, and none can carry behavior of its own.

  • Text and structure: p, br, div, hr, blockquote.
  • Emphasis: b, strong, i, em, u, and s, strike and del for strikethrough (different browsers produce the three names).
  • Headings: h1, h2, h3.
  • Lists: ul, ol, li.
  • Code: pre and code.
  • Links: a, with one attribute, href.

That makes 22 tags. What's missing matters as much as what's there:

  • No img. An image is a request to another server, which tells whoever runs it that you opened the message, when, and from where. That's the tracking pixel (read receipts without tracking pixels), and also the classic carrier for onerror. Attached images are shown from the decrypted file instead.
  • No style, no class or id. CSS can leak data (a selector on an attribute value can trigger a request) and can cover the page with a fake form. An id or name can overwrite a global variable in the page (“DOM clobbering”).
  • No form, input, button. A message that looks like our login box would be a phishing page on our own domain.
  • No svg, math, iframe, object, embed, video, audio, link, meta, base, script. Each can load or run something, and the foreign-content ones (SVG and MathML) have a history of parsing tricks.
  • No attribute but href. Every event handler (onclick, onerror, …) is an attribute, and so is style. Allow one attribute and there's no handler to inject.

A link is the one place a message can send you elsewhere, so it gets its own rule, applied by a DOMPurify hook after the attribute pass. The address must start with http:, https: or mailto:. Anything else loses its href, which removes javascript: and data: links outright. Links that survive open in a new tab with rel="noopener noreferrer nofollow". noopener stops the new page from reaching back to the message through window.opener, noreferrer stops the browser telling the destination which page you came from, and nofollow tells search engines not to count the link as an endorsement. None of that makes the destination trustworthy. A link whose text names one site and whose address names another is still possible, so hover before you click.

Three layers, because one isn't enough

  1. On paste. Anything pasted into the editor is sanitized before it goes in, so a page copied from elsewhere can't carry its scripts and styles into your message.
  2. On seal. The editor's content is sanitized again before it's encrypted, so what gets stored is already clean. Nobody who writes a message by hand, around the editor, gets a free pass.
  3. On render. The recipient's browser sanitizes the decrypted HTML once more before putting it into the page. This is the layer that matters most, because a sender controls the first two: they could seal a message with a modified copy of the page. Only the recipient's own copy of the page decides what the recipient sees.

Behind all three stands the Content-Security-Policy. No inline script runs on any page, and a script can only load from this site, so even HTML that got past the sanitizer couldn't execute. A sanitizer is a filter written by people, and filters have bugs. The CSP is the wall behind it.

Mutation XSS, in one paragraph

The nastiest class of sanitizer bypass is mutation XSS. A sanitizer parses the HTML, checks the tree and serializes it back to a string, and then the browser parses that string again to display it. If the two parses produce different trees (the HTML parser is full of special cases for tables, SVG, comments and misnested tags), markup that looked harmless when checked can turn dangerous after the round trip. Mario Heiderich and colleagues described the class at the ACM CCS conference in 2013 (“mXSS Attacks”), and DOMPurify has had bypasses of this kind found and fixed over the years. That's the reason for three choices here: a small allowlist (fewer special cases to exploit), a pinned, self-hosted copy of the library that we update deliberately, and the CSP behind it.

The hostile-HTML test

The browser test suite runs this against the real site, in a real browser. It seals a message whose HTML is <p>hi</p><img src=x onerror="window.pwned=1"><script>window.pwned=2</script><a href="javascript:window.pwned=3">x</a>, opens it, waits a couple of seconds, and checks two things: that window.pwned never got set (nothing ran), and that no img, script or javascript: link made it into the page. It's a smoke test, not a proof, and it runs with the rest of the browser tests.

What sanitizing can't do

It can't make a message honest. A message can still say "your account has been suspended, call this number", and it can still link to a bad site. A sanitizer makes sure the message can't act on your browser. What it says to you is up to you, so treat a message from a stranger the way you'd treat an email from one.

How cipher.ink uses this

Every path that puts a message's HTML into a page goes through App.sanitize: the editor, on paste and on seal, and the viewer, on render. The library is DOMPurify, self-hosted, so the page makes no request to any other site (0 requests to anyone else, and the policy forbids them). It's one of the two libraries the product needs, and the other draws QR codes. You can read the whole thing in the code.

Further reading

  1. DOMPurify (Cure53)
  2. Heiderich et al.: mXSS Attacks: Attacking well-secured Web-Applications by using innerHTML Mutations (2013)
  3. OWASP Cross Site Scripting Prevention Cheat Sheet
  4. MDN: Content Security Policy