css_parser 1.21.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebc43b9f09241b83ffe9a2878e0a2e5295c79f6d2e02bb20dcaf54d376f91e58
4
- data.tar.gz: 5b29858923af894f1b84251c59de6a4638ae0929b63ac0b432a7607d9dd32111
3
+ metadata.gz: 7286523850595059190f244936748ce609fc7d22de85ef56d5c5c229bd9a4ba4
4
+ data.tar.gz: 64fe512e21e1687c4221be94793eedbf603bd5723ef32a804177f70ba96e41b7
5
5
  SHA512:
6
- metadata.gz: fd7a00f2be5a2ddbf730eb115dbc0e15f9ac7a71ac92e1a056161a2b418e01d7abeb757c6f8d5fabafd9e4f7dba034b209ca571012ee44ee6b377b2e4bd8100f
7
- data.tar.gz: ac8aeba706f1ae35126c6d5c4a150a52862af8654f1342e6c5b36ebce8c0b71c26e0d0931d09081b3b01a70685c3d2b173031a3e1ff32a8477799c3e39e013e4
6
+ metadata.gz: 63fa1631ae27fb97c375eb50d2e2b29f99e029872be0ad82191834818ad989470517c7d52cb77291a6a2a58fd920c3d1aa10a6250b5b569641016c77862b57cc
7
+ data.tar.gz: e6c24be4780b12aff38d866e5f0bc6b2b17deb78a94d2bb5072ec23bd82fcd70e1bcab63e4e02e915d2796e09dcaf9f5a239614c5c792d33003bb8ef0ca89d00
@@ -17,6 +17,8 @@ module CssParser
17
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>.
18
18
  # [<tt>import</tt>] Follow <tt>@import</tt> rules. Boolean, default is <tt>true</tt>.
19
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>.
20
22
  class Parser
21
23
  USER_AGENT = "Ruby CSS Parser/#{CssParser::VERSION} (https://github.com/premailer/css_parser)".freeze
22
24
  RULESET_TOKENIZER_RX = /\s+|\\{2,}|\\?[{}\s"]|[()]|.[^\s"{}()\\]*/.freeze
@@ -28,14 +30,15 @@ module CssParser
28
30
 
29
31
  MAX_REDIRECTS = 3
30
32
 
31
- # Array of CSS files that have been loaded.
32
- attr_reader :loaded_uris
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
33
39
 
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
40
+ # Array of CSS files that have been loaded.
41
+ attr_reader :loaded_uris
39
42
 
40
43
  def initialize(options = {})
41
44
  @options = {
@@ -44,14 +47,14 @@ module CssParser
44
47
  io_exceptions: true,
45
48
  rule_set_exceptions: true,
46
49
  capture_offsets: false,
47
- user_agent: USER_AGENT
50
+ user_agent: USER_AGENT,
51
+ allow_local_network: false,
52
+ allow_file_uris: false
48
53
  }.merge(options)
49
54
 
50
55
  # array of RuleSets
51
56
  @rules = []
52
57
 
53
- @redirect_count = nil
54
-
55
58
  @loaded_uris = []
56
59
 
57
60
  # unprocessed blocks of CSS
@@ -143,7 +146,7 @@ module CssParser
143
146
  media_types = [:all]
144
147
  end
145
148
 
146
- next unless options[:only_media_types].include?(:all) or media_types.empty? or !(media_types & options[:only_media_types]).empty?
149
+ next unless options[:only_media_types].include?(:all) or media_types.empty? or media_types.intersect?(options[:only_media_types])
147
150
 
