Uncontrolled Recursion in Loofah
Description
Loofah is a general library for manipulating and transforming HTML/XML documents and fragments, built on top of Nokogiri. Loofah >= 2.2.0, < 2.19.1 uses recursion for sanitizing CDATA sections, making it susceptible to stack exhaustion and raising a SystemStackError exception. This may lead to a denial of service through CPU resource consumption. This issue is patched in version 2.19.1. Users who are unable to upgrade may be able to mitigate this vulnerability by limiting the length of the strings that are sanitized.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Loofah < 2.19.1 uses recursion to sanitize CDATA sections, causing stack exhaustion and denial of service via CPU consumption.
Loofah is a Ruby library for manipulating and transforming HTML/XML documents, built on top of Nokogiri. In versions 2.2.0 through 2.19.0, the sanitization of CDATA sections is implemented recursively. This design flaw can lead to stack exhaustion when processing deeply nested or large CDATA sections, raising a SystemStackError exception and consuming excessive CPU resources [1][2].
An attacker can exploit this vulnerability by providing a crafted HTML/XML document containing a CDATA section that triggers deep recursion. No authentication is required if the application sanitizes user-supplied input. The attack surface includes any application that uses Loofah to sanitize untrusted HTML/XML, such as web applications that allow user-generated content [2].
Successful exploitation results in a denial of service due to CPU resource consumption, potentially causing the application to become unresponsive. The vulnerability does not lead to code execution or data leakage [2].
The issue is patched in Loofah version 2.19.1, which replaces the recursive approach with an escaping solution (see commit [3]). Users who are unable to upgrade can mitigate the vulnerability by limiting the length of strings that are sanitized [2][3].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
loofahRubyGems | >= 2.2.0, < 2.19.1 | 2.19.1 |
Affected products
8- ghsa-coords7 versionspkg:gem/loofahpkg:rpm/opensuse/rubygem-loofah&distro=openSUSE%20Leap%2015.4pkg:rpm/opensuse/rubygem-loofah&distro=openSUSE%20Tumbleweedpkg:rpm/suse/rubygem-loofah&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP1pkg:rpm/suse/rubygem-loofah&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP2pkg:rpm/suse/rubygem-loofah&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP3pkg:rpm/suse/rubygem-loofah&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP4
>= 2.2.0, < 2.19.1+ 6 more
- (no CPE)range: >= 2.2.0, < 2.19.1
- (no CPE)range: < 2.2.2-150000.4.9.1
- (no CPE)range: < 2.19.1-1.1
- (no CPE)range: < 2.2.2-150000.4.9.1
- (no CPE)range: < 2.2.2-150000.4.9.1
- (no CPE)range: < 2.2.2-150000.4.9.1
- (no CPE)range: < 2.2.2-150000.4.9.1
- flavorjones/loofahv5Range: >= 2.2.0, < 2.19.1
Patches
186f7f6364491fix: replace recursive approach to cdata with escaping solution
4 files changed · +60 −9
lib/loofah/html5/scrub.rb+40 −0 modified@@ -182,6 +182,46 @@ def force_correct_attribute_escaping!(node) end.force_encoding(encoding) end end + + def cdata_needs_escaping?(node) + # Nokogiri's HTML4 parser on JRuby doesn't flag the child of a `style` or `script` tag as cdata, but it acts that way + node.cdata? || (Nokogiri.jruby? && node.text? && (node.parent.name == "style" || node.parent.name == "script")) + end + + def cdata_escape(node) + escaped_text = escape_tags(node.text) + if Nokogiri.jruby? + node.document.create_text_node(escaped_text) + else + node.document.create_cdata(escaped_text) + end + end + + TABLE_FOR_ESCAPE_HTML__ = { + '<' => '<', + '>' => '>', + '&' => '&', + } + + def escape_tags(string) + # modified version of CGI.escapeHTML from ruby 3.1 + enc = string.encoding + unless enc.ascii_compatible? + if enc.dummy? + origenc = enc + enc = Encoding::Converter.asciicompat_encoding(enc) + string = enc ? string.encode(enc) : string.b + end + table = Hash[TABLE_FOR_ESCAPE_HTML__.map {|pair|pair.map {|s|s.encode(enc)}}] + string = string.gsub(/#{"[<>&]".encode(enc)}/, table) + string.encode!(origenc) if origenc + string + else + string = string.b + string.gsub!(/[<>&]/, TABLE_FOR_ESCAPE_HTML__) + string.force_encoding(enc) + end + end end end end
lib/loofah/scrubber.rb+4 −0 modified@@ -108,6 +108,10 @@ def html5lib_sanitize(node) return Scrubber::CONTINUE end when Nokogiri::XML::Node::TEXT_NODE, Nokogiri::XML::Node::CDATA_SECTION_NODE + if HTML5::Scrub.cdata_needs_escaping?(node) + node.before(HTML5::Scrub.cdata_escape(node)) + return Scrubber::STOP + end return Scrubber::CONTINUE end Scrubber::STOP
lib/loofah/scrubbers.rb+2 −6 modified@@ -100,13 +100,9 @@ def initialize def scrub(node) return CONTINUE if html5lib_sanitize(node) == CONTINUE - if node.children.length == 1 && node.children.first.cdata? - sanitized_text = Loofah.fragment(node.children.first.to_html).scrub!(:strip).to_html - node.before Nokogiri::XML::Text.new(sanitized_text, node.document) - else - node.before node.children - end + node.before(node.children) node.remove + return STOP end end
test/integration/test_ad_hoc.rb+14 −3 modified@@ -100,17 +100,28 @@ def test_return_empty_string_when_nothing_left end def test_nested_script_cdata_tags_should_be_scrubbed - html = "<script><script src='malicious.js'></script>" + html = "<script><script src=\"malicious.js\">this & that</script>" stripped = Loofah.fragment(html).scrub!(:strip) + assert_empty stripped.xpath("//script") - refute_match("<script", stripped.to_html) + assert_equal("<script src=\"malicious.js\">this & that", stripped.to_html) end def test_nested_script_cdata_tags_should_be_scrubbed_2 html = "<script><script>alert('a');</script></script>" stripped = Loofah.fragment(html).scrub!(:strip) + assert_empty stripped.xpath("//script") - refute_match("<script", stripped.to_html) + assert_equal("<script>alert('a');", stripped.to_html) + end + + def test_nested_script_cdata_tags_should_be_scrubbed_max_recursion + n = 40 + html = "<div>" + ("<script>" * n) + "alert(1);" + ("</script>" * n) + "</div>" + expected = "<div>" + ("<script>" * (n-1)) + "alert(1);</div>" + actual = Loofah.fragment(html).scrub!(:strip).to_html + + assert_equal(expected, actual) end def test_removal_of_all_tags
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-3x8r-x6xp-q4vmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-23516ghsaADVISORY
- github.com/flavorjones/loofah/commit/86f7f6364491b0099d215db858ecdc0c89ded040ghsaWEB
- github.com/flavorjones/loofah/security/advisories/GHSA-3x8r-x6xp-q4vmghsax_refsource_CONFIRMWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/loofah/CVE-2022-23516.ymlghsaWEB
- lists.debian.org/debian-lts-announce/2023/09/msg00011.htmlghsaWEB
- lists.debian.org/debian-lts-announce/2024/09/msg00044.htmlghsaWEB
News mentions
0No linked articles in our index yet.