Problem
When scrub! runs on a Nokogiri::XML::Document, Loofah traverses only document.root. The document-level nodes that are siblings of the root, meaning processing instructions, comments, and the DTD, are never visited. A scrubber therefore has no way to inspect or remove them. A custom scrubber written to sanitize an XML document cannot see a document-level <?xml-stylesheet?> processing instruction, for example, even though it can see everything inside the root.
This surfaced through a security report from @cyberlanc3r, but I concluded that it is not a vulnerability in the built-in scrubbers, because those scrubbers are intended to sanitize HTML for delivery as text/html, and the retained processing instruction is only active when the output is served with an XML content type, which those scrubbers do not support.
The underlying API gap is real regardless of that conclusion: Loofah gives a scrubber no way to act on document-level nodes, which prevents the implementation of a custom XML scrubber that wishes to remove or modify those nodes.
One possible solution
Add an overridable hook, Scrubber#traverse_document, whose base implementation preserves today's behavior, and provide a concern (an includable module) that overrides it to remove the document-level nodes. A scrubber opts in by including the module.
# in #scrub! call traverse_document(self) instead of traverse(root)
case self
when Nokogiri::XML::Document
scrubber.traverse_document(self)
...
# lib/loofah/scrubber.rb — base hook: traverse the root, unchanged from today
def traverse_document(document)
traverse(document.root) if document.root
end
# lib/loofah/scrubbers.rb — an includable mixin that removes the prolog nodes
module PrologScrubber
def traverse_document(document)
if document.xml?
prolog_nodes = document.children.to_a - [document.root]
prolog_nodes.each(&:unlink)
end
super
end
end
Custom scrubbers could use it like this:
class MyScrubber < Loofah::Scrubber
include Loofah::Scrubbers::PrologScrubber
def scrub(node)
# your existing logic for nodes inside the root
end
end
The mixin removes the prolog nodes by unlinking them, so it is independent of the scrubber's own scrub method and is safe to include in any scrubber. It applies only to whole-document scrubs, because traverse_document is not invoked for fragments or individual nodes.
FAQ
- Why not simply call the scrubber's
#scrub method on the document-level nodes?
An existing scrub method may not work outside of the DOM tree. The scrub method may add an element or add a text node as part of its operation. XML, however, does not allow arbitrary document-level nodes, and the underlying libraries (libxml2 or xerces) enforce those constraints.
Open questions
- Should any of the built-in scrubbers include this by default? Given the security discussion above, the built-in HTML scrubbers probably could, but it's not a security boundary, and the question is worth settling explicitly.
- HTML documents are gated out here with
document.xml?, so their DOCTYPE is preserved. Is that the behavior we want, or should HTML documents be handled too?
- The name. "Prolog" is the XML term for the nodes before the root, but this mixin also removes nodes that trail the root. A more accurate name may be warranted.
- Wholesale removal versus a per-node decision. This mixin removes every non-root document-level node. Should scrubbers instead be able to make the same keep-or-remove decision on document-level nodes that they make inside the root?
Related
Separately, the built-in scrubbers should document clearly that they are HTML-only, and we should consider having them warn or raise when they are applied to an XML document, so the mismatch is visible rather than silent.
Problem
When
scrub!runs on aNokogiri::XML::Document, Loofah traverses onlydocument.root. The document-level nodes that are siblings of the root, meaning processing instructions, comments, and the DTD, are never visited. A scrubber therefore has no way to inspect or remove them. A custom scrubber written to sanitize an XML document cannot see a document-level<?xml-stylesheet?>processing instruction, for example, even though it can see everything inside the root.This surfaced through a security report from @cyberlanc3r, but I concluded that it is not a vulnerability in the built-in scrubbers, because those scrubbers are intended to sanitize HTML for delivery as
text/html, and the retained processing instruction is only active when the output is served with an XML content type, which those scrubbers do not support.The underlying API gap is real regardless of that conclusion: Loofah gives a scrubber no way to act on document-level nodes, which prevents the implementation of a custom XML scrubber that wishes to remove or modify those nodes.
One possible solution
Add an overridable hook,
Scrubber#traverse_document, whose base implementation preserves today's behavior, and provide a concern (an includable module) that overrides it to remove the document-level nodes. A scrubber opts in by including the module.Custom scrubbers could use it like this:
The mixin removes the prolog nodes by unlinking them, so it is independent of the scrubber's own
scrubmethod and is safe to include in any scrubber. It applies only to whole-document scrubs, becausetraverse_documentis not invoked for fragments or individual nodes.FAQ
#scrubmethod on the document-level nodes?An existing
scrubmethod may not work outside of the DOM tree. Thescrubmethod may add an element or add a text node as part of its operation. XML, however, does not allow arbitrary document-level nodes, and the underlying libraries (libxml2 or xerces) enforce those constraints.Open questions
document.xml?, so their DOCTYPE is preserved. Is that the behavior we want, or should HTML documents be handled too?Related
Separately, the built-in scrubbers should document clearly that they are HTML-only, and we should consider having them warn or raise when they are applied to an XML document, so the mismatch is visible rather than silent.