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 +4 -4
- data/lib/css_parser/parser.rb +178 -64
- data/lib/css_parser/version.rb +1 -1
- data/lib/css_parser.rb +1 -2
- metadata +29 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c675c0d95c9efce004a49e17cf09c880c44c4c11375f754c50586052c03202d
|
|
4
|
+
data.tar.gz: 2d729de7e39e045d1e2594e4c847c495ef7765d34c37b76f53ef9348de0e1f96
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab3178e90c357ad4ede9c90ac70526bbe4b90a475690afa60551d4acfc2bb41a70db125bdda040a1bbcfa8f4e3dfa454401116aec745e639af9244c71c3f7c6a
|
|
7
|
+
data.tar.gz: 15838f208ef82d6a2d5adbddcb5486ad5b13b0d88f32e3923b6f7bd064fbbe08dc4f6dc4af0a299a517ff7c4faec3010a044b59209b809248e58622b08a29c4a
|
data/lib/css_parser/parser.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
626
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
651
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
727
|
+
return '', nil
|
|
728
|
+
end
|
|
657
729
|
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
686
|
-
[src, charset]
|
|
800
|
+
res
|
|
687
801
|
end
|
|
688
802
|
|
|
689
803
|
private
|
data/lib/css_parser/version.rb
CHANGED
data/lib/css_parser.rb
CHANGED
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:
|
|
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: []
|