css_parser 2.2.0 → 3.1.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: ed16a56359267ef6ea44fe0e45a76611e1f8365f65e9437c4c556fd4dfaa983f
4
- data.tar.gz: 51136b897f4f511bd1d285138e366e8d66e090d7a8b5811afd34178e804bfd35
3
+ metadata.gz: 7c675c0d95c9efce004a49e17cf09c880c44c4c11375f754c50586052c03202d
4
+ data.tar.gz: 2d729de7e39e045d1e2594e4c847c495ef7765d34c37b76f53ef9348de0e1f96
5
5
  SHA512:
6
- metadata.gz: 51fd7d3070a36f6a8d048f4eefcc6e5e3a85bef1f14dd14d751242bb35de0b2efb1a6278641980183e2a27e5c33ad84b9b39ed04e768f7439a76a015572f393c
7
- data.tar.gz: f3116a5e15595a2c869507d43eb8c744eb19d26c850991d9eeeb4ff190956191c2136c5add0408402177fa865cf18903fc917dd43c6932ce32ed5cb8843aa992
6
+ metadata.gz: ab3178e90c357ad4ede9c90ac70526bbe4b90a475690afa60551d4acfc2bb41a70db125bdda040a1bbcfa8f4e3dfa454401116aec745e639af9244c71c3f7c6a
7
+ data.tar.gz: 15838f208ef82d6a2d5adbddcb5486ad5b13b0d88f32e3923b6f7bd064fbbe08dc4f6dc4af0a299a517ff7c4faec3010a044b59209b809248e58622b08a29c4a
@@ -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,8 +35,24 @@ module CssParser
28
35
 
29
36
  MAX_REDIRECTS = 3
30
37
 
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
53
+
31
54
  # Array of CSS files that have been loaded.
32
- attr_reader :loaded_uris
55
+ attr_reader :loaded_uris
33
56
 
34
57
  def initialize(options = {})
35
58
  @options = {
@@ -38,14 +61,14 @@ module CssParser
38
61
  io_exceptions: true,
39
62
  rule_set_exceptions: true,
40
63
  capture_offsets: false,
41
- user_agent: USER_AGENT
64
+ user_agent: USER_AGENT,
65
+ allow_local_network: false,
66
+ allow_file_uris: false
42
67
  }.merge(options)
43
68
 
44
69
  # array of RuleSets
45
70
  @rules = []
46
71
 
47
- @redirect_count = nil
48
-
49
72
  @loaded_uris = []
50
73
 
51
74
  # unprocessed blocks of CSS
@@ -483,9 +506,7 @@ module CssParser
483
506
  #
484
507
  # You can also pass in file://test.css
485
508
  #
486
- # See add_block! for options.
487
- #
488
- # Deprecated: originally accepted three params: `uri`, `base_uri` and `media_types`
509
+ # See add_block! for options and integrity_matches? for integrity
489
510
  def load_uri!(uri, options = {}, deprecated = nil)
490
511
  uri = Addressable::URI.parse(uri) unless uri.respond_to? :scheme
491
512
 
@@ -493,7 +514,7 @@ module CssParser
493
514
 
494
515
  if options.is_a? Hash
495
516
  opts.merge!(options)
496
- else
517
+ else # TODO: start raising
497
518
  warn '[DEPRECATION] `load_uri!` with positional arguments is deprecated. ' \
498
519
  'Please use keyword arguments instead.', uplevel: 1
499
520
  opts[:base_uri] = options if options.is_a? String
@@ -510,7 +531,28 @@ module CssParser
510
531
  # pass on the uri if we are capturing file offsets
511
532
  opts[:filename] = uri.to_s if opts[:capture_offsets]
512
533
 
513
- src, = read_remote_file(uri) # skip charset
534
+ # file:// is handled here, not inside read_remote_file. The
535
+ # remote-read path must never service file:// URIs, so a 3xx
536
+ # `Location: file://...` redirect cannot be turned into a local
537
+ # File.read.
538
+ #
539
+ # file:// via `load_uri!` is also gated by `allow_file_uris`:
540
+ # an attacker who can influence a URI passed here (e.g. via a CSS
541
+ # @import resolved against an attacker-controlled base_uri) could
542
+ # otherwise turn it into arbitrary local file disclosure. Callers
543
+ # that legitimately need to load local files should use
544
+ # `load_file!` (the explicit local-file API).
545
+ src = if uri.scheme == 'file'
546
+ unless @options[:allow_file_uris]
547
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
548
+
549
+ return
550
+ end
551
+ read_local_file(uri)
552
+ else
553
+ src_and_charset, = read_remote_file(uri, integrity: opts[:integrity]) # skip charset
554
+ src_and_charset
555
+ end
514
556
 
515
557
  add_block!(src, opts) if src
516
558
  end
@@ -604,86 +646,158 @@ module CssParser
604
646
  utf8_block
