css_parser 1.2.2 → 3.0.0

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.
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+
1
5
  module CssParser
2
6
  # Exception class used for any errors encountered while downloading remote files.
3
7
  class RemoteFileError < IOError; end
@@ -5,7 +9,6 @@ module CssParser
5
9
  # Exception class used if a request is made to load a CSS file more than once.
6
10
  class CircularReferenceError < StandardError; end
7
11
 
8
-
9
12
  # == Parser class
10
13
  #
11
14
  # All CSS is converted to UTF-8.
@@ -14,37 +17,46 @@ module CssParser
14
17
  # [<tt>absolute_paths</tt>] Convert relative paths to absolute paths (<tt>href</tt>, <tt>src</tt> and <tt>url('')</tt>. Boolean, default is <tt>false</tt>.
15
18
  # [<tt>import</tt>] Follow <tt>@import</tt> rules. Boolean, default is <tt>true</tt>.
16
19
  # [<tt>io_exceptions</tt>] Throw an exception if a link can not be found. Boolean, default is <tt>true</tt>.
20
+ # [<tt>allow_local_network</tt>] Permit http(s) fetches against loopback / private / link-local / cloud-metadata addresses. Boolean, default is <tt>false</tt>. When <tt>false</tt> (the default), outbound HTTP requests are routed through <tt>ssrf_filter</tt>, which resolves the host and rejects unsafe IP ranges. Set to <tt>true</tt> only when the destination is known to be safe (e.g. local fixture servers in tests). Independent of <tt>allow_file_uris</tt>.
21
+ # [<tt>allow_file_uris</tt>] Permit <tt>file://</tt> URIs via <tt>load_uri!</tt>. Boolean, default is <tt>false</tt>. When <tt>false</tt> (the default), a caller that passes a <tt>file://</tt> URI to <tt>load_uri!</tt> — directly or via a CSS <tt>@import</tt> resolved against a <tt>file://</tt> base_uri — is refused, closing the local-file-disclosure vector when the URI is influenced by user input. <tt>load_file!</tt> is unaffected: it is the explicit local-file API and takes a caller-supplied path. Independent of <tt>allow_local_network</tt>.
17
22
  class Parser
18
- USER_AGENT = "Ruby CSS Parser/#{CssParser::VERSION} (http://github.com/alexdunae/css_parser)"
19
-
20
- STRIP_CSS_COMMENTS_RX = /\/\*.*?\*\//m
21
- STRIP_HTML_COMMENTS_RX = /\<\!\-\-|\-\-\>/m
23
+ USER_AGENT = "Ruby CSS Parser/#{CssParser::VERSION} (https://github.com/premailer/css_parser)".freeze
24
+ RULESET_TOKENIZER_RX = /\s+|\\{2,}|\\?[{}\s"]|[()]|.[^\s"{}()\\]*/.freeze
25
+ STRIP_CSS_COMMENTS_RX = %r{/\*.*?\*/}m.freeze
26
+ STRIP_HTML_COMMENTS_RX = /<!--|-->/m.freeze
22
27
 
23
28
  # Initial parsing
