css_parser 3.0.0 → 3.3.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: 7286523850595059190f244936748ce609fc7d22de85ef56d5c5c229bd9a4ba4
4
- data.tar.gz: 64fe512e21e1687c4221be94793eedbf603bd5723ef32a804177f70ba96e41b7
3
+ metadata.gz: b1b890ee0d537d338ae0a218c68424271242859ba814a52c27fcd05ffefe1acd
4
+ data.tar.gz: 147e41dae4da802d96124fc07b488b122967a44c0ff79bfe623291c1795e0d9b
5
5
  SHA512:
6
- metadata.gz: 63fa1631ae27fb97c375eb50d2e2b29f99e029872be0ad82191834818ad989470517c7d52cb77291a6a2a58fd920c3d1aa10a6250b5b569641016c77862b57cc
7
- data.tar.gz: e6c24be4780b12aff38d866e5f0bc6b2b17deb78a94d2bb5072ec23bd82fcd70e1bcab63e4e02e915d2796e09dcaf9f5a239614c5c792d33003bb8ef0ca89d00
6
+ metadata.gz: 76728b4b816f4b620f28198b6fd5f92f005733bec3afba0df5707d8daf99ab650fd0a0143a6222fc53c5453c32c3a13a76eb4877f3c06f5dc903a559a18d200f
7
+ data.tar.gz: a5b5a901312932dd9d3c50fa9a6cd4ea549180f57a7e4d85d700d5c3ec58f458927be66c2eb7169165333e153d58061dbdfa98c75c0e6ffb6a81181bc78fdd6f
@@ -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
 
@@ -37,6 +42,15 @@ module CssParser
37
42
  # was GHSA-9pmc-p236-855h.
38
43
  REMOTE_ALLOWED_SCHEMES = %w[http https].freeze
39
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
53
+
40
54
  # Array of CSS files that have been loaded.
41
55
  attr_reader :loaded_uris
42
56
 
@@ -159,6 +173,21 @@ module CssParser
159
173
  load_uri!(import_uri, import_options)
160
174
  elsif options[:base_dir]
161
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]
162
191
  load_file!(import_path, import_options)
163
192
  end
164
193
  end
@@ -492,9 +521,7 @@ module CssParser
492
521
  #
493
522
  # You can also pass in file://test.css
494
523
  #
495
- # See add_block! for options.
496
- #
497
- # Deprecated: originally accepted three params: `uri`, `base_uri` and `media_types`
524
+ # See add_block! for options and integrity_matches? for integrity
498
525
  def load_uri!(uri, options = {}, deprecated = nil)
499
526
  uri = Addressable::URI.parse(uri) unless uri.respond_to? :scheme
500
527
 
@@ -502,7 +529,7 @@ module CssParser
502
529
 
503
530
  if options.is_a? Hash
504
531
  opts.merge!(options)
505
- else
532
+ else # TODO: start raising
506
533
  warn '[DEPRECATION] `load_uri!` with positional arguments is deprecated. ' \
507
534
  'Please use keyword arguments instead.', uplevel: 1
508
535
  opts[:base_uri] = options if options.is_a? String
@@ -538,7 +565,7 @@ module CssParser
538
565
  end
539
566
  read_local_file(uri)
540
567
  else
541
- src_and_charset, = read_remote_file(uri) # skip charset
568
+ src_and_charset, = read_remote_file(uri, integrity: opts[:integrity]) # skip charset
542
569
  src_and_charset
543
570
  end
544
571
 
@@ -546,8 +573,18 @@ module CssParser
546
573
  end
547
574
 
548
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.
549
586
  def load_file!(file_name, options = {}, deprecated = nil)
550
- opts = {base_dir: nil, media_types: :all}
587
+ opts = {base_dir: nil, media_types: :all, from_import: false, import_root: nil}
551
588
 
552
589
  if options.is_a? Hash
553
590
  opts.merge!(options)
