loofah 2.25.2 → 2.25.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a721138b6b152897d72edddfb80cdb0f35200c39a8f37a2024b503e6cfacc95
4
- data.tar.gz: b342436a5a4e544b504d3e4234935ecd09dd87eddb55be893a1105cfd6a627f6
3
+ metadata.gz: ab3a16b00807f63098be8c67dbd785edea496daf292dc063299d84d25ae56133
4
+ data.tar.gz: d9ba643579b0190c1f66aa4cdfeb79ec5ade635032172c805be58bd0d67fc5fc
5
5
  SHA512:
6
- metadata.gz: e8ffd17d4b47d19bbff8070fcfe0d111530e7f146abc3501deaed1eb3198481f5ae16ee597e2a0d033eeea8bda27a038d1832c7237e24067a36e724e037e06a9
7
- data.tar.gz: 71f741d1fbcf65fc137b25906c183cf767c091b203210e06d59dbb8e209c12274c77415b03406b021eb16dd149e2f9822f70124cec0947494f955d3fd1e458ee
6
+ metadata.gz: b9b7dc5b9f7e3c0ac798a7e70f6257bb65827baadae3861d17cf0a24e1bf5cab48c909cd463ecc51ae5ba59acd91ca73c004faa7d1c489bbd0e91b7245132c79
7
+ data.tar.gz: 7e17e0f6e9cec7edd72c0f254aa406e510531297af9acc10714cd7277ec1641d48ab369a0bfeeef585104c314a79cdfb795f112e84591a5cf81f4cb8eac46486
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.25.3 / 2026-11-01
4
+
5
+ ### Security
6
+
7
+ * Ensure `Loofah::HTML5::Scrub.allowed_uri?` rejects schemes split by a numeric character reference without a semicolon that ends at a character `allowed_uri?` strips (e.g. `javascript&#x3a alert(1)`) or at another character reference (e.g. `javascript&#x3aalert(1)`). Character references are now decoded before anything is stripped, as browsers do. See [GHSA-p9wj-w8xq-vjx6](https://github.com/flavorjones/loofah/security/advisories/GHSA-p9wj-w8xq-vjx6). @flavorjones
8
+ * Ensure `Loofah::HTML5::Scrub.allowed_uri?` recognizes the named character reference `&sol;`, which `CGI.unescapeHTML` does not decode and browsers read as `/`, and rejects `data:` URIs that disguise a disallowed mediatype with it (e.g. `data:text&sol;html,<script>alert(1)</script>`). Previously that mediatype was treated as malformed and fell back to the allowed `text/plain`. Only v2.25.2 is affected. See [GHSA-jcff-cj73-4w4f](https://github.com/flavorjones/loofah/security/advisories/GHSA-jcff-cj73-4w4f). @flavorjones
9
+ * Ensure `Loofah::HTML5::Scrub.scrub_css` does not decode CSS escapes in identifiers. Previously, escaped parentheses (e.g. `url\28 http://example.com/\29`) came out as a live `url()`, which could make the viewer's browser fetch an attacker-chosen URL. See [GHSA-m3x9-938q-5767](https://github.com/flavorjones/loofah/security/advisories/GHSA-m3x9-938q-5767). @flavorjones
10
+ * Ensure that `href` and `xlink:href` on SVG `textPath` and `mpath` elements are restricted to local (same-document) references. Previously `SVG_ALLOW_LOCAL_HREF` spelled `textPath` in lowercase, which never matched the name the HTML5 parser assigns inside `<svg>`, and omitted `mpath` entirely. See [GHSA-345x-3rq2-jfrm](https://github.com/flavorjones/loofah/security/advisories/GHSA-345x-3rq2-jfrm). @flavorjones
11
+ * Fix quadratic-time scrubbing of the `href` and `xlink:href` attributes of SVG elements. Only Ruby 3.1 and earlier are affected, because Ruby 3.2 introduced linear-time regexp matching. See [GHSA-mf2j-8f84-cc9q](https://github.com/flavorjones/loofah/security/advisories/GHSA-mf2j-8f84-cc9q). @flavorjones
12
+ * Ensure HTML5 scrubber output does not turn into new markup when a browser parses it again. The fix is in Nokogiri v1.19.5, which escapes `<` and `>` in attribute values as the HTML standard requires, so Loofah now requires Nokogiri >= 1.19.5. See [GHSA-h6v6-rg66-fqj4](https://github.com/flavorjones/loofah/security/advisories/GHSA-h6v6-rg66-fqj4). @flavorjones @hackvertor
13
+
14
+ ### Dependencies
15
+
16
+ * Loofah now requires Ruby >= 3.2, the minimum that Nokogiri v1.19.x supports. We don't like dropping Ruby versions in a patch release, and we're sorry for the inconvenience, but it's the only way to make sure everyone gets the security fix in [GHSA-h6v6-rg66-fqj4](https://github.com/flavorjones/loofah/security/advisories/GHSA-h6v6-rg66-fqj4).
17
+
18
+ ### Added
19
+
20
+ * `Loofah::HTML5::Scrub.prefixed_attribute_name` and `Loofah::HTML5::Scrub.scrub_attribute_value` expose the html5lib attribute name and value rules, so downstream consumers can apply them instead of carrying their own copy. @flavorjones
21
+
22
+ ### Fixed
23
+
24
+ * Fix `:double_breakpoint`, which left document fragments unchanged despite its documented example. The scrubber now changes only paragraphs inside the scrubbed node, and its run time on a document grows linearly, not quadratically, with the paragraph count. #309 @OskarEichler
25
+ * Fix `:whitewash`, which passed CDATA sections through unchanged. Markup inside a CDATA section in `Loofah.xml_fragment` or `Loofah.xml_document` survived scrubbing and became live elements if the output was later parsed as HTML. The scrubber now escapes CDATA content, as `:strip`, `:prune` and `:escape` already do. On JRuby, every sanitizer now emits escaped CDATA content as a CDATA section, as on CRuby, instead of a text node that double-escaped it. #313 @flavorjones (reported by @MegaManSec)
26
+ * Fix `:double_breakpoint`, which raised `NoMethodError` on a paragraph that ends with a `<br>` after a double break. #314 @flavorjones (reported by @MegaManSec)
27
+ * Fix `Loofah::HTML5::Scrub.allowed_uri?`, which raised `Encoding::CompatibilityError` on a string in an encoding other than UTF-8 and `ArgumentError` on invalid UTF-8. It now transcodes the input to UTF-8 and returns `false` when the input has an invalid byte sequence or no UTF-8 representation. #315 @flavorjones
28
+
29
+
3
30
  ## 2.25.2 / 2026-07-15
4
31
 
5
32
  ### Security
@@ -623,7 +623,8 @@ module Loofah
623
623
  "pattern",
624
624
  "radialGradient",
625
625
  "set",
626
- "textpath",
626
+ "mpath",
627
+ "textPath",
627
628
  "tref",
628
629
  "use",
629
630
  ])
@@ -13,6 +13,7 @@ module Loofah
13
13
  CSS_IMPORTANT = "!important"
14
14
  CSS_WHITESPACE = " "
15
15
  CSS_PROPERTY_STRING_WITHOUT_EMBEDDED_QUOTES = /\A(["'])?[^"']+\1\z/
16
+ CSS_URLISH_NODES = [:url, :bad_url, :bad_string]
16
17
  DATA_ATTRIBUTE_NAME = /\Adata-[\w-]+\z/
17
18
 
18
19
  # Decimal (`&#58`) or hexadecimal (`&#x3a`) form, with or without the trailing semicolon that
@@ -52,6 +53,15 @@ module Loofah
52
53
  # URIs. CGI.unescapeHTML does not decode these, so they are handled explicitly.
53
54
  WHITESPACE_CHARACTER_REFERENCES = /&(Tab|NewLine);/
54
55
 
56
+ # HTML5 named character references for the delimiters that decide how a URI parses:
57
+ # ":" ends the scheme, and "/" separates the type and subtype of a data: mediatype.
58
+ # CGI.unescapeHTML does not decode these either. Together with
59
+ # WHITESPACE_CHARACTER_REFERENCES these are every named character reference that
60
+ # decodes to U+0009, U+000A, U+002F, or U+003A, so no other named reference can
61
+ # change how a URI parses.
62
+ DELIMITER_CHARACTER_REFERENCES = { "&colon;" => ":", "&sol;" => "/" }.freeze
63
+ DELIMITER_CHARACTER_REFERENCE = Regexp.union(DELIMITER_CHARACTER_REFERENCES.keys)
64
+
55
65
  class << self
56
66
  def allowed_element?(element_name)
57
67
  ::Loofah::HTML5::SafeList::ALLOWED_ELEMENTS_WITH_LIBXML2.include?(element_name)
@@ -60,11 +70,7 @@ module Loofah
60
70
  # alternative implementation of the html5lib attribute scrubbing algorithm
61
71
  def scrub_attributes(node)
62
72
  node.attribute_nodes.each do |attr_node|
63
- attr_name = if attr_node.namespace
64
- "#{attr_node.namespace.prefix}:#{attr_node.node_name}"
65
- else
66
- attr_node.node_name
67
- end
73
+ attr_name = prefixed_attribute_name(attr_node)
68
74
 
69
75
  if DATA_ATTRIBUTE_NAME.match?(attr_name)
70
76
  next
@@ -75,20 +81,7 @@ module Loofah
75
81
  next
76
82
  end
77
83
 
78
- if SafeList::ATTR_VAL_IS_URI.include?(attr_name)
79
- next if scrub_uri_attribute(attr_node)
80
- end
81
-
82
- if SafeList::SVG_ATTR_VAL_ALLOWS_REF.include?(attr_name)
83
- scrub_attribute_that_allows_local_ref(attr_node)
84
- end
85
-
86
- next unless SafeList::SVG_ALLOW_LOCAL_HREF.include?(node.name) &&
87
- SafeList::SVG_HREF_ATTRIBUTES.include?(attr_name) &&
88
- attr_node.value =~ /^\s*[^#\s].*/m
89
-
90
- attr_node.remove
91
- next
84
+ scrub_attribute_value(node, attr_node, attr_name)
92
85
  end
93
86
 
94
87
  scrub_css_attribute(node)
@@ -102,28 +95,58 @@ module Loofah
102
95
  force_correct_attribute_escaping!(node)
103
96
  end
104
97
 
98
+ # The name an attribute is matched by in the safelists, qualified by its namespace prefix.
99
+ def prefixed_attribute_name(attr_node)
100
+ if attr_node.namespace
101
+ "#{attr_node.namespace.prefix}:#{attr_node.node_name}"
102
+ else
103
+ attr_node.node_name
104
+ end
105
+ end
106
+
107
+ # Apply the html5lib value rules to an attribute that is already permitted by name.
108
+ # Returns true when `scrub_uri_attribute` removed the attribute, in which case the caller
109
+ # should stop scrubbing it.
110
+ def scrub_attribute_value(node, attr_node, attr_name = prefixed_attribute_name(attr_node))
111
+ if SafeList::ATTR_VAL_IS_URI.include?(attr_name)
112
+ return true if scrub_uri_attribute(attr_node)
113
+ end
114
+
115
+ if SafeList::SVG_ATTR_VAL_ALLOWS_REF.include?(attr_name)
116
+ scrub_attribute_that_allows_local_ref(attr_node)
117
+ end
118
+
119
+ # The whitespace run is `[^\S\n]*` rather than `\s*` so that it cannot cross a line
120
+ # boundary. With `\s*` the engine rescanned the entire run from every line start,
121
+ # which is quadratic in the length of an all-whitespace value.
122
+ if SafeList::SVG_ALLOW_LOCAL_HREF.include?(node.name) &&
123
+ SafeList::SVG_HREF_ATTRIBUTES.include?(attr_name) &&
124
+ attr_node.value =~ /^[^\S\n]*[^#\s]/
125
+ attr_node.remove
126
+ end
127
+
128
+ false
129
+ end
130
+
105
131
  def scrub_css_attribute(node)
106
132
  style = node.attributes["style"]
107
133
  style.value = scrub_css(style.value) if style
108
134
  end
109
135
 
110
136
  def scrub_css(style)
111
- url_flags = [:url, :bad_url]
112
137
  style_tree = Crass.parse_properties(style)
113
138
  sanitized_tree = []
114
139
 
115
140
  style_tree.each do |node|
116
141
  next unless node[:node] == :property
117
- next if node[:children].any? do |child|
118
- url_flags.include?(child[:node])
119
- end
142
+ next if node[:children].any? { |child| CSS_URLISH_NODES.include?(child[:node]) }
120
143
 
121
144
  name = node[:name].downcase
122
145
  next unless SafeList::ALLOWED_CSS_PROPERTIES.include?(name) ||
123
146
  SafeList::ALLOWED_SVG_PROPERTIES.include?(name) ||
124
147
  SafeList::SHORTHAND_CSS_PROPERTIES.include?(name.split("-").first)
125
148
 
126
- value = node[:children].map do |child|
149
+ value = node[:children].filter_map do |child|
127
150
  case child[:node]
128
151
  when :whitespace
129
152
  CSS_WHITESPACE
@@ -140,30 +163,55 @@ module Loofah
140
163
  if !SafeList::SHORTHAND_CSS_PROPERTIES.include?(name.split("-").first) ||
141
164
  SafeList::ALLOWED_CSS_KEYWORDS.include?(keyword) ||
142
165
  (keyword =~ CSS_KEYWORDISH)
143
- keyword
166
+ child[:raw]
144
167
  end
145
168
  else
146
169
  child[:raw]
147
170
  end
148
- end.compact.join.strip
171
+ end.join.strip
149
172
 
150
173
  next if value.empty?
151
174
 
152
175
  value << CSS_WHITESPACE << CSS_IMPORTANT if node[:important]
176
+ # Dropping tokens can join the survivors into new ones (`url/(a)*/(b)` becomes `url/*/`),
177
+ # so the rebuilt string may tokenize differently from the original and stringify
178
+ # differently again. Keep a property only if its rebuilt string survives a parse and
179
+ # stringify unchanged, so the tokens checked are exactly the tokens a browser will see.
153
180
  propstring = format("%s:%s", name, value)
154
181
  sanitized_node = Crass.parse_properties(propstring).first
182
+ next unless Crass::Parser.stringify(sanitized_node) == propstring
183
+ next if css_property_unsafe?(sanitized_node)
184
+
155
185
  sanitized_tree << sanitized_node << CRASS_SEMICOLON
156
186
  end
157
187
 
158
188
  Crass::Parser.stringify(sanitized_tree)
159
189
  end
160
190
 
191
+ # Dropping a token joins its neighbours, which can build a token that was not in the input:
192
+ # dropping the string from `url\28 "x"linear-gradient(red)` leaves one function token. So the
193
+ # rebuilt property is checked for functions and strings, which the input check never sees.
194
+ def css_property_unsafe?(node)
195
+ node[:children].any? do |child|
196
+ case child[:node]
197
+ when *CSS_URLISH_NODES
198
+ true
199
+ when :string
200
+ !CSS_PROPERTY_STRING_WITHOUT_EMBEDDED_QUOTES.match?(child[:raw])
201
+ when :function
202
+ !SafeList::ALLOWED_CSS_FUNCTIONS.include?(child[:name].downcase(:ascii))
203
+ else
204
+ false
205
+ end
206
+ end
207
+ end
208
+
161
209
  def scrub_attribute_that_allows_local_ref(attr_node)
162
210
  return unless attr_node.value
163
211
 
164
212
  nodes = Crass::Parser.new(attr_node.value).parse_component_values
165
213
 
166
- values = nodes.map do |node|
214
+ values = nodes.filter_map do |node|
167
215
  case node[:node]
168
216
  when :url
169
217
  if node[:value].start_with?("#")
@@ -172,7 +220,7 @@ module Loofah
172
220
  when :hash, :ident, :string
173
221
  node[:raw]
174
222
  end
175
- end.compact
223
+ end
176
224
 
177
225
  attr_node.value = values.join(" ")
178
226
  end
@@ -180,16 +228,27 @@ module Loofah
180
228
  # Returns true if the given URI string is safe, false otherwise. This method can be used to
181
229
  # validate URI attribute values without requiring a Nokogiri DOM node.
182
230
  def allowed_uri?(uri_string)
231
+ # The character-reference regexps are UTF-8, so the input has to be transcoded before it can
232
+ # be matched against them. String#encode is a no-op on a string already tagged UTF-8, so an
233
+ # invalid byte sequence has to be rejected separately.
234
+ return false unless uri_string.valid_encoding?
235
+
236
+ begin
237
+ uri_string = uri_string.encode(Encoding::UTF_8)
238
+ rescue EncodingError
239
+ return false
240
+ end
241
+
242
+ # Decode character references before stripping anything, as a browser does: an HTML parser
243
+ # decodes the attribute value, then the URL parser strips characters from the result. A
244
+ # semicolon-less numeric reference ends at the first character that cannot extend it, so
245
+ # stripping that character first would let the reference run on into the following text.
183
246
  # CGI.unescapeHTML decodes numeric references only when they carry a trailing semicolon, so
184
- # also decode the semicolon-less ones, which browsers still decode and execute. Normalizing
185
- # more aggressively than a browser only rejects more, which is safe. Control characters are
186
- # stripped both before and after decoding, since decoding can produce them. That strip must
187
- # precede WHITESPACE_CHARACTER_REFERENCES: removing a control character can reveal a named
188
- # whitespace reference.
189
- uri_string = decode_numeric_character_references(CGI.unescapeHTML(uri_string.gsub(CONTROL_CHARACTERS, "")))
247
+ # the semicolon-less ones are decoded separately.
248
+ uri_string = CGI.unescapeHTML(decode_numeric_character_references(uri_string))
190
249
  uri_string.gsub!(CONTROL_CHARACTERS, "")
191
250
  uri_string.gsub!(WHITESPACE_CHARACTER_REFERENCES, "")
192
- uri_string.gsub!("&colon;", ":")
251
+ uri_string.gsub!(DELIMITER_CHARACTER_REFERENCE, DELIMITER_CHARACTER_REFERENCES)
193
252
  uri_string.downcase!
194
253
  if URI_PROTOCOL_REGEX.match?(uri_string)
195
254
  protocol = uri_string.split(SafeList::PROTOCOL_SEPARATOR)[0]
@@ -265,10 +324,10 @@ module Loofah
265
324
 
266
325
  def cdata_escape(node)
267
326
  escaped_text = escape_tags(node.text)
268
- if Nokogiri.jruby?
269
- node.document.create_text_node(escaped_text)
270
- else
327
+ if node.cdata?
271
328
  node.document.create_cdata(escaped_text)
329
+ else
330
+ node.document.create_text_node(escaped_text)
272
331
  end
273
332
  end
274
333
 
@@ -201,7 +201,11 @@ module Loofah
201
201
  return CONTINUE if node.namespaces.empty?
202
202
  end
203
203
  when Nokogiri::XML::Node::TEXT_NODE, Nokogiri::XML::Node::CDATA_SECTION_NODE
204
- return CONTINUE
204
+ if HTML5::Scrub.cdata_needs_escaping?(node)
205
+ node.before(HTML5::Scrub.cdata_escape(node))
206
+ else
207
+ return CONTINUE
208
+ end
205
209
  end
206
210
  node.remove
207
211
  STOP
@@ -361,37 +365,34 @@ module Loofah
361
365
  #
362
366
  class DoubleBreakpoint < Scrubber
363
367
  def initialize # rubocop:disable Lint/MissingSuper
364
- @direction = :top_down
368
+ # a top-down walk would empty a paragraph before reaching a paragraph nested in it
369
+ @direction = :bottom_up
365
370
  end
366
371
 
367
372
  def scrub(node)
368
373
  return CONTINUE unless (node.type == Nokogiri::XML::Node::ELEMENT_NODE) && (node.name == "p")
374
+ return CONTINUE unless node.at_xpath("./br[following-sibling::br]")
369
375
 
370
- paragraph_with_break_point_nodes = node.xpath("//p[br[following-sibling::br]]")
376
+ new_paragraph = node.add_previous_sibling("<p>").first
371
377
 
372
- paragraph_with_break_point_nodes.each do |paragraph_node|
373
- new_paragraph = paragraph_node.add_previous_sibling("<p>").first
378
+ node.children.each do |child|
379
+ remove_blank_text_nodes(child)
380
+ end
374
381
 
375
- paragraph_node.children.each do |child|
376
- remove_blank_text_nodes(child)
377
- end
382
+ node.children.each do |child|
383
+ # already unlinked
384
+ next if child.parent.nil?
378
385
 
379
- paragraph_node.children.each do |child|
380
- # already unlinked
381
- next if child.parent.nil?
382
-
383
- if child.name == "br" && child.next_sibling.name == "br"
384
- new_paragraph = paragraph_node.add_previous_sibling("<p>").first
385
- child.next_sibling.unlink
386
- child.unlink
387
- else
388
- child.parent = new_paragraph
389
- end
386
+ if child.name == "br" && child.next_sibling&.name == "br"
387
+ new_paragraph = node.add_previous_sibling("<p>").first
388
+ child.next_sibling.unlink
389
+ child.unlink
390
+ else
391
+ child.parent = new_paragraph
390
392
  end
391
-
392
- paragraph_node.unlink
393
393
  end
394
394
 
395
+ node.unlink
395
396
  CONTINUE
396
397
  end
397
398
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Loofah
4
4
  # The version of Loofah you are using
5
- VERSION = "2.25.2"
5
+ VERSION = "2.25.3"
6
6
  end
data/lib/loofah.rb CHANGED
@@ -5,13 +5,7 @@ require "nokogiri"
5
5
  module Loofah
6
6
  class << self
7
7
  def html5_support?
8
- # Note that Loofah can only support HTML5 in Nokogiri >= 1.14.0 because it requires the
9
- # subclassing fix from https://github.com/sparklemotion/nokogiri/pull/2534
10
- return @html5_support if defined? @html5_support
11
-
12
- @html5_support =
13
- Gem::Version.new(Nokogiri::VERSION) > Gem::Version.new("1.14.0") &&
14
- Nokogiri.uses_gumbo?
8
+ Nokogiri.uses_gumbo?
15
9
  end
16
10
  end
17
11
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: loofah
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.25.2
4
+ version: 2.25.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: 1.12.0
33
+ version: 1.19.5
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: 1.12.0
40
+ version: 1.19.5
41
41
  description: |
42
42
  Loofah is a general library for manipulating and transforming HTML/XML documents and fragments,
43
43
  built on top of Nokogiri.
@@ -89,14 +89,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
89
89
  requirements:
90
90
  - - ">="
91
91
  - !ruby/object:Gem::Version
92
- version: 2.5.0
92
+ version: 3.2.0
93
93
  required_rubygems_version: !ruby/object:Gem::Requirement
94
94
  requirements:
95
95
  - - ">="
96
96
  - !ruby/object:Gem::Version
97
97
  version: '0'
98
98
  requirements: []
99
- rubygems_version: 4.0.10
99
+ rubygems_version: 4.0.20
100
100
  specification_version: 4
101
101
  summary: Loofah is a general library for manipulating and transforming HTML/XML documents
102
102
  and fragments, built on top of Nokogiri.