css_parser 3.0.0 → 3.3.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 +100 -7
- data/lib/css_parser/regexps.rb +21 -14
- data/lib/css_parser/rule_set.rb +10 -4
- data/lib/css_parser/version.rb +1 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1b890ee0d537d338ae0a218c68424271242859ba814a52c27fcd05ffefe1acd
|
|
4
|
+
data.tar.gz: 147e41dae4da802d96124fc07b488b122967a44c0ff79bfe623291c1795e0d9b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 76728b4b816f4b620f28198b6fd5f92f005733bec3afba0df5707d8daf99ab650fd0a0143a6222fc53c5453c32c3a13a76eb4877f3c06f5dc903a559a18d200f
|
|
7
|
+
data.tar.gz: a5b5a901312932dd9d3c50fa9a6cd4ea549180f57a7e4d85d700d5c3ec58f458927be66c2eb7169165333e153d58061dbdfa98c75c0e6ffb6a81181bc78fdd6f
|
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
|
|
|
@@ -37,6 +42,15 @@ module CssParser
|
|
|
37
42
|
# was GHSA-9pmc-p236-855h.
|
|
38
43
|
REMOTE_ALLOWED_SCHEMES = %w[http https].freeze
|
|
39
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
|
+
|
|
40
54
|
# Array of CSS files that have been loaded.
|
|
41
55
|
attr_reader :loaded_uris
|
|
42
56
|
|
|
@@ -159,6 +173,21 @@ module CssParser
|
|
|
159
173
|
load_uri!(import_uri, import_options)
|
|
160
174
|
elsif options[:base_dir]
|
|
161
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]
|
|
162
191
|
load_file!(import_path, import_options)
|
|
163
192
|
end
|
|
164
193
|
end
|
|
@@ -492,9 +521,7 @@ module CssParser
|
|
|
492
521
|
#
|
|
493
522
|
# You can also pass in file://test.css
|
|
494
523
|
#
|
|
495
|
-
# See add_block! for options
|
|
496
|
-
#
|
|
497
|
-
# Deprecated: originally accepted three params: `uri`, `base_uri` and `media_types`
|
|
524
|
+
# See add_block! for options and integrity_matches? for integrity
|
|
498
525
|
def load_uri!(uri, options = {}, deprecated = nil)
|
|
499
526
|
uri = Addressable::URI.parse(uri) unless uri.respond_to? :scheme
|
|
500
527
|
|
|
@@ -502,7 +529,7 @@ module CssParser
|
|
|
502
529
|
|
|
503
530
|
if options.is_a? Hash
|
|
504
531
|
opts.merge!(options)
|
|
505
|
-
else
|
|
532
|
+
else # TODO: start raising
|
|
506
533
|
warn '[DEPRECATION] `load_uri!` with positional arguments is deprecated. ' \
|
|
507
534
|
'Please use keyword arguments instead.', uplevel: 1
|
|
508
535
|
opts[:base_uri] = options if options.is_a? String
|
|
@@ -538,7 +565,7 @@ module CssParser
|
|
|
538
565
|
end
|
|
539
566
|
read_local_file(uri)
|
|
540
567
|
else
|
|
541
|
-
src_and_charset, = read_remote_file(uri) # skip charset
|
|
568
|
+
src_and_charset, = read_remote_file(uri, integrity: opts[:integrity]) # skip charset
|
|
542
569
|
src_and_charset
|
|
543
570
|
end
|
|
544
571
|
|
|
@@ -546,8 +573,18 @@ module CssParser
|
|
|
546
573
|
end
|
|
547
574
|
|
|
548
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.
|
|
549
586
|
def load_file!(file_name, options = {}, deprecated = nil)
|
|
550
|
-
opts = {base_dir: nil, media_types: :all}
|
|
587
|
+
opts = {base_dir: nil, media_types: :all, from_import: false, import_root: nil}
|
|
551
588
|
|
|
552
589
|
if options.is_a? Hash
|
|
553
590
|
opts.merge!(options)
|
|
@@ -558,7 +595,29 @@ module CssParser
|
|
|
558
595
|
opts[:media_types] = deprecated if deprecated
|
|
559
596
|
end
|
|
560
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
|
+
|
|
561
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
|
+
|
|
562
621
|
return unless File.readable?(file_name)
|
|
563
622
|
return unless circular_reference_check(file_name)
|
|
564
623
|
|
|
@@ -677,10 +736,14 @@ module CssParser
|
|
|
677
736
|
# is still validated on every redirect hop, so cross-scheme
|
|
678
737
|
# redirect to `file://` (the original GHSA-9pmc-p236-855h sink)
|
|
679
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.
|
|
680
743
|
#--
|
|
681
744
|
# TODO: add option to fail silently or throw and exception on a 404
|
|
682
745
|
#++
|
|
683
|
-
def read_remote_file(uri) # :nodoc:
|
|
746
|
+
def read_remote_file(uri, integrity: nil) # :nodoc:
|
|
684
747
|
uri = Addressable::URI.parse(uri.to_s)
|
|
685
748
|
|
|
686
749
|
unless circular_reference_check(uri.to_s)
|
|
@@ -711,11 +774,19 @@ module CssParser
|
|
|
711
774
|
return '', nil
|
|
712
775
|
end
|
|
713
776
|
|
|
777
|
+
if integrity && !integrity_matches?(res.body, integrity)
|
|
778
|
+
raise IntegrityError, uri.to_s if @options[:io_exceptions]
|
|
779
|
+
|
|
780
|
+
return nil, nil
|
|
781
|
+
end
|
|
782
|
+
|
|
714
783
|
charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
|
|
715
784
|
src = res.body
|
|
716
785
|
src.encode!('UTF-8', charset) if charset
|
|
717
786
|
|
|
718
787
|
[src, charset]
|
|
788
|
+
rescue IntegrityError # already is a RemoteFileError
|
|
789
|
+
raise
|
|
719
790
|
rescue
|
|
720
791
|
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
721
792
|
|
|
@@ -723,6 +794,28 @@ module CssParser
|
|
|
723
794
|
end
|
|
724
795
|
end
|
|
725
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
|
+
|
|
726
819
|
# Net::HTTP path used only when `allow_local_network: true`. Validates
|
|
727
820
|
# the URI scheme on every redirect hop so a `Location: file://...`
|
|
728
821
|
# cannot be followed even on this opt-in code path.
|
data/lib/css_parser/regexps.rb
CHANGED
|
@@ -61,20 +61,27 @@ module CssParser
|
|
|
61
61
|
RE_BORDER_UNITS = Regexp.union(BOX_MODEL_UNITS_RX, /(thin|medium|thick)/i)
|
|
62
62
|
|
|
63
63
|
# Functions like calc, var, clamp, etc.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
64
|
+
# Possessive quantifiers + recursion-first alternation avoid exponential
|
|
65
|
+
# backtracking on unclosed functions, the timeout is a backstop.
|
|
66
|
+
RE_FUNCTIONS = Regexp.new(
|
|
67
|
+
/
|
|
68
|
+
(
|
|
69
|
+
[a-z0-9-]+ # function name
|
|
70
|
+
)
|
|
71
|
+
(?>
|
|
72
|
+
\( # opening parenthesis
|
|
73
|
+
(?:
|
|
74
|
+
\g<0> # nested function
|
|
75
|
+
|
|
|
76
|
+
[^()a-z0-9]++
|
|
77
|
+
|
|
|
78
|
+
[a-z0-9-]++ (?!\()
|
|
79
|
+
)*
|
|
80
|
+
\) # closing parenthesis
|
|
81
|
+
)
|
|
82
|
+
/imx,
|
|
83
|
+
timeout: 0.01
|
|
84
|
+
).freeze
|
|
78
85
|
|
|
79
86
|
# Patterns for specificity calculations
|
|
80
87
|
NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX_NC = /
|
data/lib/css_parser/rule_set.rb
CHANGED
|
@@ -696,10 +696,16 @@ module CssParser
|
|
|
696
696
|
end
|
|
697
697
|
|
|
698
698
|
def split_value_preserving_function_whitespace(value)
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
699
|
+
# hostile values can still hit the regexp timeout, then split without protecting functions
|
|
700
|
+
split_value =
|
|
701
|
+
begin
|
|
702
|
+
value.gsub(RE_FUNCTIONS) do |c|
|
|
703
|
+
c.gsub!(/\s+/, WHITESPACE_REPLACEMENT)
|
|
704
|
+
c
|
|
705
|
+
end
|
|
706
|
+
rescue Regexp::TimeoutError
|
|
707
|
+
value
|
|
708
|
+
end
|
|
703
709
|
|
|
704
710
|
matches = split_value.strip.split(/\s+/)
|
|
705
711
|
|
data/lib/css_parser/version.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: 3.
|
|
4
|
+
version: 3.3.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: 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'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: ssrf_filter
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|