css_parser 1.22.0 → 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 +4 -4
- data/lib/css_parser/parser.rb +132 -69
- data/lib/css_parser/regexps.rb +4 -2
- data/lib/css_parser/version.rb +1 -1
- data/lib/css_parser.rb +1 -4
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7286523850595059190f244936748ce609fc7d22de85ef56d5c5c229bd9a4ba4
|
|
4
|
+
data.tar.gz: 64fe512e21e1687c4221be94793eedbf603bd5723ef32a804177f70ba96e41b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 63fa1631ae27fb97c375eb50d2e2b29f99e029872be0ad82191834818ad989470517c7d52cb77291a6a2a58fd920c3d1aa10a6250b5b569641016c77862b57cc
|
|
7
|
+
data.tar.gz: e6c24be4780b12aff38d866e5f0bc6b2b17deb78a94d2bb5072ec23bd82fcd70e1bcab63e4e02e915d2796e09dcaf9f5a239614c5c792d33003bb8ef0ca89d00
|
data/lib/css_parser/parser.rb
CHANGED
|
@@ -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
|
-
#
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,86 +634,124 @@ module CssParser
|
|
|
609
634
|
utf8_block
|
|
610
635
|
end
|
|
611
636
|
|
|
612
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
uri.port = 443 unless uri.port
|
|
649
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
650
|
-
http.use_ssl = true
|
|
651
|
-
else
|
|
652
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
653
|
-
end
|
|
654
|
-
|
|
655
|
-
res = http.get(uri.request_uri, {'User-Agent' => @options[:user_agent], 'Accept-Encoding' => 'gzip'})
|
|
656
|
-
src = res.body
|
|
657
|
-
charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
|
|
658
|
-
|
|
659
|
-
if res.code.to_i >= 400
|
|
660
|
-
@redirect_count = nil
|
|
661
|
-
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
|
|
662
707
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
unless res['Location'].nil?
|
|
666
|
-
return read_remote_file Addressable::URI.parse(Addressable::URI.escape(res['Location']))
|
|
667
|
-
end
|
|
668
|
-
end
|
|
708
|
+
if res.code.to_i >= 400
|
|
709
|
+
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
669
710
|
|
|
670
|
-
|
|
671
|
-
when 'gzip'
|
|
672
|
-
io = Zlib::GzipReader.new(StringIO.new(res.body))
|
|
673
|
-
src = io.read
|
|
674
|
-
when 'deflate'
|
|
675
|
-
io = Zlib::Inflate.new
|
|
676
|
-
src = io.inflate(res.body)
|
|
677
|
-
end
|
|
711
|
+
return '', nil
|
|
678
712
|
end
|
|
679
713
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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]
|
|
683
719
|
rescue
|
|
684
|
-
@redirect_count = nil
|
|
685
720
|
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
686
721
|
|
|
687
|
-
|
|
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)
|
|
688
752
|
end
|
|
689
753
|
|
|
690
|
-
|
|
691
|
-
[src, charset]
|
|
754
|
+
res
|
|
692
755
|
end
|
|
693
756
|
|
|
694
757
|
private
|
data/lib/css_parser/regexps.rb
CHANGED
|
@@ -259,8 +259,10 @@ module CssParser
|
|
|
259
259
|
inherit
|
|
260
260
|
currentColor
|
|
261
261
|
].freeze
|
|
262
|
-
|
|
263
|
-
|
|
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)
|
data/lib/css_parser/version.rb
CHANGED
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 '
|
|
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,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.0.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: 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'
|
|
26
40
|
description: A set of classes for parsing CSS in Ruby.
|
|
27
41
|
email: code@dunae.ca
|
|
28
42
|
executables: []
|
|
@@ -50,7 +64,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
50
64
|
requirements:
|
|
51
65
|
- - ">="
|
|
52
66
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '3.
|
|
67
|
+
version: '3.3'
|
|
54
68
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
69
|
requirements:
|
|
56
70
|
- - ">="
|