605
647
  end
606
648
 
607
- # Download a file into a string.
649
+ # Read a local file:// URI. Called only from `load_uri!` — never
650
+ # from the remote read path — so an HTTP redirect cannot reach this
651
+ # branch (GHSA-9pmc-p236-855h).
652
+ def read_local_file(uri) # :nodoc:
653
+ # Internal invariant: this method is the implementation of the
654
+ # `allow_file_uris: true` branch of `load_uri!`. If it is ever
655
+ # reached without that flag set, a future change has bypassed the
656
+ # LFI gate; refuse to read rather than silently leak.
657
+ unless @options[:allow_file_uris]
658
+ raise "BUG: #{self.class}##{__method__} reached with " \
659
+ 'allow_file_uris=false (LFI gate bypassed)'
660
+ end
661
+
662
+ return nil unless circular_reference_check(uri.to_s)
663
+
664
+ path = uri.path
665
+ path.gsub!(%r{^/}, '') if Gem.win_platform?
666
+ File.read(path, mode: 'rb')
667
+ rescue
668
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
669
+
670
+ nil
671
+ end
672
+
673
+ # Download a remote http(s) file into a string.
608
674
  #
609
675
  # Returns the file's data and character set in an array.
676
+ #
677
+ # In the default (secure) configuration, requests are issued via
678
+ # `SsrfFilter.get`, which:
679
+ # - rejects any scheme other than http/https (defeats redirect-to-
680
+ # `file://` / `gopher://` / `dict://` etc.);
681
+ # - resolves the hostname with `Resolv` and rejects requests whose
682
+ # resolved IP is loopback, RFC-1918, link-local, multicast, or any
683
+ # other range typically used for internal services (defeats SSRF
684
+ # via literal IPs and via CNAME / attacker-controlled A records);
685
+ # - re-validates scheme and IP on every redirect hop.
686
+ #
687
+ # When `allow_local_network: true` is set on the Parser, the SSRF
688
+ # check is bypassed and plain `Net::HTTP` is used — but the scheme
689
+ # is still validated on every redirect hop, so cross-scheme
690
+ # redirect to `file://` (the original GHSA-9pmc-p236-855h sink)
691
+ # remains closed even on this opt-in path.
692
+ #
693
+ # `integrity:`, when given, is verified against the raw response body
694
+ # (before charset decoding, matching Subresource Integrity semantics)
695
+ # -- see `integrity_matches?` and `load_uri!`'s documentation.
610
696
  #--
611
697
  # TODO: add option to fail silently or throw and exception on a 404
612
698
  #++
613
- def read_remote_file(uri) # :nodoc:
614
- if @redirect_count.nil?
615
- @redirect_count = 0
616
- else
617
- @redirect_count += 1
618
- end
699
+ def read_remote_file(uri, integrity: nil) # :nodoc:
700
+ uri = Addressable::URI.parse(uri.to_s)
619
701
 
620
702
  unless circular_reference_check(uri.to_s)
621
- @redirect_count = nil
622
703
  return nil, nil
623
704
  end
624
705
 
625
- if @redirect_count > MAX_REDIRECTS
626
- @redirect_count = nil
706
+ unless REMOTE_ALLOWED_SCHEMES.include?(uri.scheme)
707
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
708
+
627
709
  return nil, nil
628
710
  end
629
711
 
630
- src = '', charset = nil
631
-
632
712
  begin
633
- uri = Addressable::URI.parse(uri.to_s)
634
-
635
- if uri.scheme == 'file'
636
- # local file
637
- path = uri.path
638
- path.gsub!(%r{^/}, '') if Gem.win_platform?
639
- src = File.read(path, mode: 'rb')
640
- else
641
- # remote file
642
- if uri.scheme == 'https'
643
- uri.port = 443 unless uri.port
644
- http = Net::HTTP.new(uri.host, uri.port)
645
- http.use_ssl = true
646
- else
647
- http = Net::HTTP.new(uri.host, uri.port)
648
- end
713
+ res = if @options[:allow_local_network]
714
+ fetch_via_net_http(uri)
715
+ else
716
+ SsrfFilter.get(
717
+ uri.to_s,
718
+ scheme_whitelist: REMOTE_ALLOWED_SCHEMES,
719
+ max_redirects: MAX_REDIRECTS,
720
+ headers: {'User-Agent' => @options[:user_agent]}
721
+ )
722
+ end
649
723
 
650
- res = http.get(uri.request_uri, {'User-Agent' => @options[:user_agent], 'Accept-Encoding' => 'gzip'})
651
- src = res.body
652
- charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
724
+ if res.code.to_i >= 400
725
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
653
726
 
654
- if res.code.to_i >= 400
655
- @redirect_count = nil
656
- raise RemoteFileError, uri.to_s if @options[:io_exceptions]
727
+ return '', nil
728
+ end
657
729
 
