css_parser 1.21.0 → 3.4.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 +231 -76
- data/lib/css_parser/regexps.rb +28 -16
- data/lib/css_parser/rule_set.rb +67 -47
- data/lib/css_parser/version.rb +1 -1
- data/lib/css_parser.rb +8 -8
- metadata +32 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ff7ead55436d394e644feb148e017f631ea4ede0783746107a72cc790a70ee5
|
|
4
|
+
data.tar.gz: 8bdd909c65c7f7fe4139d164898e6cd7906b1b649b6a68429dee620cc5798cd0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ecf5d9f9ef55e3f69269c511b51f5363bdc6e317fa1446ae04be7850256c30039dd9719c74705df29dbb9011847d3cbe3602bbd11f8b273cd7b6c92b9e2268cb
|
|
7
|
+
data.tar.gz: 7ddbd0f9bab2c8327f081a48cce1e7fb20cc133eb5af8530eb1dcc6ef1acc2836f9a2274e75e74dec6b9866461754ceecc759efbbd7aec7dbe6b06814bd2cbc9
|
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,14 +35,24 @@ module CssParser
|
|
|
28
35
|
|
|
29
36
|
MAX_REDIRECTS = 3
|
|
30
37
|
|
|
31
|
-
#
|
|
32
|
-
|
|
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
|
|
33
53
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
#++
|
|
37
|
-
@folded_declaration_cache = {}
|
|
38
|
-
class << self; attr_reader :folded_declaration_cache; end
|
|
54
|
+
# Array of CSS files that have been loaded.
|
|
55
|
+
attr_reader :loaded_uris
|
|
39
56
|
|
|
40
57
|
def initialize(options = {})
|
|
41
58
|
@options = {
|
|
@@ -44,14 +61,14 @@ module CssParser
|
|
|
44
61
|
io_exceptions: true,
|
|
45
62
|
rule_set_exceptions: true,
|
|
46
63
|
capture_offsets: false,
|
|
47
|
-
user_agent: USER_AGENT
|
|
64
|
+
user_agent: USER_AGENT,
|
|
65
|
+
allow_local_network: false,
|
|
66
|
+
allow_file_uris: false
|
|
48
67
|
}.merge(options)
|
|
49
68
|
|
|
50
69
|
# array of RuleSets
|
|
51
70
|
@rules = []
|
|
52
71
|
|
|
53
|
-
@redirect_count = nil
|
|
54
|
-
|
|
55
72
|
@loaded_uris = []
|
|
56
73
|
|
|
57
74
|
# unprocessed blocks of CSS
|
|
@@ -143,7 +160,7 @@ module CssParser
|
|
|
143
160
|
media_types = [:all]
|
|
144
161
|
end
|
|
145
162
|
|
|
146
|
-
next unless options[:only_media_types].include?(:all) or media_types.empty? or
|
|
163
|
+
next unless options[:only_media_types].include?(:all) or media_types.empty? or media_types.intersect?(options[:only_media_types])
|
|
147
164
|
|
|
148
165
|
import_path = import_rule[0].to_s.gsub(/['"]*/, '').strip
|
|
149
166
|
|
|
@@ -156,6 +173,21 @@ module CssParser
|
|
|
156
173
|
load_uri!(import_uri, import_options)
|
|
157
174
|
elsif options[:base_dir]
|
|
158
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]
|
|
159
191
|
load_file!(import_path, import_options)
|
|
160
192
|
end
|
|
161
193
|
end
|
|
@@ -448,7 +480,7 @@ module CssParser
|
|
|
448
480
|
current_media_query << token << ' '
|
|
449
481
|
end
|
|
450
482
|
end
|
|
451
|
-
elsif in_charset or
|
|
483
|
+
elsif in_charset or /@charset/i.match?(token)
|
|
452
484
|
# iterate until we are out of the charset declaration
|
|
453
485
|
in_charset = !token.include?(';')
|
|
454
486
|
elsif !in_string && token.include?('}')
|
|
@@ -467,7 +499,7 @@ module CssParser
|
|
|
467
499
|
current_selectors << token
|
|
468
500
|
|
|
469
501
|
# mark this as the beginning of the selector unless we have already marked it
|
|
470
|
-
rule_start = start_offset if options[:capture_offsets] && rule_start.nil? &&
|
|
502
|
+
rule_start = start_offset if options[:capture_offsets] && rule_start.nil? && /^[^\s]+$/.match?(token)
|
|
471
503
|
end
|
|
472
504
|
end
|
|
473
505
|
|
|
@@ -489,9 +521,7 @@ module CssParser
|
|
|
489
521
|
#
|
|
490
522
|
# You can also pass in file://test.css
|
|
491
523
|
#
|
|
492
|
-
# See add_block! for options
|
|
493
|
-
#
|
|
494
|
-
# Deprecated: originally accepted three params: `uri`, `base_uri` and `media_types`
|
|
524
|
+
# See add_block! for options and integrity_matches? for integrity
|
|
495
525
|
def load_uri!(uri, options = {}, deprecated = nil)
|
|
496
526
|
uri = Addressable::URI.parse(uri) unless uri.respond_to? :scheme
|
|
497
527
|
|
|
@@ -499,7 +529,7 @@ module CssParser
|
|
|
499
529
|
|
|
500
530
|
if options.is_a? Hash
|
|
501
531
|
opts.merge!(options)
|
|
502
|
-
else
|
|
532
|
+
else # TODO: start raising
|
|
503
533
|
warn '[DEPRECATION] `load_uri!` with positional arguments is deprecated. ' \
|
|
504
534
|
'Please use keyword arguments instead.', uplevel: 1
|
|
505
535
|
opts[:base_uri] = options if options.is_a? String
|
|
@@ -516,14 +546,45 @@ module CssParser
|
|
|
516
546
|
# pass on the uri if we are capturing file offsets
|
|
517
547
|
opts[:filename] = uri.to_s if opts[:capture_offsets]
|
|
518
548
|
|
|
519
|
-
|
|
549
|
+
# file:// is handled here, not inside read_remote_file. The
|
|
550
|
+
# remote-read path must never service file:// URIs, so a 3xx
|
|
551
|
+
# `Location: file://...` redirect cannot be turned into a local
|
|
552
|
+
# File.read.
|
|
553
|
+
#
|
|
554
|
+
# file:// via `load_uri!` is also gated by `allow_file_uris`:
|
|
555
|
+
# an attacker who can influence a URI passed here (e.g. via a CSS
|
|
556
|
+
# @import resolved against an attacker-controlled base_uri) could
|
|
557
|
+
# otherwise turn it into arbitrary local file disclosure. Callers
|
|
558
|
+
# that legitimately need to load local files should use
|
|
559
|
+
# `load_file!` (the explicit local-file API).
|
|
560
|
+
src = if uri.scheme == 'file'
|
|
561
|
+
unless @options[:allow_file_uris]
|
|
562
|
+
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
563
|
+
|
|
564
|
+
return
|
|
565
|
+
end
|
|
566
|
+
read_local_file(uri)
|
|
567
|
+
else
|
|
568
|
+
src_and_charset, = read_remote_file(uri, integrity: opts[:integrity]) # skip charset
|
|
569
|
+
src_and_charset
|
|
570
|
+
end
|
|
520
571
|
|
|
521
572
|
add_block!(src, opts) if src
|
|
522
573
|
end
|
|
523
574
|
|
|
524
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.
|
|
525
586
|
def load_file!(file_name, options = {}, deprecated = nil)
|
|
526
|
-
opts = {base_dir: nil, media_types: :all}
|
|
587
|
+
opts = {base_dir: nil, media_types: :all, from_import: false, import_root: nil}
|
|
527
588
|
|
|
528
589
|
if options.is_a? Hash
|
|
529
590
|
opts.merge!(options)
|
|
@@ -534,7 +595,29 @@ module CssParser
|
|
|
534
595
|
opts[:media_types] = deprecated if deprecated
|
|
535
596
|
end
|
|
536
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
|
+
|
|
537
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
|
+
|
|
538
621
|
return unless File.readable?(file_name)
|
|
539
622
|
return unless circular_reference_check(file_name)
|
|
540
623
|
|
|
@@ -568,7 +651,8 @@ module CssParser
|
|
|
568
651
|
#
|
|
569
652
|
# Raises a CircularReferenceError exception if io_exceptions are on,
|
|
570
653
|
# otherwise returns true/false.
|
|
571
|
-
|
|
654
|
+
# TODO: fix rubocop
|
|
655
|
+
def circular_reference_check(path) # rubocop:disable Naming/PredicateMethod
|
|
572
656
|
path = path.to_s
|
|
573
657
|
if @loaded_uris.include?(path)
|
|
574
658
|
raise CircularReferenceError, "can't load #{path} more than once" if @options[:io_exceptions]
|
|
@@ -609,87 +693,158 @@ module CssParser
|
|
|
609
693
|
utf8_block
|
|
610
694
|
end
|
|
611
695
|
|
|
612
|
-
#
|
|
696
|
+
# Read a local file:// URI. Called only from `load_uri!` — never
|
|
697
|
+
# from the remote read path — so an HTTP redirect cannot reach this
|
|
698
|
+
# branch (GHSA-9pmc-p236-855h).
|
|
699
|
+
def read_local_file(uri) # :nodoc:
|
|
700
|
+
# Internal invariant: this method is the implementation of the
|
|
701
|
+
# `allow_file_uris: true` branch of `load_uri!`. If it is ever
|
|
702
|
+
# reached without that flag set, a future change has bypassed the
|
|
703
|
+
# LFI gate; refuse to read rather than silently leak.
|
|
704
|
+
unless @options[:allow_file_uris]
|
|
705
|
+
raise "BUG: #{self.class}##{__method__} reached with " \
|
|
706
|
+
'allow_file_uris=false (LFI gate bypassed)'
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
return nil unless circular_reference_check(uri.to_s)
|
|
710
|
+
|
|
711
|
+
path = uri.path
|
|
712
|
+
path.gsub!(%r{^/}, '') if Gem.win_platform?
|
|
713
|
+
File.read(path, mode: 'rb')
|
|
714
|
+
rescue
|
|
715
|
+
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
716
|
+
|
|
717
|
+
nil
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Download a remote http(s) file into a string.
|
|
613
721
|
#
|
|
614
722
|
# Returns the file's data and character set in an array.
|
|
723
|
+
#
|
|
724
|
+
# In the default (secure) configuration, requests are issued via
|
|
725
|
+
# `SsrfFilter.get`, which:
|
|
726
|
+
# - rejects any scheme other than http/https (defeats redirect-to-
|
|
727
|
+
# `file://` / `gopher://` / `dict://` etc.);
|
|
728
|
+
# - resolves the hostname with `Resolv` and rejects requests whose
|
|
729
|
+
# resolved IP is loopback, RFC-1918, link-local, multicast, or any
|
|
730
|
+
# other range typically used for internal services (defeats SSRF
|
|
731
|
+
# via literal IPs and via CNAME / attacker-controlled A records);
|
|
732
|
+
# - re-validates scheme and IP on every redirect hop.
|
|
733
|
+
#
|
|
734
|
+
# When `allow_local_network: true` is set on the Parser, the SSRF
|
|
735
|
+
# check is bypassed and plain `Net::HTTP` is used — but the scheme
|
|
736
|
+
# is still validated on every redirect hop, so cross-scheme
|
|
737
|
+
# redirect to `file://` (the original GHSA-9pmc-p236-855h sink)
|
|
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.
|
|
615
743
|
#--
|
|
616
744
|
# TODO: add option to fail silently or throw and exception on a 404
|
|
617
745
|
#++
|
|
618
|
-
def read_remote_file(uri) # :nodoc:
|
|
619
|
-
|
|
620
|
-
@redirect_count = 0
|
|
621
|
-
else
|
|
622
|
-
@redirect_count += 1
|
|
623
|
-
end
|
|
746
|
+
def read_remote_file(uri, integrity: nil) # :nodoc:
|
|
747
|
+
uri = Addressable::URI.parse(uri.to_s)
|
|
624
748
|
|
|
625
749
|
unless circular_reference_check(uri.to_s)
|
|
626
|
-
@redirect_count = nil
|
|
627
750
|
return nil, nil
|
|
628
751
|
end
|
|
629
752
|
|
|
630
|
-
|
|
631
|
-
|
|
753
|
+
unless REMOTE_ALLOWED_SCHEMES.include?(uri.scheme)
|
|
754
|
+
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
755
|
+
|
|
632
756
|
return nil, nil
|
|
633
757
|
end
|
|
634
758
|
|
|
635
|
-
src = '', charset = nil
|
|
636
|
-
|
|
637
759
|
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
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
652
|
-
else
|
|
653
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
654
|
-
end
|
|
760
|
+
res = if @options[:allow_local_network]
|
|
761
|
+
fetch_via_net_http(uri)
|
|
762
|
+
else
|
|
763
|
+
SsrfFilter.get(
|
|
764
|
+
uri.to_s,
|
|
765
|
+
scheme_whitelist: REMOTE_ALLOWED_SCHEMES,
|
|
766
|
+
max_redirects: MAX_REDIRECTS,
|
|
767
|
+
headers: {'User-Agent' => @options[:user_agent]}
|
|
768
|
+
)
|
|
769
|
+
end
|
|
655
770
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
|
|
771
|
+
if res.code.to_i >= 400
|
|
772
|
+
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
659
773
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
774
|
+
return '', nil
|
|
775
|
+
end
|
|
663
776
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
unless res['Location'].nil?
|
|
667
|
-
return read_remote_file Addressable::URI.parse(Addressable::URI.escape(res['Location']))
|
|
668
|
-
end
|
|
669
|
-
end
|
|
777
|
+
if integrity && !integrity_matches?(res.body, integrity)
|
|
778
|
+
raise IntegrityError, uri.to_s if @options[:io_exceptions]
|
|
670
779
|
|
|
671
|
-
|
|
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
|
|
780
|
+
return nil, nil
|
|
679
781
|
end
|
|
680
782
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
783
|
+
charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
|
|
784
|
+
src = res.body
|
|
785
|
+
src.encode!('UTF-8', charset) if charset
|
|
786
|
+
|
|
787
|
+
[src, charset]
|
|
788
|
+
rescue IntegrityError # already is a RemoteFileError
|
|
789
|
+
raise
|
|
684
790
|
rescue
|
|
685
|
-
@redirect_count = nil
|
|
686
791
|
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
687
792
|
|
|
688
|
-
|
|
793
|
+
[nil, nil]
|
|
794
|
+
end
|
|
795
|
+
end
|
|
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
|
+
|
|
819
|
+
# Net::HTTP path used only when `allow_local_network: true`. Validates
|
|
820
|
+
# the URI scheme on every redirect hop so a `Location: file://...`
|
|
821
|
+
# cannot be followed even on this opt-in code path.
|
|
822
|
+
def fetch_via_net_http(uri, redirect_count = 0) # :nodoc:
|
|
823
|
+
# Internal invariant: this method is the implementation of the
|
|
824
|
+
# `allow_local_network: true` branch of `read_remote_file`. If it
|
|
825
|
+
# is ever reached without that flag set, a future change has
|
|
826
|
+
# bypassed the SSRF gate; refuse to fetch rather than silently
|
|
827
|
+
# connect. The recursive call on a redirect inherits this guard
|
|
828
|
+
# because the option does not change mid-request.
|
|
829
|
+
unless @options[:allow_local_network]
|
|
830
|
+
raise "BUG: #{self.class}##{__method__} reached with " \
|
|
831
|
+
'allow_local_network=false (SSRF gate bypassed)'
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
raise RemoteFileError, uri.to_s unless REMOTE_ALLOWED_SCHEMES.include?(uri.scheme)
|
|
835
|
+
raise RemoteFileError, uri.to_s if redirect_count > MAX_REDIRECTS
|
|
836
|
+
|
|
837
|
+
http = Net::HTTP.new(uri.host, uri.port || uri.default_port)
|
|
838
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
839
|
+
|
|
840
|
+
res = http.get(uri.request_uri, {'User-Agent' => @options[:user_agent]})
|
|
841
|
+
|
|
842
|
+
if res.code.to_i >= 300 && res.code.to_i < 400 && res['Location']
|
|
843
|
+
redirect_uri = Addressable::URI.parse(Addressable::URI.escape(res['Location']))
|
|
844
|
+
return fetch_via_net_http(redirect_uri, redirect_count + 1)
|
|
689
845
|
end
|
|
690
846
|
|
|
691
|
-
|
|
692
|
-
[src, charset]
|
|
847
|
+
res
|
|
693
848
|
end
|
|
694
849
|
|
|
695
850
|
private
|
data/lib/css_parser/regexps.rb
CHANGED
|
@@ -35,6 +35,9 @@ module CssParser
|
|
|
35
35
|
#++
|
|
36
36
|
IMPORTANT_IN_PROPERTY_RX = /\s*!important\b\s*/i.freeze
|
|
37
37
|
|
|
38
|
+
# What sanitize_media_query would change: blank, padded, or not single-space separated
|
|
39
|
+
UNSANITIZED_MEDIA_QUERY_RX = /\A\z|\A[\s\0]|[\s\0]\z|\s\s|[^\S ]/.freeze
|
|
40
|
+
|
|
38
41
|
RE_INSIDE_OUTSIDE = regex_possible_values 'inside', 'outside'
|
|
39
42
|
RE_SCROLL_FIXED = regex_possible_values 'scroll', 'fixed'
|
|
40
43
|
RE_REPEAT = regex_possible_values 'repeat(\-x|\-y)*|no\-repeat'
|
|
@@ -61,20 +64,27 @@ module CssParser
|
|
|
61
64
|
RE_BORDER_UNITS = Regexp.union(BOX_MODEL_UNITS_RX, /(thin|medium|thick)/i)
|
|
62
65
|
|
|
63
66
|
# Functions like calc, var, clamp, etc.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
67
|
+
# Possessive quantifiers + recursion-first alternation avoid exponential
|
|
68
|
+
# backtracking on unclosed functions, the timeout is a backstop.
|
|
69
|
+
RE_FUNCTIONS = Regexp.new(
|
|
70
|
+
/
|
|
71
|
+
(
|
|
72
|
+
[a-z0-9-]+ # function name
|
|
73
|
+
)
|
|
74
|
+
(?>
|
|
75
|
+
\( # opening parenthesis
|
|
76
|
+
(?:
|
|
77
|
+
\g<0> # nested function
|
|
78
|
+
|
|
|
79
|
+
[^()a-z0-9]++
|
|
80
|
+
|
|
|
81
|
+
[a-z0-9-]++ (?!\()
|
|
82
|
+
)*
|
|
83
|
+
\) # closing parenthesis
|
|
84
|
+
)
|
|
85
|
+
/imx,
|
|
86
|
+
timeout: 0.01
|
|
87
|
+
).freeze
|
|
78
88
|
|
|
79
89
|
# Patterns for specificity calculations
|
|
80
90
|
NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX_NC = /
|
|
@@ -259,8 +269,10 @@ module CssParser
|
|
|
259
269
|
inherit
|
|
260
270
|
currentColor
|
|
261
271
|
].freeze
|
|
262
|
-
|
|
263
|
-
|
|
272
|
+
# CSS <number> allows the integer part to be omitted (e.g. `.1`), per CSS Values & Units.
|
|
273
|
+
# `(?:\d*\.)?\d+` accepts `1`, `1.5`, and `.5` while still rejecting bare `1.`.
|
|
274
|
+
RE_COLOUR_NUMERIC = /\b(hsl|rgb)\s*\(-?\s*-?(?:\d*\.)?\d+%?\s*%?,-?\s*-?(?:\d*\.)?\d+%?\s*%?,-?\s*-?(?:\d*\.)?\d+%?\s*%?\)/i.freeze
|
|
275
|
+
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
276
|
RE_COLOUR_HEX = /\s*#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/.freeze
|
|
265
277
|
RE_COLOUR_NAMED = /\s*\b(#{NAMED_COLOURS.join('|')})\b/i.freeze
|
|
266
278
|
RE_COLOUR = Regexp.union(RE_COLOUR_NUMERIC, RE_COLOUR_NUMERIC_ALPHA, RE_COLOUR_HEX, RE_COLOUR_NAMED)
|
data/lib/css_parser/rule_set.rb
CHANGED
|
@@ -18,6 +18,11 @@ module CssParser
|
|
|
18
18
|
|
|
19
19
|
NUMBER_OF_DIMENSIONS = 4
|
|
20
20
|
|
|
21
|
+
NORMALIZED_PROPERTY_RX = /\A[-a-z0-9_]+\z/.freeze
|
|
22
|
+
BLANK_VALUE_RX = /\A[\s\0]*\z/.freeze
|
|
23
|
+
SURROUNDING_WHITESPACE_RX = /\A[\s\0]|[\s\0]\z/.freeze
|
|
24
|
+
TRAILING_SEMICOLON_RX = /\s*;\s*\Z/.freeze
|
|
25
|
+
|
|
21
26
|
DIMENSIONS = [
|
|
22
27
|
['margin', %w[margin-top margin-right margin-bottom margin-left]],
|
|
23
28
|
['padding', %w[padding-top padding-right padding-bottom padding-left]],
|
|
@@ -35,6 +40,8 @@ module CssParser
|
|
|
35
40
|
RPAREN = ')'.freeze
|
|
36
41
|
IMPORTANT = '!important'.freeze
|
|
37
42
|
class Declarations
|
|
43
|
+
EMPTY_DECLARATIONS = {}.freeze
|
|
44
|
+
|
|
38
45
|
class Value
|
|
39
46
|
attr_reader :value
|
|
40
47
|
attr_accessor :important
|
|
@@ -45,12 +52,17 @@ module CssParser
|
|
|
45
52
|
end
|
|
46
53
|
|
|
47
54
|
def value=(value)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
value.
|
|
55
|
+
# sub and strip always return a copy, and the caller's string must not be mutated:
|
|
56
|
+
# only copy when there is something to remove, so a clean value allocates nothing
|
|
57
|
+
value = value.to_s
|
|
58
|
+
value = value.sub(TRAILING_SEMICOLON_RX, '') if value.match?(TRAILING_SEMICOLON_RX)
|
|
59
|
+
self.important = value.match?(CssParser::IMPORTANT_IN_PROPERTY_RX)
|
|
60
|
+
value = value.sub(CssParser::IMPORTANT_IN_PROPERTY_RX, '') if important
|
|
61
|
+
value = value.strip if value.match?(SURROUNDING_WHITESPACE_RX)
|
|
51
62
|
raise ArgumentError, 'value is empty' if value.empty?
|
|
52
63
|
|
|
53
|
-
|
|
64
|
+
# value may still be the caller's string: -value freezes a copy instead of it
|
|
65
|
+
@value = -value
|
|
54
66
|
end
|
|
55
67
|
|
|
56
68
|
def to_s
|
|
@@ -68,7 +80,7 @@ module CssParser
|
|
|
68
80
|
|
|
69
81
|
def_delegators :declarations, :each, :each_value
|
|
70
82
|
|
|
71
|
-
def initialize(declarations =
|
|
83
|
+
def initialize(declarations = EMPTY_DECLARATIONS)
|
|
72
84
|
self.declarations = {}
|
|
73
85
|
declarations.each { |property, value| add_declaration!(property, value) }
|
|
74
86
|
end
|
|
@@ -89,17 +101,20 @@ module CssParser
|
|
|
89
101
|
# puts declarations['margin']
|
|
90
102
|
# => #<CssParser::RuleSet::Declarations::Value:0x00000000030c1838 @important=true, @order=2, @value="0px auto">
|
|
91
103
|
#
|
|
92
|
-
# If the property already exists its value will be over-written
|
|
104
|
+
# If the property already exists its value will be over-written unless it was !important and the new value
|
|
105
|
+
# is not !important.
|
|
93
106
|
# If the value is empty - property will be deleted
|
|
94
107
|
def []=(property, value)
|
|
95
108
|
property = normalize_property(property)
|
|
109
|
+
currently_important = declarations[property]&.important
|
|
96
110
|
|
|
97
|
-
if value.is_a?(Value)
|
|
111
|
+
if value.is_a?(Value) && (!currently_important || value.important)
|
|
98
112
|
declarations[property] = value
|
|
99
|
-
elsif value.to_s.
|
|
113
|
+
elsif value.to_s.match?(BLANK_VALUE_RX)
|
|
100
114
|
delete property
|
|
101
115
|
else
|
|
102
|
-
|
|
116
|
+
value = Value.new(value)
|
|
117
|
+
declarations[property] = value if !currently_important || value.important
|
|
103
118
|
end
|
|
104
119
|
rescue ArgumentError => e
|
|
105
120
|
raise e.exception, "#{property} #{e.message}"
|
|
@@ -159,18 +174,19 @@ module CssParser
|
|
|
159
174
|
|
|
160
175
|
# We should preserve subsequent declarations of the same properties
|
|
161
176
|
# and prior important ones if replacement one is not important
|
|
162
|
-
replacements =
|
|
177
|
+
replacements = {}
|
|
178
|
+
replacement_declarations.declarations.each do |key, replacement|
|
|
163
179
|
existing = declarations[key]
|
|
164
180
|
|
|
165
181
|
# No existing -> set
|
|
166
182
|
unless existing
|
|
167
|
-
|
|
183
|
+
replacements[key] = replacement
|
|
168
184
|
next
|
|
169
185
|
end
|
|
170
186
|
|
|
171
187
|
# Replacement more important than existing -> replace
|
|
172
188
|
if replacement.important && !existing.important
|
|
173
|
-
|
|
189
|
+
replacements[key] = replacement
|
|
174
190
|
replaced_index = replacement_keys.index(key)
|
|
175
191
|
replacement_keys.delete_at(replaced_index)
|
|
176
192
|
replacement_values.delete_at(replaced_index)
|
|
@@ -183,7 +199,7 @@ module CssParser
|
|
|
183
199
|
|
|
184
200
|
# Existing and replacement importance are the same,
|
|
185
201
|
# value which is declared later wins
|
|
186
|
-
|
|
202
|
+
replacements[key] = replacement if property_index > replacement_keys.index(key)
|
|
187
203
|
end
|
|
188
204
|
|
|
189
205
|
return if replacements.empty?
|
|
@@ -194,16 +210,17 @@ module CssParser
|
|
|
194
210
|
replacement_values.delete_at(property_index)
|
|
195
211
|
replacement_values.insert(property_index, *replacements.values)
|
|
196
212
|
|
|
197
|
-
self.declarations =
|
|
213
|
+
self.declarations = {}
|
|
214
|
+
replacement_keys.each_with_index { |key, index| declarations[key] = replacement_values[index] }
|
|
198
215
|
end
|
|
199
216
|
|
|
200
217
|
def to_s(options = {})
|
|
201
|
-
str =
|
|
218
|
+
str = +''
|
|
219
|
+
declarations.each do |prop, value|
|
|
202
220
|
importance = options[:force_important] || value.important ? ' !important' : ''
|
|
203
|
-
|
|
221
|
+
str << prop << ': ' << value.value << importance << '; '
|
|
204
222
|
end
|
|
205
|
-
|
|
206
|
-
str.gsub!(/^[\s^({)]+|[\n\r\f\t]*|\s+$/mx, '')
|
|
223
|
+
str.gsub!(/^[\s^({)]+|[\n\r\f\t]+/, '')
|
|
207
224
|
str.strip!
|
|
208
225
|
str
|
|
209
226
|
end
|
|
@@ -223,7 +240,10 @@ module CssParser
|
|
|
223
240
|
attr_writer :declarations
|
|
224
241
|
|
|
225
242
|
def normalize_property(property)
|
|
226
|
-
property = property.to_s
|
|
243
|
+
property = property.to_s
|
|
244
|
+
return property if property.match?(NORMALIZED_PROPERTY_RX)
|
|
245
|
+
|
|
246
|
+
property = property.downcase
|
|
227
247
|
property.strip!
|
|
228
248
|
property
|
|
229
249
|
end
|
|
@@ -396,14 +416,14 @@ module CssParser
|
|
|
396
416
|
DIMENSIONS.each do |property, (top, right, bottom, left)|
|
|
397
417
|
next unless (declaration = declarations[property])
|
|
398
418
|
|
|
399
|
-
value = declaration.value
|
|
419
|
+
value = declaration.value
|
|
400
420
|
|
|
401
421
|
# RGB and HSL values in borders are the only units that can have spaces (within params).
|
|
402
422
|
# We cheat a bit here by stripping spaces after commas in RGB and HSL values so that we
|
|
403
423
|
# can split easily on spaces.
|
|
404
424
|
#
|
|
405
425
|
# TODO: rgba, hsl, hsla
|
|
406
|
-
value.gsub
|
|
426
|
+
value = value.gsub(RE_COLOUR) { |c| c.gsub(/(\s*,\s*)/, ',') } if value.include?(',')
|
|
407
427
|
|
|
408
428
|
matches = split_value_preserving_function_whitespace(value)
|
|
409
429
|
|
|
@@ -421,7 +441,7 @@ module CssParser
|
|
|
421
441
|
raise ArgumentError, "Cannot parse #{value}"
|
|
422
442
|
end
|
|
423
443
|
|
|
424
|
-
replacement = [
|
|
444
|
+
replacement = {top => values[0], right => values[1], bottom => values[2], left => values[3]}
|
|
425
445
|
|
|
426
446
|
declarations.replace_declaration!(property, replacement, preserve_importance: true)
|
|
427
447
|
end
|
|
@@ -515,17 +535,13 @@ module CssParser
|
|
|
515
535
|
|
|
516
536
|
# Combine several properties into a shorthand one
|
|
517
537
|
def create_shorthand_properties!(properties, shorthand_property) # :nodoc:
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
properties.each do |property|
|
|
521
|
-
next unless (declaration = declarations[property])
|
|
522
|
-
next if declaration.important
|
|
523
|
-
|
|
524
|
-
values << declaration.value
|
|
525
|
-
properties_to_delete << property
|
|
538
|
+
properties_to_delete = properties.select do |property|
|
|
539
|
+
(declaration = declarations[property]) && !declaration.important
|
|
526
540
|
end
|
|
527
541
|
|
|
528
|
-
return if
|
|
542
|
+
return if properties_to_delete.length <= 1
|
|
543
|
+
|
|
544
|
+
values = properties_to_delete.map { |property| declarations[property].value }
|
|
529
545
|
|
|
530
546
|
properties_to_delete.each do |property|
|
|
531
547
|
declarations.delete(property)
|
|
@@ -581,14 +597,11 @@ module CssParser
|
|
|
581
597
|
return if declarations.size < NUMBER_OF_DIMENSIONS
|
|
582
598
|
|
|
583
599
|
DIMENSIONS.each do |property, dimensions|
|
|
584
|
-
values = DIMENSION_DIRECTIONS.each_with_index.with_object({}) do |(side, index), result|
|
|
585
|
-
next unless (declaration = declarations[dimensions[index]])
|
|
586
|
-
|
|
587
|
-
result[side] = declaration.value
|
|
588
|
-
end
|
|
589
|
-
|
|
590
600
|
# All four dimensions must be present
|
|
591
|
-
next
|
|
601
|
+
next unless dimensions.all? { |dimension| declarations.key?(dimension) }
|
|
602
|
+
|
|
603
|
+
top, right, bottom, left = dimensions.map { |dimension| declarations[dimension].value }
|
|
604
|
+
values = {top: top, right: right, bottom: bottom, left: left}
|
|
592
605
|
|
|
593
606
|
new_value = values.values_at(*compute_dimensions_shorthand(values)).join(' ').strip
|
|
594
607
|
declarations[property] = new_value unless new_value.empty?
|
|
@@ -638,7 +651,7 @@ module CssParser
|
|
|
638
651
|
|
|
639
652
|
def compute_dimensions_shorthand(values)
|
|
640
653
|
# All four sides are equal, returning single value
|
|
641
|
-
return [:top] if values
|
|
654
|
+
return [:top] if values[:top] == values[:right] && values[:right] == values[:bottom] && values[:bottom] == values[:left]
|
|
642
655
|
|
|
643
656
|
# `/* top | right | bottom | left */`
|
|
644
657
|
return DIMENSION_DIRECTIONS if values[:left] != values[:right]
|
|
@@ -667,12 +680,13 @@ module CssParser
|
|
|
667
680
|
next unless (colon = decs.index(COLON))
|
|
668
681
|
|
|
669
682
|
property = decs[0, colon]
|
|
670
|
-
value = decs[
|
|
683
|
+
value = decs[colon + 1, decs.length]
|
|
671
684
|
property.strip!
|
|
672
685
|
value.strip!
|
|
673
|
-
|
|
686
|
+
# casecmp? case-folds both strings, allocating two of them per declaration
|
|
687
|
+
next if property.empty? || value.empty? || value.casecmp(IMPORTANT).zero?
|
|
674
688
|
|
|
675
|
-
add_declaration!(property, value)
|
|
689
|
+
add_declaration!(property, value.freeze)
|
|
676
690
|
continuation = nil
|
|
677
691
|
end
|
|
678
692
|
end
|
|
@@ -685,7 +699,7 @@ module CssParser
|
|
|
685
699
|
# TODO: way too simplistic
|
|
686
700
|
#++
|
|
687
701
|
def parse_selectors!(selectors) # :nodoc:
|
|
688
|
-
@selectors = selectors.split(',').map do |s|
|
|
702
|
+
@selectors = selectors.split(',').map! do |s|
|
|
689
703
|
s.gsub!(/\s+/, ' ')
|
|
690
704
|
s.strip!
|
|
691
705
|
s
|
|
@@ -693,10 +707,16 @@ module CssParser
|
|
|
693
707
|
end
|
|
694
708
|
|
|
695
709
|
def split_value_preserving_function_whitespace(value)
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
710
|
+
# hostile values can still hit the regexp timeout, then split without protecting functions
|
|
711
|
+
split_value =
|
|
712
|
+
begin
|
|
713
|
+
value.gsub(RE_FUNCTIONS) do |c|
|
|
714
|
+
c.gsub!(/\s+/, WHITESPACE_REPLACEMENT)
|
|
715
|
+
c
|
|
716
|
+
end
|
|
717
|
+
rescue Regexp::TimeoutError
|
|
718
|
+
value
|
|
719
|
+
end
|
|
700
720
|
|
|
701
721
|
matches = split_value.strip.split(/\s+/)
|
|
702
722
|
|
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
|
|
|
@@ -88,9 +85,10 @@ module CssParser
|
|
|
88
85
|
end
|
|
89
86
|
end
|
|
90
87
|
|
|
91
|
-
merged =
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
merged = RuleSet.new(nil, nil)
|
|
89
|
+
properties.each do |property, details|
|
|
90
|
+
value = details[:value]
|
|
91
|
+
merged[property] = details[:is_important] ? "#{value.gsub(/;\Z/, '')}!important" : value
|
|
94
92
|
end
|
|
95
93
|
|
|
96
94
|
merged.create_shorthand!
|
|
@@ -110,7 +108,7 @@ module CssParser
|
|
|
110
108
|
#++
|
|
111
109
|
def self.calculate_specificity(selector)
|
|
112
110
|
a = 0
|
|
113
|
-
b = selector.
|
|
111
|
+
b = selector.count('#')
|
|
114
112
|
c = selector.scan(NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX_NC).length
|
|
115
113
|
d = selector.scan(ELEMENTS_AND_PSEUDO_ELEMENTS_RX_NC).length
|
|
116
114
|
|
|
@@ -150,6 +148,8 @@ module CssParser
|
|
|
150
148
|
end
|
|
151
149
|
|
|
152
150
|
def self.sanitize_media_query(raw)
|
|
151
|
+
return raw if raw.is_a?(Symbol) && !raw.match?(UNSANITIZED_MEDIA_QUERY_RX)
|
|
152
|
+
|
|
153
153
|
mq = raw.to_s.gsub(/\s+/, ' ')
|
|
154
154
|
mq.strip!
|
|
155
155
|
mq = 'all' if mq.empty?
|
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:
|
|
4
|
+
version: 3.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Dunae
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
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,34 @@ dependencies:
|
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
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'
|
|
27
54
|
description: A set of classes for parsing CSS in Ruby.
|
|
28
55
|
email: code@dunae.ca
|
|
29
56
|
executables: []
|
|
@@ -44,7 +71,6 @@ metadata:
|
|
|
44
71
|
source_code_uri: https://github.com/premailer/css_parser
|
|
45
72
|
bug_tracker_uri: https://github.com/premailer/css_parser/issues
|
|
46
73
|
rubygems_mfa_required: 'true'
|
|
47
|
-
post_install_message:
|
|
48
74
|
rdoc_options: []
|
|
49
75
|
require_paths:
|
|
50
76
|
- lib
|
|
@@ -52,15 +78,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
52
78
|
requirements:
|
|
53
79
|
- - ">="
|
|
54
80
|
- !ruby/object:Gem::Version
|
|
55
|
-
version: '3.
|
|
81
|
+
version: '3.3'
|
|
56
82
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
83
|
requirements:
|
|
58
84
|
- - ">="
|
|
59
85
|
- !ruby/object:Gem::Version
|
|
60
86
|
version: '0'
|
|
61
87
|
requirements: []
|
|
62
|
-
rubygems_version:
|
|
63
|
-
signing_key:
|
|
88
|
+
rubygems_version: 4.0.3
|
|
64
89
|
specification_version: 4
|
|
65
90
|
summary: Ruby CSS parser.
|
|
66
91
|
test_files: []
|