sanitize 7.0.0 → 7.0.2

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: c7ec07b16780ba818a5fdbb0570fb6c84245c60b2656388cab0b03b0d00bbc6a
4
- data.tar.gz: 650c2a0c59fd4af1d305051e5488bcacee3f5a638a96960163f6677b838a9661
3
+ metadata.gz: 9dcbdd9a00a2f80ddded816eb9b52215365ae34ec23e99f0d31b2a2b11757b49
4
+ data.tar.gz: 38be6ac4c8215e558b4b1f4c8ae2eb570f6225a6473964b52ffbcb49c2806779
5
5
  SHA512:
6
- metadata.gz: ec33f841766422dda2ee1d889d2e3f42f6b3b479c2cf557cad80de0311969a4e132e02a5a33dda296af2e2a5fec8791aa6ba8eacd6ea370428cc40ff3f08a720
7
- data.tar.gz: f4cf669dba8e416c38fb4b9eae30b86e9c7816718e80cc32eac1f536f1e2023c59cb74df7e701af717a157e1fe1ccc902a89b26992488a85400e4dc0c089d07a
6
+ metadata.gz: d802d1606fa2a8cb2056125d26092ef056146252c38ff55a086776c35eda10b70c63882f7b473656f4ea5cf18dfc9864e1e2bc6d257b204a1072568a5a65d440
7
+ data.tar.gz: 0f64bb78dd9c4b2dca863530c02a08559c36ed9ec761c0d3f3b1d81fef3b1eefe39e7632d0354eaf75b49b5e755c0e0191f92b6fb6a669255fd48bde2df332d7
data/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  All notable changes to Sanitize are documented in this file. The format (since version 7.0.0) is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## 7.0.2 (2026-11-02)
6
+
7
+ ### Security
8
+
9
+ - MathML and SVG elements whose `<math>` or `<svg>` ancestor has been removed are now also removed (keeping their contents, subject to `:remove_contents`), even if they're allowed by the config or have been allowlisted by a transformer. Previously, if Sanitize removed a `<math>` or `<svg>` element but kept its contents, its descendants were kept even though they'd been parsed as foreign elements, and browsers would reparse them as HTML elements. Since some elements are parsed very differently in foreign content than in HTML, this could allow arbitrary HTML to be injected. For example, the contents of a `<textarea>`, `<title>`, `<xmp>`, `<noembed>`, or `<noframes>` element inside a MathML or SVG element are parsed as markup rather than text, so an attribute value, comment, or raw text element inside it like `<svg><textarea><!--</textarea><img src=x onerror=alert(1)>-->` could close the element early once it was reparsed as HTML. Similarly, browsers that use legacy `<select>` parsing rules would parse the contents of a raw text element inside a `<select>` element like `<math><select><mi><xmp><input><img src=x onerror=alert(1)></xmp>` as markup. This only affected custom configs that don't remove the contents of `<math>` or `<svg>` elements and allow one of the affected elements. Sanitize's default and built-in configs aren't affected. Thanks to Mike Dalessio (@flavorjones) for reporting this issue. (GHSA-599p-54q3-ff89)
10
+
11
+ - Elements and comments inside text content elements such as `<textarea>`, `<title>`, `<xmp>`, `<noembed>`, and `<noframes>` are now always removed, even if they're allowed by the config or have been allowlisted by a transformer. Only their text is kept, subject to `:remove_contents`. This provides defense in depth against the issue above. (GHSA-599p-54q3-ff89)
12
+
13
+ - Sanitize now requires Nokogiri 1.19.7 or higher. Nokogiri versions since 1.19.5 escape `<` and `>` in serialized attribute values to match browser behavior, which provides defense in depth against the issue above. (GHSA-599p-54q3-ff89)
14
+
15
+ - Elements inside `<select>`, `<option>`, and `<optgroup>` elements that browsers using legacy `<select>` parsing rules wouldn't allow there are now always removed (keeping their contents, subject to `:remove_contents`), even if they're allowed by the config or have been allowlisted by a transformer. Previously, if Sanitize removed a `<template>` element inside a `<select>` element but kept its contents, any raw text element inside the `<template>` element would end up inside the `<select>` element. Browsers that use legacy `<select>` parsing rules ignore the start tags of raw text elements inside a `<select>` element and parse their contents as markup, so input like `<select><template><xmp><input><img src=x onerror=alert(1)></xmp>` could be used to inject arbitrary HTML. This only affected custom configs that allow `<select>` and at least one raw text element such as `<style>`, `<xmp>`, `<noembed>`, `<noframes>`, or `<plaintext>`. Sanitize's default and built-in configs aren't affected. (GHSA-mg79-gw37-jxx7)
16
+
17
+ ### Changed
18
+
19
+ - Ruby 3.2.0 is now the oldest supported Ruby version, since Nokogiri 1.19.x requires Ruby 3.2.0 or higher. Ruby 3.1 reached end of life in March 2025.
20
+
21
+ ## 7.0.1 (2026-10-21)
22
+
23
+ ### Security
24
+
25
+ - CSS properties that contain a `{}` block anywhere in their value are now removed. Browsers that support [CSS nesting](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting) reparse an invalid declaration containing a `{}` block as a nested style rule, so a value like `color:hover{position:fixed}` could previously be used to smuggle disallowed properties and URLs past Sanitize in a stylesheet or `<style>` element. (GHSA-wgw9-qhqg-8hv5)
26
+
27
+ - The contents of at-rules such as `@media` and `@supports` that appear in a list of declarations (inside a style rule, inside an at-rule like `@font-face`, or in CSS sanitized with `Sanitize::CSS.properties`) are now sanitized as declarations rather than as rules, matching how browsers that support CSS nesting parse them. Previously, a stylesheet like `p{@media all{position:fixed;a{}}}` could be used to smuggle disallowed properties and URLs past Sanitize, since Sanitize treated `position:fixed;a` as a style rule's selector. The same was true of CSS sanitized with `Sanitize::CSS.properties` if the result was later embedded in a style rule. Style rules nested inside these at-rules are now removed, just as style rules nested directly inside other style rules already were. (GHSA-wgw9-qhqg-8hv5)
28
+
29
+ - CSS properties, style rules, and at-rules that contain blocks or functions nested more deeply than Crass's maximum nesting depth (25 levels by default) are now removed. (GHSA-wgw9-qhqg-8hv5)
30
+
31
+ - Hardened CSS sanitization against tokenizer differentials involving bad URLs. CSS properties that contain a bad URL anywhere inside a simple block (such as `[...]` or `(...)`) are now removed, as are style rules whose selectors contain a bad URL and at-rules whose preludes contain a bad URL. URLs inside simple blocks are now also validated against the configured protocol allowlist. Previously, a bug in Crass (fixed in Crass 1.0.8) could be exploited to smuggle CSS declarations, rules, or at-rules past Sanitize by hiding them in a bad URL in one of these locations.
32
+
33
+ - CSS style rules whose selectors contain a semicolon are now removed, as a defense in depth measure. A semicolon is never valid in a selector, but when a style rule appears where a browser that supports CSS nesting expects declarations, the browser treats everything before the semicolon as a declaration. This could be used to smuggle disallowed properties past `Sanitize::CSS.tree!` when it was given a list of declarations rather than a list of rules (though this isn't something Sanitize itself does).
34
+
35
+ ### Fixed
36
+
37
+ - Removed an unused `require "cgi"` that caused a warning on Ruby 4.0, since the CGI library is no longer part of Ruby's standard library.
38
+
5
39
  ## 7.0.0 (2024-12-29)
6
40
 
7
41
  Sanitize has no breaking API changes in this release, but the major version number has been incremented because we've dropped support for end-of-life versions of Ruby. As long as you're using Ruby 3.1.0 or later, this should be a painless upgrade!
data/README.md CHANGED
@@ -12,10 +12,16 @@ Sanitize is based on the [Nokogiri HTML5 parser][nokogiri], which parses HTML th
12
12
  [crass]:https://github.com/rgrove/crass
13
13
  [nokogiri]:https://github.com/sparklemotion/nokogiri
14
14
 
15
+ > [!WARNING]
16
+ >
17
+ > Sanitize cannot fully sanitize the contents of `<math>` or `<svg>` elements. MathML and SVG elements are [foreign elements](https://html.spec.whatwg.org/multipage/syntax.html#foreign-elements) that don't follow normal HTML parsing rules.
18
+ >
19
+ > By default, Sanitize will remove all MathML and SVG elements. If you add MathML or SVG elements to a custom element allowlist, you may create a security vulnerability in your application.
20
+
15
21
  ## Links
16
22
 
17
23
  * [Home](https://github.com/rgrove/sanitize/)
18
- * [API Docs](https://rubydoc.info/github/rgrove/sanitize/Sanitize)
24
+ * [API Docs](https://rubydoc.info/gems/sanitize/Sanitize)
19
25
  * [Issues](https://github.com/rgrove/sanitize/issues)
20
26
  * [Release History](https://github.com/rgrove/sanitize/releases)
21
27
  * [Online Demo](https://sanitize-web.fly.dev/)
@@ -136,6 +142,9 @@ Sanitize.fragment(html,
136
142
 
137
143
  Sanitize will happily clean up a standalone CSS stylesheet or property string without needing to invoke the HTML parser.
138
144
 
145
+ > [!WARNING]
146
+ > This should only be used to sanitize CSS for use in a standalone CSS file. To safely sanitize CSS for use in an HTML context, such as in a `<style>` element or a `style` attribute, you must use `Sanitize.document()` or `Sanitize.fragment()` with an appropriate allowlist as described in the previous sections. Failure to do so may enable HTML injection attacks.
147
+
139
148
  ```ruby
140
149
  css = %[
141
150
  @import url(evil.css);
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
3
  class Sanitize
6
4
  module Config
7
5
  # Deeply freezes and returns the given configuration Hash.
data/lib/sanitize/css.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "crass"
4
- require "set"
5
4
 
6
5
  class Sanitize
7
6
  class CSS
@@ -66,6 +65,10 @@ class Sanitize
66
65
  # tree = Crass.parse(css)
67
66
  # Sanitize::CSS.tree!(tree, Sanitize::Config::RELAXED)
68
67
  #
68
+ # The tree must be a list of rules, such as the tree returned by
69
+ # `Crass.parse`. To sanitize a list of declarations, such as the contents
70
+ # of an HTML `style` attribute, use {.properties}.
71
+ #
69
72
  # @return [Array] Sanitized Crass CSS parse tree.
70
73
  def self.tree!(tree, config = {})
71
74
  new(config).tree!(tree)
@@ -82,6 +85,11 @@ class Sanitize
82
85
  @at_rules_with_properties = Set.new(@config[:at_rules_with_properties])
83
86
  @at_rules_with_styles = Set.new(@config[:at_rules_with_styles])
84
87
  @import_url_validator = @config[:import_url_validator]
88
+
89
+ @parse_options = {
90
+ preserve_comments: @config[:allow_comments],
91
+ preserve_hacks: @config[:allow_hacks]
92
+ }.freeze
85
93
  end
86
94
 
87
95
  # Sanitizes inline CSS style properties.
@@ -96,11 +104,8 @@ class Sanitize
96
104
  #
97
105
  # @return [String] Sanitized CSS properties.
98
106
  def properties(css)
99
- tree = Crass.parse_properties(css,
100
- preserve_comments: @config[:allow_comments],
101
- preserve_hacks: @config[:allow_hacks])
102
-
103
- tree!(tree)
107
+ tree = Crass.parse_properties(css, @parse_options)
108
+ sanitize_tree!(tree, declarations: true)
104
109
  Crass::Parser.stringify(tree)
105
110
  end
106
111
 
@@ -127,10 +132,7 @@ class Sanitize
127
132
  #
128
133
  # @return [String] Sanitized CSS stylesheet.
129
134
  def stylesheet(css)
130
- tree = Crass.parse(css,
131
- preserve_comments: @config[:allow_comments],
132
- preserve_hacks: @config[:allow_hacks])
133
-
135
+ tree = Crass.parse(css, @parse_options)
134
136
  tree!(tree)
135
137
  Crass::Parser.stringify(tree)
136
138
  end
@@ -155,48 +157,13 @@ class Sanitize
155
157
  #
156
158
  # scss.tree!(tree)
157
159
  #
160
+ # The tree must be a list of rules, such as the tree returned by
161
+ # `Crass.parse`. To sanitize a list of declarations, such as the contents
162
+ # of an HTML `style` attribute, use {#properties}.
163
+ #
158
164
  # @return [Array] Sanitized Crass CSS parse tree.
159
165
  def tree!(tree)
160
- preceded_by_property = false
161
-
162
- tree.map! do |node|
163
- next nil if node.nil?
164
-
165
- case node[:node]
166
- when :at_rule
167
- preceded_by_property = false
168
- next at_rule!(node)
169
-
170
- when :comment
171
- next node if @config[:allow_comments]
172
-
173
- when :property
174
- prop = property!(node)
175
- preceded_by_property = !prop.nil?
176
- next prop
177
-
178
- when :semicolon
179
- # Only preserve the semicolon if it was preceded by an allowlisted
180
- # property. Otherwise, omit it in order to prevent redundant
181
- # semicolons.
182
- if preceded_by_property
183
- preceded_by_property = false
184
- next node
185
- end
186
-
187
- when :style_rule
188
- preceded_by_property = false
189
- tree!(node[:children])
190
- next node
191
-
192
- when :whitespace
193
- next node
194
- end
195
-
196
- nil
197
- end
198
-
199
- tree
166
+ sanitize_tree!(tree, declarations: false)
200
167
  end
201
168
 
202
169
  # -- Protected Instance Methods --------------------------------------------
@@ -204,22 +171,33 @@ class Sanitize
204
171
 
205
172
  # Sanitizes a CSS at-rule node. Returns the sanitized node, or `nil` if the
206
173
  # current config doesn't allow this at-rule.
207
- def at_rule!(rule)
174
+ #
175
+ # If _declarations_ is `true`, the at-rule appears in a list of
176
+ # declarations (such as the contents of a style rule) rather than in a list
177
+ # of rules.
178
+ def at_rule!(rule, declarations:)
208
179
  name = rule[:name].downcase
209
180
 
181
+ return nil if contains_invalid_node?(rule[:prelude])
182
+
210
183
  if @at_rules_with_styles.include?(name)
211
- styles = Crass::Parser.parse_rules(rule[:block],
212
- preserve_comments: @config[:allow_comments],
213
- preserve_hacks: @config[:allow_hacks])
184
+ # Browsers that support CSS nesting parse the block of an at-rule like
185
+ # `@media` that appears in a list of declarations as more declarations
186
+ # rather than as rules, so it must be sanitized as declarations.
187
+ # Otherwise a declaration like `position:fixed;a{}` would be parsed as
188
+ # a style rule with the selector `position:fixed;a` and would bypass
189
+ # the property allowlist.
190
+ styles = if declarations
191
+ Crass::Parser.parse_properties(rule[:block], @parse_options)
192
+ else
193
+ Crass::Parser.parse_rules(rule[:block], @parse_options)
194
+ end
214
195
 
215
- rule[:block] = tree!(styles)
196
+ rule[:block] = sanitize_tree!(styles, declarations: declarations)
216
197
 
217
198
  elsif @at_rules_with_properties.include?(name)
218
- props = Crass::Parser.parse_properties(rule[:block],
219
- preserve_comments: @config[:allow_comments],
220
- preserve_hacks: @config[:allow_hacks])
221
-
222
- rule[:block] = tree!(props)
199
+ props = Crass::Parser.parse_properties(rule[:block], @parse_options)
200
+ rule[:block] = sanitize_tree!(props, declarations: true)
223
201
 
224
202
  elsif @at_rules.include?(name)
225
203
  return nil if name == "import" && !import_url_allowed?(rule)
@@ -231,6 +209,31 @@ class Sanitize
231
209
  rule
232
210
  end
233
211
 
212
+ # Returns `true` if the given array of Crass nodes contains an invalid node
213
+ # (see {#invalid_node?}) at any depth.
214
+ def contains_invalid_node?(nodes)
215
+ contains_node?(nodes) { |node| invalid_node?(node) }
216
+ end
217
+
218
+ # Returns `true` if the given array of Crass nodes contains a node at any
219
+ # depth for which the given block returns a truthy value.
220
+ def contains_node?(nodes)
221
+ return false unless Array === nodes
222
+
223
+ stack = nodes.dup
224
+
225
+ until stack.empty?
226
+ node = stack.pop
227
+ next unless Hash === node
228
+ return true if yield(node)
229
+
230
+ value = node[:value]
231
+ stack.concat(value) if Array === value
232
+ end
233
+
234
+ false
235
+ end
236
+
234
237
  # Returns `true` if the given CSS function name is an image-related function
235
238
  # that may contain image URLs that need to be validated.
236
239
  def image_function?(name)
@@ -250,6 +253,22 @@ class Sanitize
250
253
  @import_url_validator.call(import_url)
251
254
  end
252
255
 
256
+ # Returns `true` if the given Crass node is a `:bad_url` or `:error` node.
257
+ #
258
+ # A bad URL is invalid CSS that a browser will discard, but a parser that
259
+ # disagrees with the browser about where a bad URL ends could be tricked
260
+ # into hiding CSS inside it.
261
+ #
262
+ # An `:error` node is CSS that Crass couldn't or wouldn't parse, such as a
263
+ # block nested more deeply than Crass's maximum nesting depth. Its contents
264
+ # are never inspected, but they're preserved verbatim when the tree is
265
+ # stringified, so a browser would still see them.
266
+ #
267
+ # It's safest to reject any construct that contains either one.
268
+ def invalid_node?(node)
269
+ node[:node] == :bad_url || node[:node] == :error
270
+ end
271
+
253
272
  # Sanitizes a CSS property node. Returns the sanitized node, or `nil` if the
254
273
  # current config doesn't allow this property.
255
274
  def property!(prop)
@@ -266,6 +285,9 @@ class Sanitize
266
285
  combined_value = +""
267
286
 
268
287
  nodes.each do |child|
288
+ # Reject properties that contain an invalid node at any depth.
289
+ return nil if invalid_node?(child)
290
+
269
291
  value = child[:value]
270
292
 
271
293
  case child[:node]
@@ -299,14 +321,86 @@ class Sanitize
299
321
  when :url
300
322
  return nil unless valid_url?(child)
301
323
 
302
- when :bad_url
303
- return nil
324
+ when :simple_block
325
+ # Reject properties that contain a `{}` block at any depth.
326
+ #
327
+ # A `{}` block isn't valid in a standard property value, and browsers
328
+ # that support CSS nesting will reparse an invalid declaration that
329
+ # contains one as a nested style rule (e.g.
330
+ # `color:hover{position:fixed}` becomes the rule
331
+ # `color:hover { position: fixed }`), which would allow disallowed
332
+ # properties to be smuggled past the sanitizer.
333
+ return nil if child[:start] == "{"
334
+
335
+ nodes.concat(value) if Array === value
304
336
  end
305
337
  end
306
338
 
307
339
  prop
308
340
  end
309
341
 
342
+ # Sanitizes the given Crass CSS parse tree and all its children, modifying
343
+ # it in place. See {#tree!}.
344
+ #
345
+ # If _declarations_ is `true`, the tree is a list of declarations (such as
346
+ # the contents of a style rule) rather than a list of rules.
347
+ def sanitize_tree!(tree, declarations:)
348
+ preceded_by_property = false
349
+
350
+ tree.map! do |node|
351
+ next nil if node.nil?
352
+
353
+ case node[:node]
354
+ when :at_rule
355
+ preceded_by_property = false
356
+ next at_rule!(node, declarations: declarations)
357
+
358
+ when :comment
359
+ next node if @config[:allow_comments]
360
+
361
+ when :property
362
+ prop = property!(node)
363
+ preceded_by_property = !prop.nil?
364
+ next prop
365
+
366
+ when :semicolon
367
+ # Only preserve the semicolon if it was preceded by an allowlisted
368
+ # property. Otherwise, omit it in order to prevent redundant
369
+ # semicolons.
370
+ if preceded_by_property
371
+ preceded_by_property = false
372
+ next node
373
+ end
374
+
375
+ when :style_rule
376
+ preceded_by_property = false
377
+
378
+ # Reject style rules whose selectors contain an invalid node (see
379
+ # {#invalid_node?}) or a semicolon at any depth.
380
+ #
381
+ # A semicolon is never valid in a selector, but if a style rule
382
+ # appears where a browser expects declarations (such as the contents
383
+ # of an `@media` rule in a list of declarations), the browser will
384
+ # treat everything before the semicolon as a declaration. For
385
+ # example, `position:fixed;a{}` would be treated as the declaration
386
+ # `position:fixed` followed by the style rule `a{}`.
387
+ next nil if contains_node?(node.dig(:selector, :tokens)) { |token|
388
+ invalid_node?(token) || token[:node] == :semicolon
389
+ }
390
+
391
+ sanitize_tree!(node[:children], declarations: true)
392
+ next node
393
+
394
+ when :whitespace
395
+ next node
396
+ end
397
+
398
+ nil
399
+ end
400
+
401
+ tree
402
+ end
403
+
310
404
  # Returns `true` if the given node (which may be of type `:url` or
311
405
  # `:function`, since the CSS syntax can produce both) uses an allowlisted
312
406
  # protocol.
@@ -2,12 +2,48 @@
2
2
 
3
3
  class Sanitize
4
4
  module Transformers
5
+ # Names of elements whose content is parsed as text rather than markup when
6
+ # they're in the HTML namespace.
7
+ #
8
+ # An element with one of these names can contain markup if it's in a foreign
9
+ # namespace like MathML or SVG. If Sanitize removes the foreign ancestor but
10
+ # keeps the element, its serialized contents will be reparsed as text inside
11
+ # an HTML element, so a closing tag inside a comment or an unescaped raw
12
+ # text element could break out of the element and inject arbitrary markup.
13
+ # To prevent this, Sanitize removes all elements and comments inside these
14
+ # elements, leaving only text.
15
+ TEXT_CONTENT_ELEMENTS = Set.new(%w[
16
+ iframe
17
+ noembed
18
+ noframes
19
+ noscript
20
+ plaintext
21
+ script
22
+ style
23
+ textarea
24
+ title
25
+ xmp
26
+ ]).freeze
27
+
5
28
  CleanComment = lambda do |env|
6
29
  node = env[:node]
7
30
 
8
- if node.type == Nokogiri::XML::Node::COMMENT_NODE
9
- node.unlink unless env[:is_allowlisted]
31
+ return unless node.type == Nokogiri::XML::Node::COMMENT_NODE
32
+
33
+ # Comments inside text content elements are always removed, even if
34
+ # they're allowlisted, since they can never be serialized safely.
35
+ parent = node.parent
36
+
37
+ while parent&.element?
38
+ if TEXT_CONTENT_ELEMENTS.include?(parent.name.downcase)
39
+ node.unlink
40
+ return
41
+ end
42
+
43
+ parent = parent.parent
10
44
  end
45
+
46
+ node.unlink unless env[:config][:allow_comments] || env[:is_allowlisted]
11
47
  end
12
48
  end
13
49
  end
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cgi"
4
- require "set"
5
-
6
3
  class Sanitize
7
4
  module Transformers
8
5
  class CleanElement
6
+ # Maps the namespace URI of each foreign element type supported by HTML to
7
+ # the name of its root element.
8
+ FOREIGN_NAMESPACE_ROOTS = {
9
+ "http://www.w3.org/1998/Math/MathML" => "math",
10
+ "http://www.w3.org/2000/svg" => "svg"
11
+ }.freeze
12
+
9
13
  # Matches a valid HTML5 data attribute name. The unicode ranges included
10
14
  # here are a conservative subset of the full range of characters that are
11
15
  # technically allowed, with the intent of matching the most common
@@ -21,6 +25,14 @@ class Sanitize
21
25
  # https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes
22
26
  REGEX_DATA_ATTR = /\Adata-(?!xml)[a-z_][\w.\u00E0-\u00F6\u00F8-\u017F\u01DD-\u02AF-]*\z/u
23
27
 
28
+ # Maps `<select>` and related elements to the names of the elements that
29
+ # browsers using legacy `<select>` parsing rules allow as their children.
30
+ SELECT_CONTENT_MODEL = {
31
+ "optgroup" => Set["option"].freeze,
32
+ "option" => Set.new.freeze,
33
+ "select" => Set["hr", "optgroup", "option"].freeze
34
+ }.freeze
35
+
24
36
  # Elements whose content is treated as unescaped text by HTML parsers.
25
37
  UNESCAPED_TEXT_ELEMENTS = Set.new(%w[
26
38
  iframe
@@ -91,9 +103,18 @@ class Sanitize
91
103
 
92
104
  def call(env)
93
105
  node = env[:node]
94
- return if node.type != Nokogiri::XML::Node::ELEMENT_NODE || env[:is_allowlisted]
106
+ return if node.type != Nokogiri::XML::Node::ELEMENT_NODE
95
107
 
96
108
  name = env[:node_name]
109
+ parent = node.parent
110
+
111
+ # Some elements are always removed, even if they're allowlisted, since
112
+ # browsers would parse them differently than Nokogiri did.
113
+ force_remove = orphaned_foreign_element?(node, name, parent) ||
114
+ invalid_select_descendant?(name, parent) ||
115
+ text_content_element?(parent)
116
+
117
+ return if env[:is_allowlisted] && !force_remove
97
118
 
98
119
  # Delete any element that isn't in the config allowlist, unless the node
99
120
  # has already been deleted from the document.
@@ -101,7 +122,7 @@ class Sanitize
101
122
  # It's important that we not try to reparent the children of a node that
102
123
  # has already been deleted, since that seems to trigger a memory leak in
103
124
  # Nokogiri.
104
- unless @elements.include?(name) || node.parent.nil?
125
+ if (force_remove || !@elements.include?(name)) && !parent.nil?
105
126
  # Elements like br, div, p, etc. need to be replaced with whitespace
106
127
  # in order to preserve readability.
107
128
  if @whitespace_elements.include?(name)
@@ -246,6 +267,55 @@ class Sanitize
246
267
  node.unlink
247
268
  end
248
269
  end
270
+
271
+ private
272
+
273
+ # Returns `true` if _parent_ is an HTML `<select>`, `<option>`, or
274
+ # `<optgroup>` element and _name_ isn't an element that browsers allow
275
+ # inside it.
276
+ #
277
+ # Browsers that use legacy `<select>` parsing rules ignore the start tags
278
+ # of most elements inside a `<select>` element, including raw text
279
+ # elements like `<style>` and `<xmp>`, so these elements' contents would
280
+ # be parsed as markup. Nokogiri parses these elements the same way, but
281
+ # they can still end up inside a `<select>` element via a `<template>`
282
+ # element or foreign content that Sanitize removes. Since traversal is
283
+ # top-down, removing these elements ensures that only allowed elements
284
+ # and text remain inside `<select>` elements.
285
+ def invalid_select_descendant?(name, parent)
286
+ return false unless parent&.element? && SELECT_CONTENT_MODEL.key?(parent.name.downcase)
287
+ return false if FOREIGN_NAMESPACE_ROOTS.key?(parent.namespace&.href)
288
+
289
+ !SELECT_CONTENT_MODEL[parent.name.downcase].include?(name)
290
+ end
291
+
292
+ # Returns `true` if _node_ is a MathML or SVG element other than a
293
+ # `<math>` or `<svg>` element and its parent isn't a MathML or SVG element.
294
+ #
295
+ # This happens when Sanitize removes a `<math>` or `<svg>` element but
296
+ # keeps its contents. Nokogiri parsed these descendants as foreign
297
+ # elements, but browsers would reparse them as HTML elements, which may
298
+ # be parsed very differently. For example, the contents of a foreign
299
+ # `<textarea>` element are parsed as markup rather than as text. Since
300
+ # traversal is top-down, removing these elements ensures that no foreign
301
+ # element is kept without a foreign root.
302
+ def orphaned_foreign_element?(node, name, parent)
303
+ root_name = FOREIGN_NAMESPACE_ROOTS[node.namespace&.href]
304
+ return false if root_name.nil? || name == root_name
305
+
306
+ !(parent&.element? && FOREIGN_NAMESPACE_ROOTS.key?(parent.namespace&.href))
307
+ end
308
+
309
+ # Returns `true` if _parent_ is a text content element, such as
310
+ # `<textarea>` or `<xmp>`.
311
+ #
312
+ # Browsers parse the contents of these elements as text when they're in
313
+ # the HTML namespace, so any element inside one must have been parsed in
314
+ # a foreign namespace. Since traversal is top-down, removing these
315
+ # elements ensures that only text remains inside text content elements.
316
+ def text_content_element?(parent)
317
+ !!parent&.element? && TEXT_CONTENT_ELEMENTS.include?(parent.name.downcase)
318
+ end
249
319
  end
250
320
  end
251
321
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Sanitize
4
- VERSION = "7.0.0"
4
+ VERSION = "7.0.2"
5
5
  end
data/lib/sanitize.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "nokogiri"
4
- require "set"
5
4
 
6
5
  require_relative "sanitize/version"
7
6
  require_relative "sanitize/config"
@@ -97,7 +96,7 @@ class Sanitize
97
96
  # Default transformers always run at the end of the chain, after any custom
98
97
  # transformers.
99
98
  @transformers << Transformers::CleanElement.new(@config)
100
- @transformers << Transformers::CleanComment unless @config[:allow_comments]
99
+ @transformers << Transformers::CleanComment
101
100
 
102
101
  if @config[:elements].include?("style")
103
102
  scss = Sanitize::CSS.new(config)