@@ -558,7 +595,29 @@ module CssParser
558
595
  opts[:media_types] = deprecated if deprecated
559
596
  end
560
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
+
561
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
+
562
621
  return unless File.readable?(file_name)
563
622
  return unless circular_reference_check(file_name)
564
623
 
@@ -677,10 +736,14 @@ module CssParser
677
736
  # is still validated on every redirect hop, so cross-scheme
678
737
  # redirect to `file://` (the original GHSA-9pmc-p236-855h sink)
679
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.
680
743
  #--
681
744
  # TODO: add option to fail silently or throw and exception on a 404
682
745
  #++
683
- def read_remote_file(uri) # :nodoc:
746
+ def read_remote_file(uri, integrity: nil) # :nodoc:
684
747
  uri = Addressable::URI.parse(uri.to_s)
685
748
 
686
749
  unless circular_reference_check(uri.to_s)
@@ -711,11 +774,19 @@ module CssParser
711
774
  return '', nil
712
775
  end
713
776
 
777
+ if integrity && !integrity_matches?(res.body, integrity)
778
+ raise IntegrityError, uri.to_s if @options[:io_exceptions]
779
+
780
+ return nil, nil
781
+ end
782
+
714
783
  charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
715
784
  src = res.body
716
785
  src.encode!('UTF-8', charset) if charset
717
786
 
718
787
  [src, charset]
788
+ rescue IntegrityError # already is a RemoteFileError
789
+ raise
719
790
  rescue
720
791
  raise RemoteFileError, uri.to_s if @options[:io_exceptions]
721
792
 
@@ -723,6 +794,28 @@ module CssParser
723
794
  end
724
795
  end
725
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
+
726
819
  # Net::HTTP path used only when `allow_local_network: true`. Validates
727
820
  # the URI scheme on every redirect hop so a `Location: file://...`
728
821
  # cannot be followed even on this opt-in code path.
@@ -61,20 +61,27 @@ module CssParser
61
61
  RE_BORDER_UNITS = Regexp.union(BOX_MODEL_UNITS_RX, /(thin|medium|thick)/i)
62
62
 
63
63
  # 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
64
+ # Possessive quantifiers + recursion-first alternation avoid exponential
65
+ # backtracking on unclosed functions, the timeout is a backstop.
66
+ RE_FUNCTIONS = Regexp.new(
67
+ /
68
+ (
69
+ [a-z0-9-]+ # function name
70
+ )
71
+ (?>
72
+ \( # opening parenthesis
73
+ (?:
74
+ \g<0> # nested function
75
+ |
76
+ [^()a-z0-9]++
77
+ |
78
+ [a-z0-9-]++ (?!\()
79
+ )*
80
+ \) # closing parenthesis
81
+ )
82
+ /imx,
83
+ timeout: 0.01
84
+ ).freeze
78
85
 
79
86
  # Patterns for specificity calculations
80
87
  NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX_NC = /
@@ -696,10 +696,16 @@ module CssParser
696
696
  end
697
697
 
698
698
  def split_value_preserving_function_whitespace(value)
699
- split_value = value.gsub(RE_FUNCTIONS) do |c|
700
- c.gsub!(/\s+/, WHITESPACE_REPLACEMENT)
701
- c
702
- end
699
+ # hostile values can still hit the regexp timeout, then split without protecting functions
700
+ split_value =
701
+ begin
702
+ value.gsub(RE_FUNCTIONS) do |c|
703
+ c.gsub!(/\s+/, WHITESPACE_REPLACEMENT)
704
+ c
705
+ end
706
+ rescue Regexp::TimeoutError
707
+ value
708
+ end
703
709
 
704
710
  matches = split_value.strip.split(/\s+/)
705
711
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CssParser
4
- VERSION = '3.0.0'.freeze
4
+ VERSION = '3.3.0'.freeze
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: css_parser
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Dunae
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
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'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: ssrf_filter
28
42
  requirement: !ruby/object:Gem::Requirement