css_parser 1.21.0 → 3.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8880ebd6978e8a8a3108ea93b4d567252aee152b28f237dab31809f1488f42e
4
- data.tar.gz: 4e8379acf246b7fe2573a2ada4fc49c01e9c3b2de43fe80d07f42390f8f16450
3
+ metadata.gz: 3ff7ead55436d394e644feb148e017f631ea4ede0783746107a72cc790a70ee5
4
+ data.tar.gz: 8bdd909c65c7f7fe4139d164898e6cd7906b1b649b6a68429dee620cc5798cd0
5
5
  SHA512:
6
- metadata.gz: 480947bb06a6c40ab955f76ff9eb7ba9ac5caf160d7fe343d16901e9f37210c9ccab22a07a4d3165ec26bec4cd28547f530718978d2d969ed398c8195351a0c0
7
- data.tar.gz: e7a757c9dc096e71d5f617343f0900bacddea42bef4b26ca68f2830728a480ace908807dc4db82b6b0b77a11c1b2c9295ad814bb2aaa80fd069c959cd58afe65
6
+ metadata.gz: ecf5d9f9ef55e3f69269c511b51f5363bdc6e317fa1446ae04be7850256c30039dd9719c74705df29dbb9011847d3cbe3602bbd11f8b273cd7b6c92b9e2268cb
7
+ data.tar.gz: 7ddbd0f9bab2c8327f081a48cce1e7fb20cc133eb5af8530eb1dcc6ef1acc2836f9a2274e75e74dec6b9866461754ceecc759efbbd7aec7dbe6b06814bd2cbc9
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'strscan'
4
+ require 'digest'
5
+ require 'base64'
4
6
 
5
7
  module CssParser
6
8
  # Exception class used for any errors encountered while downloading remote files.
7
9
  class RemoteFileError < IOError; end
8
10
 
11
+ # Exception class used when a fetched remote file fails Subresource Integrity verification.
12
+ class IntegrityError < RemoteFileError; end
13
+
9
14
  # Exception class used if a request is made to load a CSS file more than once.
10
15
  class CircularReferenceError < StandardError; end
11
16
 
@@ -17,6 +22,8 @@ module CssParser
17
22
  # [<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>.
18
23
  # [<tt>import</tt>] Follow <tt>@import</tt> rules. Boolean, default is <tt>true</tt>.
19
24
  # [<tt>io_exceptions</tt>] Throw an exception if a link can not be found. Boolean, default is <tt>true</tt>.
25
+ # [<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>.
26
+ # [<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>.
20
27
  class Parser
21
28
  USER_AGENT = "Ruby CSS Parser/#{CssParser::VERSION} (https://github.com/premailer/css_parser)".freeze
22
29
  RULESET_TOKENIZER_RX = /\s+|\\{2,}|\\?[{}\s"]|[()]|.[^\s"{}()\\]*/.freeze
@@ -28,14 +35,24 @@ module CssParser
28
35
 
29
36
  MAX_REDIRECTS = 3
30
37
 
31
- # Array of CSS files that have been loaded.
32
- attr_reader :loaded_uris
38
+ # Schemes accepted by `read_remote_file`. `file://` is intentionally
39
+ # NOT in this list — local files are handled directly by `load_uri!`
40
+ # and `load_file!`. Keeping `file://` out of the remote read path
41
+ # closes the cross-scheme redirect (HTTP 3xx → `file://`) vector that
42
+ # was GHSA-9pmc-p236-855h.
43
+ REMOTE_ALLOWED_SCHEMES = %w[http https].freeze
44
+
45
+ # Subresource Integrity hash algorithms this library can verify, strongest first.
46
+ # Mirrors the SRI spec's "agility" rule (https://www.w3.org/TR/SRI/#agility):
47
+ # if `integrity` lists multiple algorithms, only the strongest is checked.
48
+ INTEGRITY_ALGORITHMS = {
49
+ 'sha512' => Digest::SHA512,
50
+ 'sha384' => Digest::SHA384,
51
+ 'sha256' => Digest::SHA256
52
+ }.freeze
33
53
 
34
- #--
35
- # Class variable? see http://www.oreillynet.com/ruby/blog/2007/01/nubygems_dont_use_class_variab_1.html
36
- #++
37
- @folded_declaration_cache = {}
38
- class << self; attr_reader :folded_declaration_cache; end
54
+ # Array of CSS files that have been loaded.
55
+ attr_reader :loaded_uris
39
56
 
40
57
  def initialize(options = {})
41
58
  @options = {
@@ -44,14 +61,14 @@ module CssParser
44
61
  io_exceptions: true,
45
62
  rule_set_exceptions: true,
46
63
  capture_offsets: false,
47
- user_agent: USER_AGENT
64
+ user_agent: USER_AGENT,
65
+ allow_local_network: false,
66
+ allow_file_uris: false
48
67
  }.merge(options)
49
68
 
50
69
  # array of RuleSets
51
70
  @rules = []
52
71
 
53
- @redirect_count = nil
54
-
55
72
  @loaded_uris = []
56
73
 
57
74
  # unprocessed blocks of CSS
@@ -143,7 +160,7 @@ module CssParser
143
160
  media_types = [:all]
144
161
  end
145
162
 
146
- next unless options[:only_media_types].include?(:all) or media_types.empty? or !(media_types & options[:only_media_types]).empty?
163
+ next unless options[:only_media_types].include?(:all) or media_types.empty? or media_types.intersect?(options[:only_media_types])
147
164
 
