Like Shampoo for External Markup
We’ve been prototyping something new at KUY.io: an in-house productivity tool called Olympus. The idea is a unified worksuite: email, calendar, files, plus shared resources based on group memberships and permissions. Think of it as the internal counterpart to our devtools. Wave CI and Codex handle the engineering side, Olympus handles the business side. If you’re in the Marketing group, you get access to marketing emails and the Marketing sharepoint. That kind of thing.
The email piece is where this starts. We wanted to render email contents in high fidelity: not stripped-down plaintext, not “here’s a sanitized version that lost all the formatting.” Actual emails, looking the way they’re supposed to look, but without the security nightmare of injecting arbitrary HTML into our app.
So I went looking for a Ruby sanitizer that understands email HTML. And… there isn’t one. Loofah strips things. The sanitize gem strips things differently. Rails wraps Loofah and strips things with Rails opinions. They all treat the problem as “remove the bad stuff” without much thought about what “bad” means in different contexts. Web HTML and email HTML have completely different security profiles. There’s no JavaScript execution in email, so DOM clobbering doesn’t matter, but CSS injection through style attributes is a real concern because email clients render CSS inconsistently. None of the existing libraries think about this distinction.
DOMPurify on the JavaScript side is the closest thing to what I wanted. Cure53 maintains it, it treats sanitization as an adversarial problem, and it has content profiles for different contexts. Ruby didn’t have an equivalent. So I built one, modeled heavily on DOMPurify’s approach, adapted for Nokogiri’s HTML5 parser.
I called it Scrubber, which lasted about four days before I realized that name is already everywhere in the Rails sanitization world. Renamed it to Dandruff, medicated shampoo for your markup.
The API
One-liner for the common case:
clean = Dandruff.scrub(dirty_html)
Instance with configuration when you need control:
d = Dandruff.new { |c| c.allowed_tags = ['p', 'strong', 'em'] }
clean = d.scrub(user_input)
Underneath there’s a single Sanitizer class that handles the whole pipeline: parse with Nokogiri, walk the tree, sanitize elements and attributes, serialize. I thought about a composable scrubber architecture like Loofah, but the security model falls apart when you compose. A style attribute on a div is a different conversation than a style attribute on an svg.
Mutation XSS is terrifying
This was the thing I didn’t appreciate before building this. You sanitize a string, it looks clean, but then a browser parses it into DOM, serializes it back, and the structure shifts in a way that opens up new attack vectors. Mismatched tags, nested comments, elements getting adopted into different parsing contexts. The HTML spec has dark corners where the parsed result is nothing like what you’d expect from reading the source.
DOMPurify’s answer is to sanitize, serialize, re-parse, and sanitize again. Keep going until the output stops changing. If it never stabilizes, something adversarial is happening. I ported this directly. sanitize_until_stable is on by default. And it caught things in my test suite that a single pass missed, which was both validating and unsettling.
The email profile
This was the whole reason the library exists, and it ended up being the most complex piece. Email HTML has completely different rules: no JavaScript execution context means DOM clobbering protection is unnecessary overhead, but you need careful per-tag attribute restrictions and CSS property validation because email clients are all over the map.
Global attribute allowlists can’t express what email needs. An href is fine on <a> but dangerous on other elements. A src belongs on <img> but not on <a>. So the email profile uses per-tag attribute maps, specifying exactly which attributes each tag is allowed to have, nothing more.
The CSS sanitization parses inline styles into individual declarations, checks each property against an allowlist, and validates values for dangerous patterns: javascript:, expression(), @import url, data:text/html. This is the part where Olympus’s requirement for high-fidelity rendering really pushed the design. We couldn’t just strip all styles because that kills the formatting. We had to keep the safe ones and reject the dangerous ones, property by property.
DOMPurify’s test vectors
I pulled DOMPurify’s test corpus and run it as part of my suite. They don’t all pass identically because Nokogiri and browser DOM parsers disagree on some malformed markup. I wrote up every divergence with notes on whether it’s a security concern or just a parsing quirk.
Most are harmless: attribute reordering, whitespace normalization. A few are interesting: Nokogiri is sometimes stricter than browsers, so Dandruff rejects things DOMPurify would allow. That’s the right direction to err for a security library.
DOM clobbering
I initially thought a DOM clobbering denylist was overkill. Then I learned that an element with id="location" can shadow document.location in certain browser contexts. The denylist ended up with about fifty entries like window, document, __proto__, and constructor, each one representing an attack someone actually pulled off in the wild. It’s off for the email profile (where it’s irrelevant) and on by default for everything else.
Hooks
Went back and forth on whether to add a hook system. Six entry points feels like a lot of surface area. But the alternative is someone hits an edge case where the default behavior is 95% right, and they fork the gem or abandon it. Hooks let them write a three-line callback. Pragmatism won.
Where it stands
It does what we need for Olympus. Emails render with full formatting, safely. The DOMPurify test vectors give me confidence on edge cases. Whether it’s ready for every use case, I’m not sure. But it’s ready for ours.