Custom Scrubbers
The built-in scrubbers are just instances of Loofah::Scrubber. You can write your own to perform any document transformation: renaming tags, rewriting URLs, removing nodes, adding attributes, or collecting information. This page explains the Loofah::Scrubber contract, traversal order, flow control with STOP and CONTINUE, and common recipes.
- Block Scrubbers
- Class Scrubbers
- Traversal Direction
- Controlling Traversal: STOP and CONTINUE
- Helper: append_attribute
- Running Scrubbers
- Recipes
- Errors
Block Scrubbers
The quickest way to write a scrubber is to pass a block to Loofah::Scrubber.new. The block is called once for every node in the traversed tree (elements, text nodes, comments, and so on):
span2div = Loofah::Scrubber.new do |node|
node.name = "div" if node.name == "span"
end
Loofah.html5_fragment("<span>foo</span><p>bar</p>").scrub!(span2div).to_s
# => "<div>foo</div><p>bar</p>"
node is a regular Nokogiri::XML::Node, so the whole Nokogiri API is available: node.name, node["attr"], node.set_attribute, node.remove, node.replace, node.add_next_sibling, node.children, node.text?, node.element?, and so on.
Loofah::Scrubber.new takes an optional options hash:
| Option | Values | Default |
|---|---|---|
:direction |
:top_down or :bottom_up |
:top_down |
Any other direction raises:
ArgumentError: direction quux must be one of :top_down or :bottom_up
The block's arity does not matter; a block that takes no arguments is still called for every node (the tests call this "works anyway, shrug").
Class Scrubbers
For reusable or more complex scrubbers, subclass Loofah::Scrubber and implement scrub(node):
class Span2Div < Loofah::Scrubber
def scrub(node)
node.name = "div" if node.name == "span"
end
end
Loofah.html5_fragment("<span>foo</span>").scrub!(Span2Div.new).to_s
# => "<div>foo</div>"
The class comments note that implementing scrub is slightly faster than using a block.
To choose a direction in a subclass, either call super(direction: :bottom_up) from initialize, or set @direction yourself, which is what the built-in scrubbers do:
class Unwrapper < Loofah::Scrubber
def initialize(tag_name)
@direction = :bottom_up
@tag_name = tag_name
end
def scrub(node)
return CONTINUE unless node.element? && node.name == @tag_name
node.before(node.children)
node.remove
end
end
Loofah.html5_fragment("<p><font>Hello</font> <font>there</font></p>")
.scrub!(Unwrapper.new("font")).to_s
# => "<p>Hello there</p>"
If initialize does not call super and does not set @direction, the direction is nil, which Loofah treats as top-down. If a scrubber has no block and its class does not override scrub, traversal raises Loofah::ScrubberNotFound (see Errors).
Traversal Direction
Top-down (the default) calls the scrubber on a node first and then on each of its children, recursively. It is a pre-order walk. Use it when your decision about a node determines whether its children matter, for example removing a subtree.
Bottom-up visits all children first and then the node itself (a post-order walk). Use it when you want to act on a node after its content has already been processed, for example unwrapping an element whose children have already been cleaned, as :strip does.
For a fragment <span>hello</span><span>goodbye</span>, the scrubber is called 4 times (span, text, span, text) in either direction. For the document <html><head><link></head><body><span>hello</span><span>goodbye</span></body></html>, it is called 8 times: html, head, link, body, and the four nodes above.
Traversal starts at:
- a document's root element (the document node itself is not passed to the scrubber),
- each top-level child of a fragment,
- the node itself, when you call
scrub!on a node.
Note: Children are iterated from
node.childrenas it was when the parent finished processing. In top-down mode, if your scrubber removes or replaces a node without returningSTOP, Loofah still walks the old node's children, even though they are now detached. ReturnSTOPafter removing or replacing a node.
Controlling Traversal: STOP and CONTINUE
Two frozen sentinel objects control top-down traversal:
| Constant | Meaning |
|---|---|
Loofah::Scrubber::STOP |
Do not traverse this node's children. |
Loofah::Scrubber::CONTINUE |
Traverse the children (same as returning anything else). |
Only STOP has an effect; any other return value, including nil, means "continue". In bottom-up mode the return value is ignored entirely, because the children have already been visited.
# remove all <employee> tags that have a "deceased" attribute set to true
bring_out_your_dead = Loofah::Scrubber.new do |node|
if node.name == "employee" && node["deceased"] == "true"
node.remove
Loofah::Scrubber::STOP # don't bother with the rest of the subtree
end
end
xml = Loofah.xml_document(<<~XML)
<root>
<employee deceased='true'>Abraham Lincoln</employee>
<employee deceased='false'>Abe Vigoda</employee>
</root>
XML
xml.scrub!(bring_out_your_dead)
xml.css("employee").map(&:text) # => ["Abe Vigoda"]
STOP is also useful for performance: the built-in link scrubbers return STOP after each <a> element because there is nothing to change inside it.
Inside a Loofah::Scrubber subclass you can write STOP and CONTINUE without the namespace.
Helper: append_attribute
Loofah::Scrubber#append_attribute(node, attribute, value) adds a value to a space-separated attribute without duplicating it:
class AddExternalClass < Loofah::Scrubber
def scrub(node)
return CONTINUE unless node.element? && node.name == "a"
append_attribute(node, "class", "external") if node["href"].to_s.start_with?("http")
STOP
end
end
Loofah.html5_fragment('<a class="btn" href="https://example.com">x</a>')
.scrub!(AddExternalClass.new).to_s
# => "<a class=\"btn external\" href=\"https://example.com\">x</a>"
It splits the current value on whitespace, takes the union with the new value, and joins the result with single spaces. If the attribute is missing it is created with just the new value. Inside a block passed to Loofah::Scrubber.new, self is not the scrubber, so this helper is easiest to use from a class scrubber.
Running Scrubbers
A custom scrubber is used exactly like a built-in one:
doc.scrub!(my_scrubber) # whole document or fragment
doc.css("div.comment").scrub!(my_scrubber) # a NodeSet
doc.at_css("article").scrub!(my_scrubber) # a single node
Loofah.scrub_html5_fragment(html, my_scrubber) # parse and scrub
Loofah.scrub_xml_document(xml, my_scrubber) # works for XML too
Chain built-in and custom scrubbers freely:
Loofah.html5_fragment("<span>hello</span> <script>alert('OHAI')</script>")
.scrub!(:prune)
.scrub!(span2div)
.to_s
# => "<div>hello</div> "
Scrubber instances keep no per-run state of their own, so you can define them once (for example as constants) and reuse them across threads, as long as your own block or scrub method does not mutate shared state.
Recipes
Removing elements the safelist allows
The safelist allows form elements, images, and tables. To forbid some of them, run a pruning scrubber before or after the sanitizer:
NO_FORMS = Loofah::Scrubber.new do |node|
if node.element? && %w[form input button select textarea].include?(node.name)
node.remove
Loofah::Scrubber::STOP
end
end
Loofah.html5_fragment(html).scrub!(:prune).scrub!(NO_FORMS)
Allowing an element the safelist would remove
Sanitizers remove anything not on the safelist. To keep content from a custom element, convert it to an allowed element before sanitizing:
callout_to_aside = Loofah::Scrubber.new do |node|
node.name = "aside" if node.element? && node.name == "x-callout"
end
Loofah.html5_fragment('<x-callout>Heads up</x-callout>')
.scrub!(callout_to_aside)
.scrub!(:prune)
.to_s
# => "<aside>Heads up</aside>"
Rewriting link targets
require "cgi"
proxy_images = Loofah::Scrubber.new do |node|
if node.element? && node.name == "img" && node["src"]
node["src"] = "/image-proxy?url=#{CGI.escape(node["src"])}"
end
end
Run it after the sanitizer so you only rewrite URLs that already passed the URI check.
Collecting data without changing the tree
A scrubber does not have to modify anything:
links = []
collector = Loofah::Scrubber.new do |node|
links << node["href"] if node.element? && node.name == "a" && node["href"]
end
Loofah.html5_fragment(html).scrub!(collector)
Restricting a scrubber to part of a document
Because nodes and node sets have scrub!, select first and scrub second:
doc = Loofah.html5_document(page)
doc.css(".user-content").scrub!(:prune)
Errors
| Situation | Exception | Message |
|---|---|---|
scrub! receives something that is neither a known symbol nor a Loofah::Scrubber |
Loofah::ScrubberNotFound |
not a Scrubber or a scrubber name: <value.inspect> |
A subclass has no block and does not implement scrub |
Loofah::ScrubberNotFound |
No scrub method has been defined on <ClassName> |
Invalid :direction |
ArgumentError |
direction <value> must be one of :top_down or :bottom_up |
Loofah::ScrubberNotFound is a subclass of RuntimeError.
Note: Scrubber names must be symbols.
scrub!("prune")raisesLoofah::ScrubberNotFound, because the lookup tableLoofah::Scrubbers::MAPis keyed by symbols. Convert user-provided names withto_symand check them againstLoofah::Scrubbers.scrubber_symbols.
See Also
- Sanitizing Scrubbers
- Link and Text Scrubbers
- Internals: how
scrub!resolves and runs scrubbers. - API Reference