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

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.children as it was when the parent finished processing. In top-down mode, if your scrubber removes or replaces a node without returning STOP, Loofah still walks the old node's children, even though they are now detached. Return STOP after 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>"
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") raises Loofah::ScrubberNotFound, because the lookup table Loofah::Scrubbers::MAP is keyed by symbols. Convert user-provided names with to_sym and check them against Loofah::Scrubbers.scrubber_symbols.

See Also