148
151
  import_path = import_rule[0].to_s.gsub(/['"]*/, '').strip
149
152
 
@@ -516,7 +519,28 @@ module CssParser
516
519
  # pass on the uri if we are capturing file offsets
517
520
  opts[:filename] = uri.to_s if opts[:capture_offsets]
518
521
 
519
- src, = read_remote_file(uri) # skip charset
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
520
544
 
521
545
  add_block!(src, opts) if src
522
546
  end
@@ -568,7 +592,8 @@ module CssParser
568
592
  #
569
593
  # Raises a CircularReferenceError exception if io_exceptions are on,
570
594
  # otherwise returns true/false.
571
- def circular_reference_check(path)
595
+ # TODO: fix rubocop
596
+ def circular_reference_check(path) # rubocop:disable Naming/PredicateMethod
572
597
  path = path.to_s
573
598
  if @loaded_uris.include?(path)
574
599
  raise CircularReferenceError, "can't load #{path} more than once" if @options[:io_exceptions]
@@ -609,87 +634,124 @@ module CssParser
609
634
  utf8_block
610
635
  end
611
636
 
612
- # Download a file into a string.
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]
657
+
658
+ nil
659
+ end
660
+
661
+ # Download a remote http(s) file into a string.
613
662
  #
614
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.
615
680
  #--
616
681
  # TODO: add option to fail silently or throw and exception on a 404
617
682
  #++
618
683
  def read_remote_file(uri) # :nodoc:
619
- if @redirect_count.nil?
620
- @redirect_count = 0
621
- else
622
- @redirect_count += 1
623
- end
684
+ uri = Addressable::URI.parse(uri.to_s)
624
685
 
625
686
  unless circular_reference_check(uri.to_s)
626
- @redirect_count = nil
627
687
  return nil, nil
628
688
  end
629
689
 
630
- if @redirect_count > MAX_REDIRECTS
631
- @redirect_count = nil
690
+ unless REMOTE_ALLOWED_SCHEMES.include?(uri.scheme)
691
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
692
+
632
693
  return nil, nil
633
694
  end
634
695
 
635
- src = '', charset = nil
636
-
637
696
  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
655
-
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'
659
-
660
- if res.code.to_i >= 400
661
- @redirect_count = nil
662
- raise RemoteFileError, uri.to_s if @options[:io_exceptions]
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
663
707
 
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
708
+ if res.code.to_i >= 400
709
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
670
710
 
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
711
+ return '', nil
679
712
  end
680
713
 
681
- if charset
682
- src.encode!('UTF-8', charset)
683
- end
714
+ charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
715
+ src = res.body
716
+ src.encode!('UTF-8', charset) if charset
717
+
718
+ [src, charset]
684
719
  rescue
685
- @redirect_count = nil
686
720
  raise RemoteFileError, uri.to_s if @options[:io_exceptions]
687
721
 
688
- return nil, nil
722
+ [nil, nil]
723
+ end
724
+ end
725
+
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
740
+
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)
689
752
  end
690
753
 
691
- @redirect_count = nil
692
- [src, charset]
754
+ res
693
755
  end
694
756
 
695
757
  private
@@ -259,8 +259,10 @@ module CssParser
259
259
  inherit
260
260
  currentColor
261
261
  ].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
262
+ # CSS <number> allows the integer part to be omitted (e.g. `.1`), per CSS Values & Units.
263
+ # `(?:\d*\.)?\d+` accepts `1`, `1.5`, and `.5` while still rejecting bare `1.`.
264
+ RE_COLOUR_NUMERIC = /\b(hsl|rgb)\s*\(-?\s*-?(?:\d*\.)?\d+%?\s*%?,-?\s*-?(?:\d*\.)?\d+%?\s*%?,-?\s*-?(?:\d*\.)?\d+%?\s*%?\)/i.freeze
265
+ 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
266
  RE_COLOUR_HEX = /\s*#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/.freeze
265
267
  RE_COLOUR_NAMED = /\s*\b(#{NAMED_COLOURS.join('|')})\b/i.freeze
266
268
  RE_COLOUR = Regexp.union(RE_COLOUR_NUMERIC, RE_COLOUR_NUMERIC_ALPHA, RE_COLOUR_HEX, RE_COLOUR_NAMED)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CssParser
4
- VERSION = '1.21.1'.freeze
4
+ VERSION = '3.0.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
 
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.1
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Dunae
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-03-09 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,20 @@ dependencies:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ssrf_filter
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.5'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.5'
27
40
  description: A set of classes for parsing CSS in Ruby.
28
41
  email: code@dunae.ca
29
42
  executables: []
@@ -44,7 +57,6 @@ metadata:
44
57
  source_code_uri: https://github.com/premailer/css_parser
45
58
  bug_tracker_uri: https://github.com/premailer/css_parser/issues
46
59
  rubygems_mfa_required: 'true'
47
- post_install_message:
48
60
  rdoc_options: []
49
61
  require_paths:
50
62
  - lib
@@ -52,15 +64,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
52
64
  requirements:
53
65
  - - ">="
54
66
  - !ruby/object:Gem::Version
55
- version: '3.0'
67
+ version: '3.3'
56
68
  required_rubygems_version: !ruby/object:Gem::Requirement
57
69
  requirements:
58
70
  - - ">="
59
71
  - !ruby/object:Gem::Version
60
72
  version: '0'
61
73
  requirements: []
62
- rubygems_version: 3.4.19
63
- signing_key:
74
+ rubygems_version: 4.0.3
64
75
  specification_version: 4
65
76
  summary: Ruby CSS parser.
66
77
  test_files: []