148
165
  import_path = import_rule[0].to_s.gsub(/['"]*/, '').strip
149
166
 
@@ -156,6 +173,21 @@ module CssParser
156
173
  load_uri!(import_uri, import_options)
157
174
  elsif options[:base_dir]
158
175
  import_options[:base_dir] = options[:base_dir]
176
+ # The import path comes from (potentially untrusted) CSS
177
+ # content, not from the caller. Flag this load so load_file!
178
+ # keeps it contained within the import root and refuses
179
+ # absolute paths / `..` traversal that escape it -- otherwise
180
+ # @import turns into arbitrary local file disclosure. This is
181
+ # the base_dir sibling of the file://-via-base_uri hole closed
182
+ # in GHSA-9pmc-p236-855h, which left this arm untouched.
183
+ #
184
+ # import_root is the top-level base_dir, threaded through
185
+ # nested imports so containment is always checked against it.
186
+ # That still allows legitimate `../` imports that stay inside
187
+ # the tree (e.g. subdir/import2.css -> ../simple.css) while
188
+ # rejecting anything that leaves it.
189
+ import_options[:from_import] = true
190
+ import_options[:import_root] = options[:import_root] || options[:base_dir]
159
191
  load_file!(import_path, import_options)
160
192
  end
161
193
  end
@@ -448,7 +480,7 @@ module CssParser
448
480
  current_media_query << token << ' '
449
481
  end
450
482
  end
451
- elsif in_charset or token =~ /@charset/i
483
+ elsif in_charset or /@charset/i.match?(token)
452
484
  # iterate until we are out of the charset declaration
453
485
  in_charset = !token.include?(';')
454
486
  elsif !in_string && token.include?('}')
@@ -467,7 +499,7 @@ module CssParser
467
499
  current_selectors << token
468
500
 
469
501
  # mark this as the beginning of the selector unless we have already marked it
470
- rule_start = start_offset if options[:capture_offsets] && rule_start.nil? && token =~ /^[^\s]+$/
502
+ rule_start = start_offset if options[:capture_offsets] && rule_start.nil? && /^[^\s]+$/.match?(token)
471
503
  end
472
504
  end
473
505
 
@@ -489,9 +521,7 @@ module CssParser
489
521
  #
490
522
  # You can also pass in file://test.css
491
523
  #
492
- # See add_block! for options.
493
- #
494
- # Deprecated: originally accepted three params: `uri`, `base_uri` and `media_types`
524
+ # See add_block! for options and integrity_matches? for integrity
495
525
  def load_uri!(uri, options = {}, deprecated = nil)
496
526
  uri = Addressable::URI.parse(uri) unless uri.respond_to? :scheme
497
527
 
@@ -499,7 +529,7 @@ module CssParser
499
529
 
500
530
  if options.is_a? Hash
501
531
  opts.merge!(options)
502
- else
532
+ else # TODO: start raising
503
533
  warn '[DEPRECATION] `load_uri!` with positional arguments is deprecated. ' \
504
534
  'Please use keyword arguments instead.', uplevel: 1
505
535
  opts[:base_uri] = options if options.is_a? String
@@ -516,14 +546,45 @@ module CssParser
516
546
  # pass on the uri if we are capturing file offsets
517
547
  opts[:filename] = uri.to_s if opts[:capture_offsets]
518
548
 
519
- src, = read_remote_file(uri) # skip charset
549
+ # file:// is handled here, not inside read_remote_file. The
550
+ # remote-read path must never service file:// URIs, so a 3xx
551
+ # `Location: file://...` redirect cannot be turned into a local
552
+ # File.read.
553
+ #
554
+ # file:// via `load_uri!` is also gated by `allow_file_uris`:
555
+ # an attacker who can influence a URI passed here (e.g. via a CSS
556
+ # @import resolved against an attacker-controlled base_uri) could
557
+ # otherwise turn it into arbitrary local file disclosure. Callers
558
+ # that legitimately need to load local files should use
559
+ # `load_file!` (the explicit local-file API).
560
+ src = if uri.scheme == 'file'
561
+ unless @options[:allow_file_uris]
562
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
563
+
564
+ return
565
+ end
566
+ read_local_file(uri)
567
+ else
568
+ src_and_charset, = read_remote_file(uri, integrity: opts[:integrity]) # skip charset
569
+ src_and_charset
570
+ end
520
571
 
521
572
  add_block!(src, opts) if src
522
573
  end
523
574
 
524
575
  # Load a local CSS file.
576
+ #
577
+ # +from_import+ (internal) marks a load whose +file_name+ originates
578
+ # from an @import rule in parsed CSS rather than from the caller. Such
579
+ # a path is attacker-influenced, so it is contained within the import
580
+ # root (+import_root+, the top-level +base_dir+): absolute paths and
581
+ # `..` traversal that escape the root are refused (raising
582
+ # RemoteFileError when io_exceptions are on, otherwise a silent no-op).
583
+ # Direct callers (the default, +from_import: false+) are unaffected --
584
+ # load_file! remains the explicit, trusted local-file API and may load
585
+ # any readable path.
525
586
  def load_file!(file_name, options = {}, deprecated = nil)
526
- opts = {base_dir: nil, media_types: :all}
587
+ opts = {base_dir: nil, media_types: :all, from_import: false, import_root: nil}
527
588
 
528
589
  if options.is_a? Hash
529
590
  opts.merge!(options)
@@ -534,7 +595,29 @@ module CssParser
534
595
  opts[:media_types] = deprecated if deprecated
535
596
  end
536
597
 
598
+ # Pin the containment root to the first base_dir seen in the chain.
599
+ # load_file! resets base_dir to each file's own directory as it
600
+ # recurses, so without this a file loaded from a subdirectory would
601
+ # shrink the sandbox and reject a legitimate `..` back toward the
602
+ # caller's original base_dir.
603
+ opts[:import_root] ||= opts[:base_dir]
604
+
537
605
  file_name = File.expand_path(file_name, opts[:base_dir])
606
+
607
+ # Containment check for @import-sourced paths: the resolved file must
608
+ # stay inside the import root. Rejects both `@import "/etc/passwd"`
609
+ # (absolute) and `@import "../../secret"` (traversal escaping the
610
+ # tree) from untrusted CSS, while leaving direct load_file! callers
611
+ # untouched and still permitting `..` that stays within the root.
612
+ if opts[:from_import] && (opts[:import_root] || opts[:base_dir])
613
+ root = File.expand_path((opts[:import_root] || opts[:base_dir]).to_s)
614
+ unless file_name == root || file_name.start_with?(root + File::SEPARATOR)
615
+ raise RemoteFileError, file_name if @options[:io_exceptions]
616
+
617
+ return
618
+ end
619
+ end
620
+
538
621
  return unless File.readable?(file_name)
539
622
  return unless circular_reference_check(file_name)
540
623
 
@@ -568,7 +651,8 @@ module CssParser
568
651
  #
569
652
  # Raises a CircularReferenceError exception if io_exceptions are on,
570
653
  # otherwise returns true/false.
571
- def circular_reference_check(path)
654
+ # TODO: fix rubocop
655
+ def circular_reference_check(path) # rubocop:disable Naming/PredicateMethod
572
656
  path = path.to_s
573
657
  if @loaded_uris.include?(path)
574
658
  raise CircularReferenceError, "can't load #{path} more than once" if @options[:io_exceptions]
@@ -609,87 +693,158 @@ module CssParser
609
693
  utf8_block
610
694
  end
611
695
 
612
- # Download a file into a string.
696
+ # Read a local file:// URI. Called only from `load_uri!` — never
697
+ # from the remote read path — so an HTTP redirect cannot reach this
698
+ # branch (GHSA-9pmc-p236-855h).
699
+ def read_local_file(uri) # :nodoc:
700
+ # Internal invariant: this method is the implementation of the
701
+ # `allow_file_uris: true` branch of `load_uri!`. If it is ever
702
+ # reached without that flag set, a future change has bypassed the
703
+ # LFI gate; refuse to read rather than silently leak.
704
+ unless @options[:allow_file_uris]
705
+ raise "BUG: #{self.class}##{__method__} reached with " \
706
+ 'allow_file_uris=false (LFI gate bypassed)'
707
+ end
708
+
709
+ return nil unless circular_reference_check(uri.to_s)
710
+
711
+ path = uri.path
712
+ path.gsub!(%r{^/}, '') if Gem.win_platform?
713
+ File.read(path, mode: 'rb')
714
+ rescue
715
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
716
+
717
+ nil
718
+ end
719
+
720
+ # Download a remote http(s) file into a string.
613
721
  #
614
722
  # Returns the file's data and character set in an array.
723
+ #
724
+ # In the default (secure) configuration, requests are issued via
725
+ # `SsrfFilter.get`, which:
726
+ # - rejects any scheme other than http/https (defeats redirect-to-
727
+ # `file://` / `gopher://` / `dict://` etc.);
728
+ # - resolves the hostname with `Resolv` and rejects requests whose
729
+ # resolved IP is loopback, RFC-1918, link-local, multicast, or any
730
+ # other range typically used for internal services (defeats SSRF
731
+ # via literal IPs and via CNAME / attacker-controlled A records);
732
+ # - re-validates scheme and IP on every redirect hop.
733
+ #
734
+ # When `allow_local_network: true` is set on the Parser, the SSRF
735
+ # check is bypassed and plain `Net::HTTP` is used — but the scheme
736
+ # is still validated on every redirect hop, so cross-scheme
737
+ # redirect to `file://` (the original GHSA-9pmc-p236-855h sink)
738
+ # remains closed even on this opt-in path.
739
+ #
740
+ # `integrity:`, when given, is verified against the raw response body
741
+ # (before charset decoding, matching Subresource Integrity semantics)
742
+ # -- see `integrity_matches?` and `load_uri!`'s documentation.
615
743
  #--
616
744
  # TODO: add option to fail silently or throw and exception on a 404
617
745
  #++
618
- def read_remote_file(uri) # :nodoc:
619
- if @redirect_count.nil?
620
- @redirect_count = 0
621
- else
622
- @redirect_count += 1
623
- end
746
+ def read_remote_file(uri, integrity: nil) # :nodoc:
747
+ uri = Addressable::URI.parse(uri.to_s)
624
748
 
625
749
  unless circular_reference_check(uri.to_s)
626
- @redirect_count = nil
627
750
  return nil, nil
628
751
  end
629
752
 
630
- if @redirect_count > MAX_REDIRECTS
631
- @redirect_count = nil
753
+ unless REMOTE_ALLOWED_SCHEMES.include?(uri.scheme)
754
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
755
+
632
756
  return nil, nil
633
757
  end
634
758
 
635
- src = '', charset = nil
636
-
637
759
  begin
638
- uri = Addressable::URI.parse(uri.to_s)
639
-
640
- if uri.scheme == 'file'
641
- # local file
642
- path = uri.path
643
- path.gsub!(%r{^/}, '') if Gem.win_platform?
644
- src = File.read(path, mode: 'rb')
645
- else
646
- # remote file
647
- if uri.scheme == 'https'
648
- uri.port = 443 unless uri.port
649
- http = Net::HTTP.new(uri.host, uri.port)
650
- http.use_ssl = true
651
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE
652
- else
653
- http = Net::HTTP.new(uri.host, uri.port)
654
- end
760
+ res = if @options[:allow_local_network]
761
+ fetch_via_net_http(uri)
762
+ else
763
+ SsrfFilter.get(
764
+ uri.to_s,
765
+ scheme_whitelist: REMOTE_ALLOWED_SCHEMES,
766
+ max_redirects: MAX_REDIRECTS,
767
+ headers: {'User-Agent' => @options[:user_agent]}
768
+ )
769
+ end
655
770
 
656
- res = http.get(uri.request_uri, {'User-Agent' => @options[:user_agent], 'Accept-Encoding' => 'gzip'})
657
- src = res.body
658
- charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
771
+ if res.code.to_i >= 400
772
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
659
773
 
660
- if res.code.to_i >= 400
661
- @redirect_count = nil
662
- raise RemoteFileError, uri.to_s if @options[:io_exceptions]
774
+ return '', nil
775
+ end
663
776
 
664
- return '', nil
665
- elsif res.code.to_i >= 300 and res.code.to_i < 400
666
- unless res['Location'].nil?
667
- return read_remote_file Addressable::URI.parse(Addressable::URI.escape(res['Location']))
668
- end
669
- end
777
+ if integrity && !integrity_matches?(res.body, integrity)
778
+ raise IntegrityError, uri.to_s if @options[:io_exceptions]
670
779
 
671
- case res['content-encoding']
672
- when 'gzip'
673
- io = Zlib::GzipReader.new(StringIO.new(res.body))
674
- src = io.read
675
- when 'deflate'
676
- io = Zlib::Inflate.new
677
- src = io.inflate(res.body)
678
- end
780
+ return nil, nil
679
781
  end
680
782
 
681
- if charset
682
- src.encode!('UTF-8', charset)
683
- end
783
+ charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
784
+ src = res.body
785
+ src.encode!('UTF-8', charset) if charset
786
+
787
+ [src, charset]
788
+ rescue IntegrityError # already is a RemoteFileError
789
+ raise
684
790
  rescue
685
- @redirect_count = nil
686
791
  raise RemoteFileError, uri.to_s if @options[:io_exceptions]
687
792
 
688
- return nil, nil
793
+ [nil, nil]
794
+ end
795
+ end
796
+
797
+ # Verifies +body+ (raw response bytes, not yet charset-decoded) against a
798
+ # Subresource Integrity value -- a `<algorithm>-<base64 digest>` token, or
799
+ # several whitespace-separated tokens (https://www.w3.org/TR/SRI/#the-integrity-attribute).
800
+ #
801
+ # - Tokens using an algorithm this library doesn't recognize are ignored
802
+ # - Multiple recognized algorithms only check the strongest one is checked
803
+ # - Value containing no recognized algorithm is treated as unverifiable and matches by default
804
+ def integrity_matches?(body, integrity) # :nodoc:
805
+ candidates = integrity.to_s.split.filter_map do |token|
806
+ algorithm, value = token.split('-', 2)
807
+ [algorithm, value] if algorithm && value && INTEGRITY_ALGORITHMS.key?(algorithm)
808
+ end
809
+ return true if candidates.empty?
810
+
811
+ algorithms_present = candidates.map(&:first)
812
+ algorithm = INTEGRITY_ALGORITHMS.each_key.find { |a| algorithms_present.include?(a) }
813
+ expected_values = candidates.select { |a, _v| a == algorithm }.map { |_a, v| v }
814
+ digest_class = INTEGRITY_ALGORITHMS.fetch(algorithm)
815
+
816
+ expected_values.include?(Base64.strict_encode64(digest_class.digest(body)))
817
+ end
818
+
819
+ # Net::HTTP path used only when `allow_local_network: true`. Validates
820
+ # the URI scheme on every redirect hop so a `Location: file://...`
821
+ # cannot be followed even on this opt-in code path.
822
+ def fetch_via_net_http(uri, redirect_count = 0) # :nodoc:
823
+ # Internal invariant: this method is the implementation of the
824
+ # `allow_local_network: true` branch of `read_remote_file`. If it
825
+ # is ever reached without that flag set, a future change has
826
+ # bypassed the SSRF gate; refuse to fetch rather than silently
827
+ # connect. The recursive call on a redirect inherits this guard
828
+ # because the option does not change mid-request.
829
+ unless @options[:allow_local_network]
830
+ raise "BUG: #{self.class}##{__method__} reached with " \
831
+ 'allow_local_network=false (SSRF gate bypassed)'
832
+ end
833
+
834
+ raise RemoteFileError, uri.to_s unless REMOTE_ALLOWED_SCHEMES.include?(uri.scheme)
835
+ raise RemoteFileError, uri.to_s if redirect_count > MAX_REDIRECTS
836
+
837
+ http = Net::HTTP.new(uri.host, uri.port || uri.default_port)
838
+ http.use_ssl = (uri.scheme == 'https')
839
+
840
+ res = http.get(uri.request_uri, {'User-Agent' => @options[:user_agent]})
841
+
842
+ if res.code.to_i >= 300 && res.code.to_i < 400 && res['Location']
843
+ redirect_uri = Addressable::URI.parse(Addressable::URI.escape(res['Location']))
844
+ return fetch_via_net_http(redirect_uri, redirect_count + 1)
689
845
  end
690
846
 
691
- @redirect_count = nil
692
- [src, charset]
847
+ res
693
848
  end
694
849
 
695
850
  private
@@ -35,6 +35,9 @@ module CssParser
35
35
  #++
36
36
  IMPORTANT_IN_PROPERTY_RX = /\s*!important\b\s*/i.freeze
37
37
 
38
+ # What sanitize_media_query would change: blank, padded, or not single-space separated
39
+ UNSANITIZED_MEDIA_QUERY_RX = /\A\z|\A[\s\0]|[\s\0]\z|\s\s|[^\S ]/.freeze
40
+
38
41
  RE_INSIDE_OUTSIDE = regex_possible_values 'inside', 'outside'
39
42
  RE_SCROLL_FIXED = regex_possible_values 'scroll', 'fixed'
40
43
  RE_REPEAT = regex_possible_values 'repeat(\-x|\-y)*|no\-repeat'
@@ -61,20 +64,27 @@ module CssParser
61
64
  RE_BORDER_UNITS = Regexp.union(BOX_MODEL_UNITS_RX, /(thin|medium|thick)/i)
62
65
 
63
66
  # Functions like calc, var, clamp, etc.
64
- RE_FUNCTIONS = /
65
- (
66
- [a-z0-9-]+ # function name
67
- )
68
- (?>
69
- \( # opening parenthesis
70
- (?:
71
- ([^()]+)
72
- | # recursion via subexpression
73
- \g<0>
74
- )*
75
- \) # closing parenthesis
76
- )
77
- /imx.freeze
67
+ # Possessive quantifiers + recursion-first alternation avoid exponential
68
+ # backtracking on unclosed functions, the timeout is a backstop.
69
+ RE_FUNCTIONS = Regexp.new(
70
+ /
71
+ (
72
+ [a-z0-9-]+ # function name
73
+ )
74
+ (?>
75
+ \( # opening parenthesis
76
+ (?:
77
+ \g<0> # nested function
78
+ |
79
+ [^()a-z0-9]++
80
+ |
81
+ [a-z0-9-]++ (?!\()
82
+ )*
83
+ \) # closing parenthesis
84
+ )
85
+ /imx,
86
+ timeout: 0.01
87
+ ).freeze
78
88
 
79
89
  # Patterns for specificity calculations
80
90
  NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX_NC = /
@@ -259,8 +269,10 @@ module CssParser
259
269
  inherit
260
270
  currentColor
261
271
  ].freeze
262
- RE_COLOUR_NUMERIC = /\b(hsl|rgb)\s*\(-?\s*-?\d+(\.\d+)?%?\s*%?,-?\s*-?\d+(\.\d+)?%?\s*%?,-?\s*-?\d+(\.\d+)?%?\s*%?\)/i.freeze
263
- RE_COLOUR_NUMERIC_ALPHA = /\b(hsla|rgba)\s*\(-?\s*-?\d+(\.\d+)?%?\s*%?,-?\s*-?\d+(\.\d+)?%?\s*%?,-?\s*-?\d+(\.\d+)?%?\s*%?,-?\s*-?\d+(\.\d+)?%?\s*%?\)/i.freeze
272
+ # CSS <number> allows the integer part to be omitted (e.g. `.1`), per CSS Values & Units.
273
+ # `(?:\d*\.)?\d+` accepts `1`, `1.5`, and `.5` while still rejecting bare `1.`.
274
+ RE_COLOUR_NUMERIC = /\b(hsl|rgb)\s*\(-?\s*-?(?:\d*\.)?\d+%?\s*%?,-?\s*-?(?:\d*\.)?\d+%?\s*%?,-?\s*-?(?:\d*\.)?\d+%?\s*%?\)/i.freeze
275
+ RE_COLOUR_NUMERIC_ALPHA = /\b(hsla|rgba)\s*\(-?\s*-?(?:\d*\.)?\d+%?\s*%?,-?\s*-?(?:\d*\.)?\d+%?\s*%?,-?\s*-?(?:\d*\.)?\d+%?\s*%?,-?\s*-?(?:\d*\.)?\d+%?\s*%?\)/i.freeze
264
276
  RE_COLOUR_HEX = /\s*#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/.freeze
265
277
  RE_COLOUR_NAMED = /\s*\b(#{NAMED_COLOURS.join('|')})\b/i.freeze
266
278
  RE_COLOUR = Regexp.union(RE_COLOUR_NUMERIC, RE_COLOUR_NUMERIC_ALPHA, RE_COLOUR_HEX, RE_COLOUR_NAMED)
@@ -18,6 +18,11 @@ module CssParser
18
18
 
19
19
  NUMBER_OF_DIMENSIONS = 4
20
20
 
21
+ NORMALIZED_PROPERTY_RX = /\A[-a-z0-9_]+\z/.freeze
22
+ BLANK_VALUE_RX = /\A[\s\0]*\z/.freeze
23
+ SURROUNDING_WHITESPACE_RX = /\A[\s\0]|[\s\0]\z/.freeze
24
+ TRAILING_SEMICOLON_RX = /\s*;\s*\Z/.freeze
25
+
21
26
  DIMENSIONS = [
22
27
  ['margin', %w[margin-top margin-right margin-bottom margin-left]],
23
28
  ['padding', %w[padding-top padding-right padding-bottom padding-left]],
@@ -35,6 +40,8 @@ module CssParser
35
40
  RPAREN = ')'.freeze
36
41
  IMPORTANT = '!important'.freeze
37
42
  class Declarations
43
+ EMPTY_DECLARATIONS = {}.freeze
44
+
38
45
  class Value
39
46
  attr_reader :value
40
47
  attr_accessor :important
@@ -45,12 +52,17 @@ module CssParser
45
52
  end
46
53
 
47
54
  def value=(value)
48
- value = value.to_s.sub(/\s*;\s*\Z/, '')
49
- self.important = !value.slice!(CssParser::IMPORTANT_IN_PROPERTY_RX).nil?
50
- value.strip!
55
+ # sub and strip always return a copy, and the caller's string must not be mutated:
56
+ # only copy when there is something to remove, so a clean value allocates nothing
57
+ value = value.to_s
58
+ value = value.sub(TRAILING_SEMICOLON_RX, '') if value.match?(TRAILING_SEMICOLON_RX)
59
+ self.important = value.match?(CssParser::IMPORTANT_IN_PROPERTY_RX)
60
+ value = value.sub(CssParser::IMPORTANT_IN_PROPERTY_RX, '') if important
61
+ value = value.strip if value.match?(SURROUNDING_WHITESPACE_RX)
51
62
  raise ArgumentError, 'value is empty' if value.empty?
52
63
 
53
- @value = value.freeze
64
+ # value may still be the caller's string: -value freezes a copy instead of it
65
+ @value = -value
54
66
  end
55
67
 
56
68
  def to_s
@@ -68,7 +80,7 @@ module CssParser
68
80
 
69
81
  def_delegators :declarations, :each, :each_value
70
82
 
71
- def initialize(declarations = {})
83
+ def initialize(declarations = EMPTY_DECLARATIONS)
72
84
  self.declarations = {}
73
85
  declarations.each { |property, value| add_declaration!(property, value) }
74
86
  end
@@ -89,17 +101,20 @@ module CssParser
89
101
  # puts declarations['margin']
90
102
  # => #<CssParser::RuleSet::Declarations::Value:0x00000000030c1838 @important=true, @order=2, @value="0px auto">
91
103
  #
92
- # If the property already exists its value will be over-written.
104
+ # If the property already exists its value will be over-written unless it was !important and the new value
105
+ # is not !important.
93
106
  # If the value is empty - property will be deleted
94
107
  def []=(property, value)
95
108
  property = normalize_property(property)
109
+ currently_important = declarations[property]&.important
96
110
 
97
- if value.is_a?(Value)
111
+ if value.is_a?(Value) && (!currently_important || value.important)
98
112
  declarations[property] = value
99
- elsif value.to_s.strip.empty?
113
+ elsif value.to_s.match?(BLANK_VALUE_RX)
100
114
  delete property
101
115
  else
102
- declarations[property] = Value.new(value)
116
+ value = Value.new(value)
117
+ declarations[property] = value if !currently_important || value.important
103
118
  end
104
119
  rescue ArgumentError => e
105
120
  raise e.exception, "#{property} #{e.message}"
@@ -159,18 +174,19 @@ module CssParser
159
174
 
160
175
  # We should preserve subsequent declarations of the same properties
161
176
  # and prior important ones if replacement one is not important
162
- replacements = replacement_declarations.each.with_object({}) do |(key, replacement), result|
177
+ replacements = {}
178
+ replacement_declarations.declarations.each do |key, replacement|
163
179
  existing = declarations[key]
164
180
 
165
181
  # No existing -> set
166
182
  unless existing
167
- result[key] = replacement
183
+ replacements[key] = replacement
168
184
  next
169
185
  end
170
186
 
171
187
  # Replacement more important than existing -> replace
172
188
  if replacement.important && !existing.important
173
- result[key] = replacement
189
+ replacements[key] = replacement
174
190
  replaced_index = replacement_keys.index(key)
175
191
  replacement_keys.delete_at(replaced_index)
176
192
  replacement_values.delete_at(replaced_index)
@@ -183,7 +199,7 @@ module CssParser
183
199
 
184
200
  # Existing and replacement importance are the same,
185
201
  # value which is declared later wins
186
- result[key] = replacement if property_index > replacement_keys.index(key)
202
+ replacements[key] = replacement if property_index > replacement_keys.index(key)
187
203
  end
188
204
 
189
205
  return if replacements.empty?
@@ -194,16 +210,17 @@ module CssParser
194
210
  replacement_values.delete_at(property_index)
195
211
  replacement_values.insert(property_index, *replacements.values)
196
212
 
197
- self.declarations = replacement_keys.zip(replacement_values).to_h
213
+ self.declarations = {}
214
+ replacement_keys.each_with_index { |key, index| declarations[key] = replacement_values[index] }
198
215
  end
199
216
 
200
217
  def to_s(options = {})
201
- str = declarations.reduce(+'') do |memo, (prop, value)|
218
+ str = +''
219
+ declarations.each do |prop, value|
202
220
  importance = options[:force_important] || value.important ? ' !important' : ''
203
- memo << "#{prop}: #{value.value}#{importance}; "
221
+ str << prop << ': ' << value.value << importance << '; '
204
222
  end
205
- # TODO: Clean-up regexp doesn't seem to work
206
- str.gsub!(/^[\s^({)]+|[\n\r\f\t]*|\s+$/mx, '')
223
+ str.gsub!(/^[\s^({)]+|[\n\r\f\t]+/, '')
207
224
  str.strip!
208
225
  str
209
226
  end
@@ -223,7 +240,10 @@ module CssParser
223
240
  attr_writer :declarations
224
241
 
225
242
  def normalize_property(property)
226
- property = property.to_s.downcase
243
+ property = property.to_s
244
+ return property if property.match?(NORMALIZED_PROPERTY_RX)
245
+
246
+ property = property.downcase
227
247
  property.strip!
228
248
  property
229
249
  end
@@ -396,14 +416,14 @@ module CssParser
396
416
  DIMENSIONS.each do |property, (top, right, bottom, left)|
397
417
  next unless (declaration = declarations[property])
398
418
 
399
- value = declaration.value.dup
419
+ value = declaration.value
400
420
 
401
421
  # RGB and HSL values in borders are the only units that can have spaces (within params).
402
422
  # We cheat a bit here by stripping spaces after commas in RGB and HSL values so that we
403
423
  # can split easily on spaces.
404
424
  #
405
425
  # TODO: rgba, hsl, hsla
406
- value.gsub!(RE_COLOUR) { |c| c.gsub(/(\s*,\s*)/, ',') }
426
+ value = value.gsub(RE_COLOUR) { |c| c.gsub(/(\s*,\s*)/, ',') } if value.include?(',')
407
427
 
408
428
  matches = split_value_preserving_function_whitespace(value)
409
429
 
@@ -421,7 +441,7 @@ module CssParser
421
441
  raise ArgumentError, "Cannot parse #{value}"
422
442
  end
423
443
 
424
- replacement = [top, right, bottom, left].zip(values).to_h
444
+ replacement = {top => values[0], right => values[1], bottom => values[2], left => values[3]}
425
445
 
426
446
  declarations.replace_declaration!(property, replacement, preserve_importance: true)
427
447
  end
@@ -515,17 +535,13 @@ module CssParser
515
535
 
516
536
  # Combine several properties into a shorthand one
517
537
  def create_shorthand_properties!(properties, shorthand_property) # :nodoc:
518
- values = []
519
- properties_to_delete = []
520
- properties.each do |property|
521
- next unless (declaration = declarations[property])
522
- next if declaration.important
523
-
524
- values << declaration.value
525
- properties_to_delete << property
538
+ properties_to_delete = properties.select do |property|
539
+ (declaration = declarations[property]) && !declaration.important
526
540
  end
527
541
 
528
- return if values.length <= 1
542
+ return if properties_to_delete.length <= 1
543
+
544
+ values = properties_to_delete.map { |property| declarations[property].value }
529
545
 
530
546
  properties_to_delete.each do |property|
531
547
  declarations.delete(property)
@@ -581,14 +597,11 @@ module CssParser
581
597
  return if declarations.size < NUMBER_OF_DIMENSIONS
582
598
 
583
599
  DIMENSIONS.each do |property, dimensions|
584
- values = DIMENSION_DIRECTIONS.each_with_index.with_object({}) do |(side, index), result|
585
- next unless (declaration = declarations[dimensions[index]])
586
-
587
- result[side] = declaration.value
588
- end
589
-
590
600
  # All four dimensions must be present
591
- next if values.size != dimensions.size
601
+ next unless dimensions.all? { |dimension| declarations.key?(dimension) }
602
+
603
+ top, right, bottom, left = dimensions.map { |dimension| declarations[dimension].value }
604
+ values = {top: top, right: right, bottom: bottom, left: left}
592
605
 
593
606
  new_value = values.values_at(*compute_dimensions_shorthand(values)).join(' ').strip
594
607
  declarations[property] = new_value unless new_value.empty?
@@ -638,7 +651,7 @@ module CssParser
638
651
 
639
652
  def compute_dimensions_shorthand(values)
640
653
  # All four sides are equal, returning single value
641
- return [:top] if values.values.uniq.count == 1
654
+ return [:top] if values[:top] == values[:right] && values[:right] == values[:bottom] && values[:bottom] == values[:left]
642
655
 
643
656
  # `/* top | right | bottom | left */`
644
657
  return DIMENSION_DIRECTIONS if values[:left] != values[:right]
@@ -667,12 +680,13 @@ module CssParser
667
680
  next unless (colon = decs.index(COLON))
668
681
 
669
682
  property = decs[0, colon]
670
- value = decs[(colon + 1)..]
683
+ value = decs[colon + 1, decs.length]
671
684
  property.strip!
672
685
  value.strip!
673
- next if property.empty? || value.empty? || value.casecmp?(IMPORTANT)
686
+ # casecmp? case-folds both strings, allocating two of them per declaration
687
+ next if property.empty? || value.empty? || value.casecmp(IMPORTANT).zero?
674
688
 
675
- add_declaration!(property, value)
689
+ add_declaration!(property, value.freeze)
676
690
  continuation = nil
677
691
  end
678
692
  end
@@ -685,7 +699,7 @@ module CssParser
685
699
  # TODO: way too simplistic
686
700
  #++
687
701
  def parse_selectors!(selectors) # :nodoc:
688
- @selectors = selectors.split(',').map do |s|
702
+ @selectors = selectors.split(',').map! do |s|
689
703
  s.gsub!(/\s+/, ' ')
690
704
  s.strip!
691
705
  s
@@ -693,10 +707,16 @@ module CssParser
693
707
  end
694
708
 
695
709
  def split_value_preserving_function_whitespace(value)
696
- split_value = value.gsub(RE_FUNCTIONS) do |c|
697
- c.gsub!(/\s+/, WHITESPACE_REPLACEMENT)
698
- c
699
- end
710
+ # hostile values can still hit the regexp timeout, then split without protecting functions
711
+ split_value =
712
+ begin
713
+ value.gsub(RE_FUNCTIONS) do |c|
714
+ c.gsub!(/\s+/, WHITESPACE_REPLACEMENT)
715
+ c
716
+ end
717
+ rescue Regexp::TimeoutError
718
+ value
719
+ end
700
720
 
701
721
  matches = split_value.strip.split(/\s+/)
702
722
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CssParser
4
- VERSION = '1.21.0'.freeze
4
+ VERSION = '3.4.0'.freeze
5
5
  end
data/lib/css_parser.rb CHANGED
@@ -4,8 +4,7 @@ require 'addressable/uri'
4
4
  require 'uri'
5
5
  require 'net/https'
6
6
  require 'digest/md5'
7
- require 'zlib'
8
- require 'stringio'
7
+ require 'ssrf_filter'
9
8
 
10
9
  require 'css_parser/version'
11
10
  require 'css_parser/rule_set'
@@ -52,8 +51,6 @@ module CssParser
52
51
  # TODO: declaration_hashes should be able to contain a RuleSet
53
52
  # this should be a Class method
54
53
  def self.merge(*rule_sets)
55
- @folded_declaration_cache = {}
56
-
57
54
  # in case called like CssParser.merge([rule_set, rule_set])
58
55
  rule_sets.flatten! if rule_sets[0].is_a?(Array)
59
56
 
@@ -88,9 +85,10 @@ module CssParser
88
85
  end
89
86
  end
90
87
 
91
- merged = properties.each_with_object(RuleSet.new(nil, nil)) do |(property, details), rule_set|
92
- value = details[:value].strip
93
- rule_set[property.strip] = details[:is_important] ? "#{value.gsub(/;\Z/, '')}!important" : value
88
+ merged = RuleSet.new(nil, nil)
89
+ properties.each do |property, details|
90
+ value = details[:value]
91
+ merged[property] = details[:is_important] ? "#{value.gsub(/;\Z/, '')}!important" : value
94
92
  end
95
93
 
96
94
  merged.create_shorthand!
@@ -110,7 +108,7 @@ module CssParser
110
108
  #++
111
109
  def self.calculate_specificity(selector)
112
110
  a = 0
113
- b = selector.scan('#').length
111
+ b = selector.count('#')
114
112
  c = selector.scan(NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX_NC).length
115
113
  d = selector.scan(ELEMENTS_AND_PSEUDO_ELEMENTS_RX_NC).length
116
114
 
@@ -150,6 +148,8 @@ module CssParser
150
148
  end
151
149
 
152
150
  def self.sanitize_media_query(raw)
151
+ return raw if raw.is_a?(Symbol) && !raw.match?(UNSANITIZED_MEDIA_QUERY_RX)
152
+
153
153
  mq = raw.to_s.gsub(/\s+/, ' ')
154
154
  mq.strip!
155
155
  mq = 'all' if mq.empty?
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: css_parser
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.21.0
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Dunae
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-12-13 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: addressable
@@ -24,6 +23,34 @@ dependencies:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: base64
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: ssrf_filter
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.5'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.5'
27
54
  description: A set of classes for parsing CSS in Ruby.
28
55
  email: code@dunae.ca
29
56
  executables: []
@@ -44,7 +71,6 @@ metadata:
44
71
  source_code_uri: https://github.com/premailer/css_parser
45
72
  bug_tracker_uri: https://github.com/premailer/css_parser/issues
46
73
  rubygems_mfa_required: 'true'
47
- post_install_message:
48
74
  rdoc_options: []
49
75
  require_paths:
50
76
  - lib
@@ -52,15 +78,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
52
78
  requirements:
53
79
  - - ">="
54
80
  - !ruby/object:Gem::Version
55
- version: '3.0'
81
+ version: '3.3'
56
82
  required_rubygems_version: !ruby/object:Gem::Requirement
57
83
  requirements:
58
84
  - - ">="
59
85
  - !ruby/object:Gem::Version
60
86
  version: '0'
61
87
  requirements: []
62
- rubygems_version: 3.4.19
63
- signing_key:
88
+ rubygems_version: 4.0.3
64
89
  specification_version: 4
65
90
  summary: Ruby CSS parser.
66
91
  test_files: []