658
- return '', nil
659
- elsif res.code.to_i >= 300 and res.code.to_i < 400
660
- unless res['Location'].nil?
661
- return read_remote_file Addressable::URI.parse(Addressable::URI.escape(res['Location']))
662
- end
663
- end
730
+ if integrity && !integrity_matches?(res.body, integrity)
731
+ raise IntegrityError, uri.to_s if @options[:io_exceptions]
664
732
 
665
- case res['content-encoding']
666
- when 'gzip'
667
- io = Zlib::GzipReader.new(StringIO.new(res.body))
668
- src = io.read
669
- when 'deflate'
670
- io = Zlib::Inflate.new
671
- src = io.inflate(res.body)
672
- end
733
+ return nil, nil
673
734
  end
674
735
 
675
- if charset
676
- src.encode!('UTF-8', charset)
677
- end
736
+ charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
737
+ src = res.body
738
+ src.encode!('UTF-8', charset) if charset
739
+
740
+ [src, charset]
741
+ rescue IntegrityError # already is a RemoteFileError
742
+ raise
678
743
  rescue
679
- @redirect_count = nil
680
744
  raise RemoteFileError, uri.to_s if @options[:io_exceptions]
681
745
 
682
- return nil, nil
746
+ [nil, nil]
747
+ end
748
+ end
749
+
750
+ # Verifies +body+ (raw response bytes, not yet charset-decoded) against a
751
+ # Subresource Integrity value -- a `<algorithm>-<base64 digest>` token, or
752
+ # several whitespace-separated tokens (https://www.w3.org/TR/SRI/#the-integrity-attribute).
753
+ #
754
+ # - Tokens using an algorithm this library doesn't recognize are ignored
755
+ # - Multiple recognized algorithms only check the strongest one is checked
756
+ # - Value containing no recognized algorithm is treated as unverifiable and matches by default
757
+ def integrity_matches?(body, integrity) # :nodoc:
758
+ candidates = integrity.to_s.split.filter_map do |token|
759
+ algorithm, value = token.split('-', 2)
760
+ [algorithm, value] if algorithm && value && INTEGRITY_ALGORITHMS.key?(algorithm)
761
+ end
762
+ return true if candidates.empty?
763
+
764
+ algorithms_present = candidates.map(&:first)
765
+ algorithm = INTEGRITY_ALGORITHMS.each_key.find { |a| algorithms_present.include?(a) }
766
+ expected_values = candidates.select { |a, _v| a == algorithm }.map { |_a, v| v }
767
+ digest_class = INTEGRITY_ALGORITHMS.fetch(algorithm)
768
+
769
+ expected_values.include?(Base64.strict_encode64(digest_class.digest(body)))
770
+ end
771
+
772
+ # Net::HTTP path used only when `allow_local_network: true`. Validates
773
+ # the URI scheme on every redirect hop so a `Location: file://...`
774
+ # cannot be followed even on this opt-in code path.
775
+ def fetch_via_net_http(uri, redirect_count = 0) # :nodoc:
776
+ # Internal invariant: this method is the implementation of the
777
+ # `allow_local_network: true` branch of `read_remote_file`. If it
778
+ # is ever reached without that flag set, a future change has
779
+ # bypassed the SSRF gate; refuse to fetch rather than silently
780
+ # connect. The recursive call on a redirect inherits this guard
781
+ # because the option does not change mid-request.
782
+ unless @options[:allow_local_network]
783
+ raise "BUG: #{self.class}##{__method__} reached with " \
784
+ 'allow_local_network=false (SSRF gate bypassed)'
785
+ end
786
+
787
+ raise RemoteFileError, uri.to_s unless REMOTE_ALLOWED_SCHEMES.include?(uri.scheme)
788
+ raise RemoteFileError, uri.to_s if redirect_count > MAX_REDIRECTS
789
+
790
+ http = Net::HTTP.new(uri.host, uri.port || uri.default_port)
791
+ http.use_ssl = (uri.scheme == 'https')
792
+
793
+ res = http.get(uri.request_uri, {'User-Agent' => @options[:user_agent]})
794
+
795
+ if res.code.to_i >= 300 && res.code.to_i < 400 && res['Location']
796
+ redirect_uri = Addressable::URI.parse(Addressable::URI.escape(res['Location']))
797
+ return fetch_via_net_http(redirect_uri, redirect_count + 1)
683
798
  end
684
799
 
685
- @redirect_count = nil
686
- [src, charset]
800
+ res
687
801
  end
688
802
 
689
803
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CssParser
4
- VERSION = '2.2.0'.freeze
4
+ VERSION = '3.1.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'
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: 2.2.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Dunae
@@ -23,6 +23,34 @@ 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'
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'
26
54
  description: A set of classes for parsing CSS in Ruby.
27
55
  email: code@dunae.ca
28
56
  executables: []