24
- RE_AT_IMPORT_RULE = /\@import\s*(?:url\s*)?(?:\()?(?:\s*)["']?([^'"\s\)]*)["']?\)?([\w\s\,^\])]*)\)?[;\n]?/
29
+ RE_AT_IMPORT_RULE = /@import\s*(?:url\s*)?(?:\()?(?:\s*)["']?([^'"\s)]*)["']?\)?([\w\s,^\]()]*)\)?[;\n]?/.freeze
25
30
 
26
- # Array of CSS files that have been loaded.
27
- attr_reader :loaded_uris
31
+ MAX_REDIRECTS = 3
28
32
 
29
- #attr_reader :rules
33
+ # Schemes accepted by `read_remote_file`. `file://` is intentionally
34
+ # NOT in this list — local files are handled directly by `load_uri!`
35
+ # and `load_file!`. Keeping `file://` out of the remote read path
36
+ # closes the cross-scheme redirect (HTTP 3xx → `file://`) vector that
37
+ # was GHSA-9pmc-p236-855h.
38
+ REMOTE_ALLOWED_SCHEMES = %w[http https].freeze
30
39
 
31
- #--
32
- # Class variable? see http://www.oreillynet.com/ruby/blog/2007/01/nubygems_dont_use_class_variab_1.html
33
- #++
34
- @folded_declaration_cache = {}
35
- class << self; attr_reader :folded_declaration_cache; end
40
+ # Array of CSS files that have been loaded.
41
+ attr_reader :loaded_uris
36
42
 
37
43
  def initialize(options = {})
38
- @options = {:absolute_paths => false,
39
- :import => true,
40
- :io_exceptions => true}.merge(options)
44
+ @options = {
45
+ absolute_paths: false,
46
+ import: true,
47
+ io_exceptions: true,
48
+ rule_set_exceptions: true,
49
+ capture_offsets: false,
50
+ user_agent: USER_AGENT,
51
+ allow_local_network: false,
52
+ allow_file_uris: false
53
+ }.merge(options)
41
54
 
42
55
  # array of RuleSets
43
56
  @rules = []
44
-
45
-
57
+
46
58
  @loaded_uris = []
47
-
59
+
48
60
  # unprocessed blocks of CSS
49
61
  @blocks = []
50
62
  reset!
@@ -68,13 +80,28 @@ module CssParser
68
80
  # Returns an array of declarations.
69
81
  def find_by_selector(selector, media_types = :all)
70
82
  out = []
71
- each_selector(media_types) do |sel, dec, spec|
83
+ each_selector(media_types) do |sel, dec, _spec|
72
84
  out << dec if sel.strip == selector.strip
73
85
  end
74
86
  out
75
87
  end
76
- alias_method :[], :find_by_selector
88
+ alias [] find_by_selector
89
+
90
+ # Finds the rule sets that match the given selectors
91
+ def find_rule_sets(selectors, media_types = :all)
92
+ rule_sets = []
93
+
94
+ selectors.each do |selector|
95
+ selector = selector.gsub(/\s+/, ' ').strip
96
+ each_rule_set(media_types) do |rule_set, _media_type|
97
+ if !rule_sets.member?(rule_set) && rule_set.selectors.member?(selector)
98
+ rule_sets << rule_set
99
+ end
100
+ end
101
+ end
77
102
 
103
+ rule_sets
104
+ end
78
105
 
79
106
  # Add a raw block of CSS.
80
107
  #
@@ -97,192 +124,368 @@ module CssParser
97
124
  # parser = CssParser::Parser.new
98
125
  # parser.add_block!(css)
99
126
  def add_block!(block, options = {})
100
- options = {:base_uri => nil, :base_dir => nil, :charset => nil, :media_types => :all, :only_media_types => :all}.merge(options)
101
- options[:media_types] = [options[:media_types]].flatten
102
- options[:only_media_types] = [options[:only_media_types]].flatten
127
+ options = {base_uri: nil, base_dir: nil, charset: nil, media_types: :all, only_media_types: :all}.merge(options)
128
+ options[:media_types] = [options[:media_types]].flatten.collect { |mt| CssParser.sanitize_media_query(mt) }
129
+ options[:only_media_types] = [options[:only_media_types]].flatten.collect { |mt| CssParser.sanitize_media_query(mt) }
103
130
 
104
- block = cleanup_block(block)
131
+ block = cleanup_block(block, options)
105
132
 
106
133
  if options[:base_uri] and @options[:absolute_paths]
107
134
  block = CssParser.convert_uris(block, options[:base_uri])
108
135
  end
109
136
 
110
137
  # Load @imported CSS
111
- block.scan(RE_AT_IMPORT_RULE).each do |import_rule|
112
- media_types = []
113
- if media_string = import_rule[-1]
114
- media_string.split(/\s|\,/).each do |t|
115
- media_types << t.to_sym unless t.empty?
138
+ if @options[:import]
139
+ block.scan(RE_AT_IMPORT_RULE).each do |import_rule|
140
+ media_types = []
141
+ if (media_string = import_rule[-1])
142
+ media_string.split(',').each do |t|
143
+ media_types << CssParser.sanitize_media_query(t) unless t.empty?
144
+ end
145
+ else
146
+ media_types = [:all]
116
147
  end
117
- end
118
148
 
119
- next unless options[:only_media_types].include?(:all) or media_types.length < 1 or (media_types & options[:only_media_types]).length > 0
149
+ next unless options[:only_media_types].include?(:all) or media_types.empty? or media_types.intersect?(options[:only_media_types])
120
150
 
121
- import_path = import_rule[0].to_s.gsub(/['"]*/, '').strip
151
+ import_path = import_rule[0].to_s.gsub(/['"]*/, '').strip
122
152
 
123
- if options[:base_uri]
124
- import_uri = Addressable::URI.parse(options[:base_uri].to_s) + Addressable::URI.parse(import_path)
125
- load_uri!(import_uri, options[:base_uri], media_types)
126
- elsif options[:base_dir]
127
- load_file!(import_path, options[:base_dir], media_types)
128
- end
153
+ import_options = {media_types: media_types}
154
+ import_options[:capture_offsets] = true if options[:capture_offsets]
155
+
156
+ if options[:base_uri]
157
+ import_uri = Addressable::URI.parse(options[:base_uri].to_s) + Addressable::URI.parse(import_path)
158
+ import_options[:base_uri] = options[:base_uri]
159
+ load_uri!(import_uri, import_options)
160
+ elsif options[:base_dir]
161
+ import_options[:base_dir] = options[:base_dir]
162
+ load_file!(import_path, import_options)
163
+ end
164
+ end
129
165
  end
130
166
 
131
167
  # Remove @import declarations
132
- block.gsub!(RE_AT_IMPORT_RULE, '')
133
-
168
+ block = ignore_pattern(block, RE_AT_IMPORT_RULE, options)
169
+
134
170
  parse_block_into_rule_sets!(block, options)
135
171
  end
136
172
 
137
- # Add a CSS rule by setting the +selectors+, +declarations+ and +media_types+.
173
+ # Add a CSS rule by setting the +selectors+, +declarations+
174
+ # and +media_types+. Optional pass +filename+ , +offset+ for source
175
+ # reference too.
176
+ #
177
+ # +media_types+ can be a symbol or an array of symbols. default to :all
178
+ # optional fields for source location for source location
179
+ # +filename+ can be a string or uri pointing to the file or url location.
180
+ # +offset+ should be Range object representing the start and end byte locations where the rule was found in the file.
181
+ def add_rule!(*args, selectors: nil, block: nil, filename: nil, offset: nil, media_types: :all) # rubocop:disable Metrics/ParameterLists
182
+ if args.any?
183
+ media_types = nil
184
+ if selectors || block || filename || offset || media_types
185
+ raise ArgumentError, "don't mix positional and keyword arguments arguments"
186
+ end
187
+
188
+ warn '[DEPRECATION] `add_rule!` with positional arguments is deprecated. ' \
189
+ 'Please use keyword arguments instead.', uplevel: 1
190
+
191
+ case args.length
192
+ when 2
193
+ selectors, block = args
194
+ when 3
195
+ selectors, block, media_types = args
196
+ else
197
+ raise ArgumentError
198
+ end
199
+ end
200
+
201
+ begin
202
+ rule_set = RuleSet.new(
203
+ selectors: selectors, block: block,
204
+ offset: offset, filename: filename
205
+ )
206
+
207
+ add_rule_set!(rule_set, media_types)
208
+ rescue ArgumentError => e
209
+ raise e if @options[:rule_set_exceptions]
210
+ end
211
+ end
212
+
213
+ # Add a CSS rule by setting the +selectors+, +declarations+, +filename+, +offset+ and +media_types+.
138
214
  #
215
+ # +filename+ can be a string or uri pointing to the file or url location.
216
+ # +offset+ should be Range object representing the start and end byte locations where the rule was found in the file.
139
217
  # +media_types+ can be a symbol or an array of symbols.
140
- def add_rule!(selectors, declarations, media_types = :all)
141
- rule_set = RuleSet.new(selectors, declarations)
142
- add_rule_set!(rule_set, media_types)
218
+ def add_rule_with_offsets!(selectors, declarations, filename, offset, media_types = :all)
219
+ warn '[DEPRECATION] `add_rule_with_offsets!` is deprecated. Please use `add_rule!` instead.', uplevel: 1
220
+ add_rule!(
221
+ selectors: selectors, block: declarations, media_types: media_types,
222
+ filename: filename, offset: offset
223
+ )
143
224
  end
144
225
 
145
226
  # Add a CssParser RuleSet object.
146
227
  #
147
228
  # +media_types+ can be a symbol or an array of symbols.
148
229
  def add_rule_set!(ruleset, media_types = :all)
149
- raise ArgumentError unless ruleset.kind_of?(CssParser::RuleSet)
230
+ raise ArgumentError unless ruleset.is_a?(CssParser::RuleSet)
150
231
 
151
- media_types = [media_types] if media_types.kind_of?(Symbol)
232
+ media_types = [media_types] unless media_types.is_a?(Array)
233
+ media_types = media_types.flat_map { |mt| CssParser.sanitize_media_query(mt) }
152
234
 
153
- @rules << {:media_types => media_types, :rules => ruleset}
235
+ @rules << {media_types: media_types, rules: ruleset}
236
+ end
237
+
238
+ # Remove a CssParser RuleSet object.
239
+ #
240
+ # +media_types+ can be a symbol or an array of symbols.
241
+ def remove_rule_set!(ruleset, media_types = :all)
242
+ raise ArgumentError unless ruleset.is_a?(CssParser::RuleSet)
243
+
244
+ media_types = [media_types].flatten.collect { |mt| CssParser.sanitize_media_query(mt) }
245
+
246
+ @rules.reject! do |rule|
247
+ rule[:media_types] == media_types && rule[:rules].to_s == ruleset.to_s
248
+ end
154
249
  end
155
250
 
156
251
  # Iterate through RuleSet objects.
157
252
  #
158
253
  # +media_types+ can be a symbol or an array of symbols.
159
- def each_rule_set(media_types = :all) # :yields: rule_set
254
+ def each_rule_set(media_types = :all) # :yields: rule_set, media_types
160
255
  media_types = [:all] if media_types.nil?
161
- media_types = [media_types] if media_types.kind_of?(Symbol)
256
+ media_types = [media_types].flatten.collect { |mt| CssParser.sanitize_media_query(mt) }
162
257
 
163
258
  @rules.each do |block|
164
259
  if media_types.include?(:all) or block[:media_types].any? { |mt| media_types.include?(mt) }
165
- yield block[:rules]
260
+ yield(block[:rules], block[:media_types])
261
+ end
262
+ end
263
+ end
264
+
265
+ # Output all CSS rules as a Hash
266
+ def to_h(which_media = :all)
267
+ out = {}
268
+ styles_by_media_types = {}
269
+ each_selector(which_media) do |selectors, declarations, _specificity, media_types|
270
+ media_types.each do |media_type|
271
+ styles_by_media_types[media_type] ||= []
272
+ styles_by_media_types[media_type] << [selectors, declarations]
166
273
  end
167
274
  end
275
+
276
+ styles_by_media_types.each_pair do |media_type, media_styles|
277
+ ms = {}
278
+ media_styles.each do |media_style|
279
+ ms = css_node_to_h(ms, media_style[0], media_style[1])
280
+ end
281
+ out[media_type.to_s] = ms
282
+ end
283
+ out
168
284
  end
169
285
 
170
286
  # Iterate through CSS selectors.
171
287
  #
172
288
  # +media_types+ can be a symbol or an array of symbols.
173
289
  # See RuleSet#each_selector for +options+.
174
- def each_selector(media_types = :all, options = {}) # :yields: selectors, declarations, specificity
175
- each_rule_set(media_types) do |rule_set|
176
- #puts rule_set
290
+ def each_selector(all_media_types = :all, options = {}) # :yields: selectors, declarations, specificity, media_types
291
+ return to_enum(__method__, all_media_types, options) unless block_given?
292
+
293
+ each_rule_set(all_media_types) do |rule_set, media_types|
177
294
  rule_set.each_selector(options) do |selectors, declarations, specificity|
178
- yield selectors, declarations, specificity
295
+ yield selectors, declarations, specificity, media_types
179
296
  end
180
297
  end
181
298
  end
182
299
 
183
300
  # Output all CSS rules as a single stylesheet.
184
- def to_s(media_types = :all)
185
- out = ''
186
- each_selector(media_types) do |selectors, declarations, specificity|
187
- out << "#{selectors} {\n#{declarations}\n}\n"
301
+ def to_s(which_media = :all)
302
+ out = []
303
+ styles_by_media_types = {}
304
+
305
+ each_selector(which_media) do |selectors, declarations, _specificity, media_types|
306
+ media_types.each do |media_type|
307
+ styles_by_media_types[media_type] ||= []
308
+ styles_by_media_types[media_type] << [selectors, declarations]
309
+ end
188
310
  end
189
- out
311
+
312
+ styles_by_media_types.each_pair do |media_type, media_styles|
313
+ media_block = (media_type != :all)
314
+ out << "@media #{media_type} {" if media_block
315
+
316
+ media_styles.each do |media_style|
317
+ if media_block
318
+ out.push(" #{media_style[0]} {\n #{media_style[1]}\n }")
319
+ else
320
+ out.push("#{media_style[0]} {\n#{media_style[1]}\n}")
321
+ end
322
+ end
323
+
324
+ out << '}' if media_block
325
+ end
326
+
327
+ out << ''
328
+ out.join("\n")
329
+ end
330
+
331
+ # A hash of { :media_query => rule_sets }
332
+ def rules_by_media_query
333
+ rules_by_media = {}
334
+ @rules.each do |block|
335
+ block[:media_types].each do |mt|
336
+ unless rules_by_media.key?(mt)
337
+ rules_by_media[mt] = []
338
+ end
339
+ rules_by_media[mt] << block[:rules]
340
+ end
341
+ end
342
+
343
+ rules_by_media
190
344
  end
191
345
 
192
346
  # Merge declarations with the same selector.
193
347
  def compact! # :nodoc:
194
- compacted = []
195
-
196
- compacted
348
+ []
197
349
  end
198
350
 
199
351
  def parse_block_into_rule_sets!(block, options = {}) # :nodoc:
200
- options = {:media_types => :all}.merge(options)
201
- media_types = options[:media_types]
352
+ current_media_queries = [:all]
353
+ if options[:media_types]
354
+ current_media_queries = options[:media_types].flatten.collect { |mt| CssParser.sanitize_media_query(mt) }
355
+ end
202
356
 
203
357
  in_declarations = 0
204
-
205
358
  block_depth = 0
206
359
 
207
- # @charset is ignored for now
208
- in_charset = false
360
+ in_charset = false # @charset is ignored for now
209
361
  in_string = false
210
362
  in_at_media_rule = false
363
+ in_media_block = false
364
+
365
+ current_selectors = +''
366
+ current_media_query = +''
367
+ current_declarations = +''
211
368
 
212
- current_selectors = ''
213
- current_declarations = ''
369
+ # once we are in a rule, we will use this to store where we started if we are capturing offsets
370
+ rule_start = nil
371
+ start_offset = nil
372
+ end_offset = nil
214
373
 
215
- block.scan(/([\\]?[{}\s"]|(.[^\s"{}\\]*))/).each do |matches|
216
- #block.scan(/((.[^{}"\n\r\f\s]*)[\s]|(.[^{}"\n\r\f]*)\{|(.[^{}"\n\r\f]*)\}|(.[^{}"\n\r\f]*)\"|(.*)[\s]+)/).each do |matches|
217
- token = matches[0]
374
+ scanner = StringScanner.new(block)
375
+ until scanner.eos?
376
+ # save the regex offset so that we know where in the file we are
377
+ start_offset = scanner.pos
378
+ token = scanner.scan(RULESET_TOKENIZER_RX)
379
+ end_offset = scanner.pos
218
380
 
219
- #puts "TOKEN: #{token}" unless token =~ /^[\s]*$/
220
- if token =~ /\A"/ # found un-escaped double quote
381
+ if token.start_with?('"') # found un-escaped double quote
221
382
  in_string = !in_string
222
- end
383
+ end
223
384
 
224
385
  if in_declarations > 0
225
-
226
386
  # too deep, malformed declaration block
227
387
  if in_declarations > 1
228
- in_declarations -= 1 if token =~ /\}/
388
+ in_declarations -= 1 if token.include?('}')
229
389
  next
230
390
  end
231
-
232
- if token =~ /\{/
391
+
392
+ if !in_string && token.include?('{')
233
393
  in_declarations += 1
234
394
  next
235
395
  end
236
-
237
- current_declarations += token
238
396
 
239
- if token =~ /\}/ and not in_string
240
- current_declarations.gsub!(/\}[\s]*$/, '')
241
-
242
- in_declarations -= 1
397
+ current_declarations << token
243
398
 
244
- unless current_declarations.strip.empty?
245
- #puts "SAVING #{current_selectors} -> #{current_declarations}"
246
- add_rule!(current_selectors, current_declarations, media_types)
399
+ if !in_string && token.include?('}')
400
+ current_declarations.gsub!(/\}\s*$/, '')
401
+
402
+ in_declarations -= 1
403
+ current_declarations.strip!
404
+
405
+ unless current_declarations.empty?
406
+ add_rule_options = {
407
+ selectors: current_selectors, block: current_declarations,
408
+ media_types: current_media_queries
409
+ }
410
+ if options[:capture_offsets]
411
+ add_rule_options[:filename] = options[:filename]
412
+ add_rule_options[:offset] = rule_start..end_offset
413
+ end
414
+ add_rule!(**add_rule_options)
247
415
  end
248
416
 
249
- current_selectors = ''
250
- current_declarations = ''
417
+ current_selectors = +''
418
+ current_declarations = +''
419
+
420
+ # restart our search for selectors and declarations
421
+ rule_start = nil if options[:capture_offsets]
251
422
  end
252
- elsif token =~ /@media/i
423
+ elsif /@media/i.match?(token)
253
424
  # found '@media', reset current media_types
254
425
  in_at_media_rule = true
255
- media_types = []
426
+ current_media_queries = []
256
427
  elsif in_at_media_rule
257
- if token =~ /\{/
258
- block_depth = block_depth + 1
428
+ if token.include?('{')
429
+ block_depth += 1
259
430
  in_at_media_rule = false
431
+ in_media_block = true
432
+ current_media_queries << CssParser.sanitize_media_query(current_media_query)
433
+ current_media_query = +''
434
+ elsif token.include?(',')
435
+ # new media query begins
436
+ token.tr!(',', ' ')
437
+ token.strip!
438
+ current_media_query << token << ' '
439
+ current_media_queries << CssParser.sanitize_media_query(current_media_query)
440
+ current_media_query = +''
260
441
  else
261
- token.gsub!(/[,\s]*/, '')
262
- media_types << token.strip.downcase.to_sym unless token.empty?
263
- end
264
- elsif in_charset or token =~ /@charset/i
265
- # iterate until we are out of the charset declaration
266
- in_charset = (token =~ /;/ ? false : true)
267
- else
268
- if token =~ /\}/ and not in_string
269
- block_depth = block_depth - 1
270
- else
271
- if token =~ /\{/ and not in_string
272
- current_selectors.gsub!(/^[\s]*/, '')
273
- current_selectors.gsub!(/[\s]*$/, '')
274
- in_declarations += 1
442
+ token.strip!
443
+ # special-case the ( and ) tokens to remove inner-whitespace
444
+ # (eg we'd prefer '(width: 500px)' to '( width: 500px )' )
445
+ case token
446
+ when '('
447
+ current_media_query << token
448
+ when ')'
449
+ current_media_query.sub!(/ ?$/, token)
275
450
  else
276
- current_selectors += token
451
+ current_media_query << token << ' '
277
452
  end
278
453
  end
454
+ elsif in_charset or /@charset/i.match?(token)
455
+ # iterate until we are out of the charset declaration
456
+ in_charset = !token.include?(';')
457
+ elsif !in_string && token.include?('}')
458
+ block_depth -= 1
459
+
460
+ # reset the current media query scope
461
+ if in_media_block
462
+ current_media_queries = [:all]
463
+ in_media_block = false
464
+ end
465
+ elsif !in_string && token.include?('{')
466
+ current_selectors.strip!
467
+ in_declarations += 1
468
+ else
469
+ # if we are in a selector, add the token to the current selectors
470
+ current_selectors << token
471
+
472
+ # mark this as the beginning of the selector unless we have already marked it
473
+ rule_start = start_offset if options[:capture_offsets] && rule_start.nil? && /^[^\s]+$/.match?(token)
279
474
  end
280
475
  end
281
476
 
282
- # check for unclosed braces
283
- if in_declarations > 0
284
- add_rule!(current_selectors, current_declarations, media_types)
285
- end
477
+ # check for unclosed braces
478
+ return unless in_declarations > 0
479
+
480
+ add_rule_options = {
481
+ selectors: current_selectors, block: current_declarations,
482
+ media_types: current_media_queries
483
+ }
484
+ if options[:capture_offsets]
485
+ add_rule_options[:filename] = options[:filename]
486
+ add_rule_options[:offset] = rule_start..end_offset
487
+ end
488
+ add_rule!(**add_rule_options)
286
489
  end
287
490
 
288
491
  # Load a remote CSS file.
@@ -294,18 +497,18 @@ module CssParser
294
497
  # Deprecated: originally accepted three params: `uri`, `base_uri` and `media_types`
295
498
  def load_uri!(uri, options = {}, deprecated = nil)
296
499
  uri = Addressable::URI.parse(uri) unless uri.respond_to? :scheme
297
- #base_uri = nil, media_types = :all, options = {}
298
500
 
299
- opts = {:base_uri => nil, :media_types => :all}
501
+ opts = {base_uri: nil, media_types: :all}
300
502
 
301
503
  if options.is_a? Hash
302
504
  opts.merge!(options)
303
505
  else
506
+ warn '[DEPRECATION] `load_uri!` with positional arguments is deprecated. ' \
507
+ 'Please use keyword arguments instead.', uplevel: 1
304
508
  opts[:base_uri] = options if options.is_a? String
305
509
  opts[:media_types] = deprecated if deprecated
306
510
  end
307
-
308
-
511
+
309
512
  if uri.scheme == 'file' or uri.scheme.nil?
310
513
  uri.path = File.expand_path(uri.path)
311
514
  uri.scheme = 'file'
@@ -313,121 +516,246 @@ module CssParser
313
516
 
314
517
  opts[:base_uri] = uri if opts[:base_uri].nil?
315
518
 
316
- src, charset = read_remote_file(uri)
519
+ # pass on the uri if we are capturing file offsets
520
+ opts[:filename] = uri.to_s if opts[:capture_offsets]
521
+
522
+ # file:// is handled here, not inside read_remote_file. The
523
+ # remote-read path must never service file:// URIs, so a 3xx
524
+ # `Location: file://...` redirect cannot be turned into a local
525
+ # File.read.
526
+ #
527
+ # file:// via `load_uri!` is also gated by `allow_file_uris`:
528
+ # an attacker who can influence a URI passed here (e.g. via a CSS
529
+ # @import resolved against an attacker-controlled base_uri) could
530
+ # otherwise turn it into arbitrary local file disclosure. Callers
531
+ # that legitimately need to load local files should use
532
+ # `load_file!` (the explicit local-file API).
533
+ src = if uri.scheme == 'file'
534
+ unless @options[:allow_file_uris]
535
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
536
+
537
+ return
538
+ end
539
+ read_local_file(uri)
540
+ else
541
+ src_and_charset, = read_remote_file(uri) # skip charset
542
+ src_and_charset
543
+ end
317
544
 
318
- if src
319
- add_block!(src, opts)
320
- end
545
+ add_block!(src, opts) if src
321
546
  end
322
-
547
+
323
548
  # Load a local CSS file.
324
- def load_file!(file_name, base_dir = nil, media_types = :all)
325
- file_name = File.expand_path(file_name, base_dir)
549
+ def load_file!(file_name, options = {}, deprecated = nil)
550
+ opts = {base_dir: nil, media_types: :all}
551
+
552
+ if options.is_a? Hash
553
+ opts.merge!(options)
554
+ else
555
+ warn '[DEPRECATION] `load_file!` with positional arguments is deprecated. ' \
556
+ 'Please use keyword arguments instead.', uplevel: 1
557
+ opts[:base_dir] = options if options.is_a? String
558
+ opts[:media_types] = deprecated if deprecated
559
+ end
560
+
561
+ file_name = File.expand_path(file_name, opts[:base_dir])
326
562
  return unless File.readable?(file_name)
327
563
  return unless circular_reference_check(file_name)
328
564
 
329
- src = IO.read(file_name)
330
- base_dir = File.dirname(file_name)
565
+ src = File.read(file_name)
566
+
567
+ opts[:filename] = file_name if opts[:capture_offsets]
568
+ opts[:base_dir] = File.dirname(file_name)
569
+
570
+ add_block!(src, opts)
571
+ end
572
+
573
+ # Load a local CSS string.
574
+ def load_string!(src, options = {}, deprecated = nil)
575
+ opts = {base_dir: nil, media_types: :all}
576
+
577
+ if options.is_a? Hash
578
+ opts.merge!(options)
579
+ else
580
+ warn '[DEPRECATION] `load_file!` with positional arguments is deprecated. ' \
581
+ 'Please use keyword arguments instead.', uplevel: 1
582
+ opts[:base_dir] = options if options.is_a? String
583
+ opts[:media_types] = deprecated if deprecated
584
+ end
331
585
 
332
- add_block!(src, {:media_types => media_types, :base_dir => base_dir})
586
+ add_block!(src, opts)
333
587
  end
334
-
335
-
336
588
 
337
589
  protected
590
+
338
591
  # Check that a path hasn't been loaded already
339
592
  #
340
- # Raises a CircularReferenceError exception if io_exceptions are on,
593
+ # Raises a CircularReferenceError exception if io_exceptions are on,
341
594
  # otherwise returns true/false.
342
- def circular_reference_check(path)
595
+ # TODO: fix rubocop
596
+ def circular_reference_check(path) # rubocop:disable Naming/PredicateMethod
343
597
  path = path.to_s
344
598
  if @loaded_uris.include?(path)
345
599
  raise CircularReferenceError, "can't load #{path} more than once" if @options[:io_exceptions]
346
- return false
600
+
601
+ false
347
602
  else
348
603
  @loaded_uris << path
349
- return true
604
+ true
350
605
  end
351
606
  end
352
-
607
+
608
+ # Remove a pattern from a given string
609
+ #
610
+ # Returns a string.
611
+ def ignore_pattern(css, regex, options)
612
+ # if we are capturing file offsets, replace the characters with spaces to retail the original positions
613
+ return css.gsub(regex) { |m| ' ' * m.length } if options[:capture_offsets]
614
+
615
+ # otherwise just strip it out
616
+ css.gsub(regex, '')
617
+ end
618
+
353
619
  # Strip comments and clean up blank lines from a block of CSS.
354
620
  #
355
621
  # Returns a string.
356
- def cleanup_block(block) # :nodoc:
622
+ def cleanup_block(block, options = {}) # :nodoc:
357
623
  # Strip CSS comments
358
- block.gsub!(STRIP_CSS_COMMENTS_RX, '')
624
+ utf8_block = block.encode('UTF-8', 'UTF-8', invalid: :replace, undef: :replace, replace: ' ')
625
+ utf8_block = ignore_pattern(utf8_block, STRIP_CSS_COMMENTS_RX, options)
359
626
 
360
- # Strip HTML comments - they shouldn't really be in here but
627
+ # Strip HTML comments - they shouldn't really be in here but
361
628
  # some people are just crazy...
362
- block.gsub!(STRIP_HTML_COMMENTS_RX, '')
629
+ utf8_block = ignore_pattern(utf8_block, STRIP_HTML_COMMENTS_RX, options)
363
630
 
364
631
  # Strip lines containing just whitespace
365
- block.gsub!(/^\s+$/, "")
632
+ utf8_block.gsub!(/^\s+$/, '') unless options[:capture_offsets]
633
+
634
+ utf8_block
635
+ end
636
+
637
+ # Read a local file:// URI. Called only from `load_uri!` — never
638
+ # from the remote read path — so an HTTP redirect cannot reach this
639
+ # branch (GHSA-9pmc-p236-855h).
640
+ def read_local_file(uri) # :nodoc:
641
+ # Internal invariant: this method is the implementation of the
642
+ # `allow_file_uris: true` branch of `load_uri!`. If it is ever
643
+ # reached without that flag set, a future change has bypassed the
644
+ # LFI gate; refuse to read rather than silently leak.
645
+ unless @options[:allow_file_uris]
646
+ raise "BUG: #{self.class}##{__method__} reached with " \
647
+ 'allow_file_uris=false (LFI gate bypassed)'
648
+ end
649
+
650
+ return nil unless circular_reference_check(uri.to_s)
651
+
652
+ path = uri.path
653
+ path.gsub!(%r{^/}, '') if Gem.win_platform?
654
+ File.read(path, mode: 'rb')
655
+ rescue
656
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
366
657
 
367
- block
658
+ nil
368
659
  end
369
660
 
370
- # Download a file into a string.
661
+ # Download a remote http(s) file into a string.
371
662
  #
372
663
  # Returns the file's data and character set in an array.
664
+ #
665
+ # In the default (secure) configuration, requests are issued via
666
+ # `SsrfFilter.get`, which:
667
+ # - rejects any scheme other than http/https (defeats redirect-to-
668
+ # `file://` / `gopher://` / `dict://` etc.);
669
+ # - resolves the hostname with `Resolv` and rejects requests whose
670
+ # resolved IP is loopback, RFC-1918, link-local, multicast, or any
671
+ # other range typically used for internal services (defeats SSRF
672
+ # via literal IPs and via CNAME / attacker-controlled A records);
673
+ # - re-validates scheme and IP on every redirect hop.
674
+ #
675
+ # When `allow_local_network: true` is set on the Parser, the SSRF
676
+ # check is bypassed and plain `Net::HTTP` is used — but the scheme
677
+ # is still validated on every redirect hop, so cross-scheme
678
+ # redirect to `file://` (the original GHSA-9pmc-p236-855h sink)
679
+ # remains closed even on this opt-in path.
373
680
  #--
374
681
  # TODO: add option to fail silently or throw and exception on a 404
375
682
  #++
376
683
  def read_remote_file(uri) # :nodoc:
377
- return nil, nil unless circular_reference_check(uri.to_s)
684
+ uri = Addressable::URI.parse(uri.to_s)
378
685
 
379
- src = '', charset = nil
686
+ unless circular_reference_check(uri.to_s)
687
+ return nil, nil
688
+ end
689
+
690
+ unless REMOTE_ALLOWED_SCHEMES.include?(uri.scheme)
691
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
692
+
693
+ return nil, nil
694
+ end
380
695
 
381
696
  begin
382
- uri = Addressable::URI.parse(uri.to_s)
697
+ res = if @options[:allow_local_network]
698
+ fetch_via_net_http(uri)
699
+ else
700
+ SsrfFilter.get(
701
+ uri.to_s,
702
+ scheme_whitelist: REMOTE_ALLOWED_SCHEMES,
703
+ max_redirects: MAX_REDIRECTS,
704
+ headers: {'User-Agent' => @options[:user_agent]}
705
+ )
706
+ end
707
+
708
+ if res.code.to_i >= 400
709
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
710
+
711
+ return '', nil
712
+ end
383
713
 
384
- if uri.scheme == 'file'
385
- # local file
386
- fh = open(uri.path, 'rb')
387
- src = fh.read
388
- fh.close
389
- else
390
- # remote file
391
- if uri.scheme == 'https'
392
- uri.port = 443 unless uri.port
393
- http = Net::HTTP.new(uri.host, uri.port)
394
- http.use_ssl = true
395
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE
396
- else
397
- http = Net::HTTP.new(uri.host, uri.port)
398
- end
714
+ charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
715
+ src = res.body
716
+ src.encode!('UTF-8', charset) if charset
399
717
 
400
- res, src = http.get(uri.path, {'User-Agent' => USER_AGENT, 'Accept-Encoding' => 'gzip'})
401
- charset = fh.respond_to?(:charset) ? fh.charset : 'utf-8'
718
+ [src, charset]
719
+ rescue
720
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
402
721
 
403
- if res.code.to_i >= 400
404
- raise RemoteFileError if @options[:io_exceptions]
405
- return '', nil
406
- end
722
+ [nil, nil]
723
+ end
724
+ end
407
725
 
408
- case res['content-encoding']
409
- when 'gzip'
410
- io = Zlib::GzipReader.new(StringIO.new(res.body))
411
- src = io.read
412
- when 'deflate'
413
- io = Zlib::Inflate.new
414
- src = io.inflate(res.body)
415
- end
416
- end
726
+ # Net::HTTP path used only when `allow_local_network: true`. Validates
727
+ # the URI scheme on every redirect hop so a `Location: file://...`
728
+ # cannot be followed even on this opt-in code path.
729
+ def fetch_via_net_http(uri, redirect_count = 0) # :nodoc:
730
+ # Internal invariant: this method is the implementation of the
731
+ # `allow_local_network: true` branch of `read_remote_file`. If it
732
+ # is ever reached without that flag set, a future change has
733
+ # bypassed the SSRF gate; refuse to fetch rather than silently
734
+ # connect. The recursive call on a redirect inherits this guard
735
+ # because the option does not change mid-request.
736
+ unless @options[:allow_local_network]
737
+ raise "BUG: #{self.class}##{__method__} reached with " \
738
+ 'allow_local_network=false (SSRF gate bypassed)'
739
+ end
417
740
 
418
- if charset
419
- ic = Iconv.new('UTF-8//IGNORE', charset)
420
- src = ic.iconv(src)
421
- end
422
- rescue
423
- raise RemoteFileError if @options[:io_exceptions]
424
- return nil, nil
741
+ raise RemoteFileError, uri.to_s unless REMOTE_ALLOWED_SCHEMES.include?(uri.scheme)
742
+ raise RemoteFileError, uri.to_s if redirect_count > MAX_REDIRECTS
743
+
744
+ http = Net::HTTP.new(uri.host, uri.port || uri.default_port)
745
+ http.use_ssl = (uri.scheme == 'https')
746
+
747
+ res = http.get(uri.request_uri, {'User-Agent' => @options[:user_agent]})
748
+
749
+ if res.code.to_i >= 300 && res.code.to_i < 400 && res['Location']
750
+ redirect_uri = Addressable::URI.parse(Addressable::URI.escape(res['Location']))
751
+ return fetch_via_net_http(redirect_uri, redirect_count + 1)
425
752
  end
426
753
 
427
- return src, charset
754
+ res
428
755
  end
429
756
 
430
757
  private
758
+
431
759
  # Save a folded declaration block to the internal cache.
432
760
  def save_folded_declaration(block_hash, folded_declaration) # :nodoc:
433
761
  @folded_declaration_cache[block_hash] = folded_declaration
@@ -435,7 +763,7 @@ module CssParser
435
763
 
436
764
  # Retrieve a folded declaration block from the internal cache.
437
765
  def get_folded_declaration(block_hash) # :nodoc:
438
- return @folded_declaration_cache[block_hash] ||= nil
766
+ @folded_declaration_cache[block_hash] ||= nil
439
767
  end
440
768
 
441
769
  def reset! # :nodoc:
@@ -444,5 +772,24 @@ module CssParser
444
772
  @css_rules = []
445
773
  @css_warnings = []
446
774
  end
775
+
776
+ # recurse through nested nodes and return them as Hashes nested in
777
+ # passed hash
778
+ def css_node_to_h(hash, key, val)
779
+ hash[key.strip] = '' and return hash if val.nil?
780
+
781
+ lines = val.split(';')
782
+ nodes = {}
783
+ lines.each do |line|
784
+ parts = line.split(':', 2)
785
+ if parts[1].include?(':')
786
+ nodes[parts[0]] = css_node_to_h(hash, parts[0], parts[1])
787
+ else
788
+ nodes[parts[0].to_s.strip] = parts[1].to_s.strip
789
+ end
790
+ end
791
+ hash[key.strip] = nodes
792
+ hash
793
+ end
447
794
  end
448
795
  end