diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75d28dd..e3ef2f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
## Ruby CSS Parser CHANGELOG
### Unreleased
+* `Parser#load_uri!` accepts an `integrity:` option (Subresource Integrity, https://www.w3.org/TR/SRI/) to verify a fetched remote stylesheet before it is parsed
### Version 3.0.0
* Harden read_remote_file, use `allow_local_network: true` and `allow_file_uris: true` to bypass
diff --git a/Gemfile.lock b/Gemfile.lock
index f4c369e..077ebe0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -3,6 +3,7 @@ PATH
specs:
css_parser (3.0.0)
addressable
+ base64
ssrf_filter (~> 1.5)
GEM
@@ -11,6 +12,7 @@ GEM
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
+ base64 (0.2.0)
benchmark-ips (2.14.0)
bump (0.10.0)
json (2.18.1)
diff --git a/README.md b/README.md
index 7a34d29..50d0659 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,26 @@ parser.load_uri!('file://home/user/styles/style.css')
# load a remote file, setting the base_uri and media_types
parser.load_uri!('../style.css', {base_uri: 'http://example.com/styles/inc/', media_types: [:screen, :handheld]})
+# load a remote file, verifying it against a Subresource Integrity value
+# (https://www.w3.org/TR/SRI/) before parsing -- e.g. the value of an
+# HTML attribute. Raises CssParser::RemoteFileError
+# (or, with io_exceptions: false, loads nothing) when the fetched body
+# doesn't match.
+parser.load_uri!('http://example.com/styles/style.css', integrity: 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC')
+
+# `integrity:` also accepts several space-separated values, exactly like the
+# HTML attribute does. When more than one hash algorithm is present, only the
+# strongest one is checked (sha512 > sha384 > sha256) and every weaker value is
+# ignored; multiple values for that same strongest algorithm are treated as
+# alternatives -- matching any one of them is enough.
+parser.load_uri!(
+ 'http://example.com/styles/style.css',
+ integrity: 'sha256-Br6tO8uuFyBAw2O0eUNdXVyuS/POLb5jpHxXaxIq6Q0= sha384-0gCPKBW0n+VzQzZu5gzP+YMxy9QTLyn1y/O/TMvLTpVajzRKAx6d7TiPB5W7DnDn'
+)
+# ^ only the sha384 value is actually checked here; the sha256 one is present
+# (e.g. for browsers/tools that only understand sha256) but ignored by this
+# library since a stronger algorithm is also listed.
+
# load a local file, setting the base_dir and media_types
parser.load_file!('print.css', '~/styles/', :print)
diff --git a/css_parser.gemspec b/css_parser.gemspec
index ef1eb0c..2d0697b 100644
--- a/css_parser.gemspec
+++ b/css_parser.gemspec
@@ -19,5 +19,6 @@ Gem::Specification.new name, CssParser::VERSION do |s|
s.metadata['rubygems_mfa_required'] = 'true'
s.add_dependency 'addressable'
+ s.add_dependency 'base64'
s.add_dependency 'ssrf_filter', '~> 1.5'
end
diff --git a/lib/css_parser/parser.rb b/lib/css_parser/parser.rb
index c3289f8..af8fe04 100644
--- a/lib/css_parser/parser.rb
+++ b/lib/css_parser/parser.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
require 'strscan'
+require 'digest'
+require 'base64'
module CssParser
# Exception class used for any errors encountered while downloading remote files.
@@ -19,6 +21,9 @@ class CircularReferenceError < StandardError; end
# [io_exceptions] Throw an exception if a link can not be found. Boolean, default is true.
# [allow_local_network] Permit http(s) fetches against loopback / private / link-local / cloud-metadata addresses. Boolean, default is false. When false (the default), outbound HTTP requests are routed through ssrf_filter, which resolves the host and rejects unsafe IP ranges. Set to true only when the destination is known to be safe (e.g. local fixture servers in tests). Independent of allow_file_uris.
# [allow_file_uris] Permit file:// URIs via load_uri!. Boolean, default is false. When false (the default), a caller that passes a file:// URI to load_uri! — directly or via a CSS @import resolved against a file:// base_uri — is refused, closing the local-file-disclosure vector when the URI is influenced by user input. load_file! is unaffected: it is the explicit local-file API and takes a caller-supplied path. Independent of allow_local_network.
+ #
+ # load_uri! also accepts a per-call :integrity option (see its documentation) for
+ # verifying a remote stylesheet against a Subresource Integrity value before it is parsed.
class Parser
USER_AGENT = "Ruby CSS Parser/#{CssParser::VERSION} (https://github.com/premailer/css_parser)".freeze
RULESET_TOKENIZER_RX = /\s+|\\{2,}|\\?[{}\s"]|[()]|.[^\s"{}()\\]*/.freeze
@@ -37,6 +42,13 @@ class Parser
# was GHSA-9pmc-p236-855h.
REMOTE_ALLOWED_SCHEMES = %w[http https].freeze
+ # Subresource Integrity hash algorithms this library can verify,
+ # strongest first. Mirrors the SRI spec's "agility" rule
+ # (https://www.w3.org/TR/SRI/#agility): when a caller-supplied
+ # `integrity` value lists more than one algorithm, only the
+ # strongest one present is checked.
+ INTEGRITY_ALGORITHM_PRIORITY = %w[sha512 sha384 sha256].freeze
+
# Array of CSS files that have been loaded.
attr_reader :loaded_uris
@@ -492,7 +504,12 @@ def parse_block_into_rule_sets!(block, options = {}) # :nodoc:
#
# You can also pass in file://test.css
#
- # See add_block! for options.
+ # See add_block! for options. In addition to those, :integrity accepts a
+ # Subresource Integrity value (https://www.w3.org/TR/SRI/) -- e.g. the value of an
+ # HTML attribute -- and, for http(s) URIs, verifies the
+ # fetched response body against it before the CSS is parsed. When the digest does not
+ # match, the fetch is treated as a failure: an exception is raised if io_exceptions
+ # is enabled, otherwise nothing is loaded. Ignored for file:// URIs.
#
# Deprecated: originally accepted three params: `uri`, `base_uri` and `media_types`
def load_uri!(uri, options = {}, deprecated = nil)
@@ -538,7 +555,7 @@ def load_uri!(uri, options = {}, deprecated = nil)
end
read_local_file(uri)
else
- src_and_charset, = read_remote_file(uri) # skip charset
+ src_and_charset, = read_remote_file(uri, integrity: opts[:integrity]) # skip charset
src_and_charset
end
@@ -677,10 +694,14 @@ def read_local_file(uri) # :nodoc:
# is still validated on every redirect hop, so cross-scheme
# redirect to `file://` (the original GHSA-9pmc-p236-855h sink)
# remains closed even on this opt-in path.
+ #
+ # `integrity:`, when given, is verified against the raw response body
+ # (before charset decoding, matching Subresource Integrity semantics)
+ # -- see `integrity_matches?` and `load_uri!`'s documentation.
#--
# TODO: add option to fail silently or throw and exception on a 404
#++
- def read_remote_file(uri) # :nodoc:
+ def read_remote_file(uri, integrity: nil) # :nodoc:
uri = Addressable::URI.parse(uri.to_s)
unless circular_reference_check(uri.to_s)
@@ -711,6 +732,12 @@ def read_remote_file(uri) # :nodoc:
return '', nil
end
+ if integrity && !integrity_matches?(res.body, integrity)
+ raise RemoteFileError, uri.to_s if @options[:io_exceptions]
+
+ return nil, nil
+ end
+
charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
src = res.body
src.encode!('UTF-8', charset) if charset
@@ -723,6 +750,33 @@ def read_remote_file(uri) # :nodoc:
end
end
+ # Verifies +body+ (raw response bytes, not yet charset-decoded) against a
+ # Subresource Integrity value -- a single `-` token, or
+ # several whitespace-separated tokens (https://www.w3.org/TR/SRI/#the-integrity-attribute).
+ # Tokens using an algorithm this library doesn't recognize are ignored; per the spec's
+ # "agility" rule, when multiple recognized algorithms are present only the strongest one
+ # is checked. A value containing no recognized algorithm is treated as unverifiable and
+ # matches by default, rather than failing every fetch whenever a caller passes a stronger
+ # or newer algorithm than this library currently supports.
+ def integrity_matches?(body, integrity) # :nodoc:
+ candidates = integrity.to_s.split.filter_map do |token|
+ algorithm, value = token.split('-', 2)
+ [algorithm, value] if algorithm && value && INTEGRITY_ALGORITHM_PRIORITY.include?(algorithm)
+ end
+ return true if candidates.empty?
+
+ algorithm = candidates.map(&:first).min_by { |a| INTEGRITY_ALGORITHM_PRIORITY.index(a) }
+ expected_values = candidates.select { |a, _v| a == algorithm }.map { |_a, v| v }
+
+ digest_class = {
+ 'sha512' => Digest::SHA512,
+ 'sha384' => Digest::SHA384,
+ 'sha256' => Digest::SHA256
+ }.fetch(algorithm)
+
+ expected_values.include?(Base64.strict_encode64(digest_class.digest(body)))
+ end
+
# Net::HTTP path used only when `allow_local_network: true`. Validates
# the URI scheme on every redirect hop so a `Location: file://...`
# cannot be followed even on this opt-in code path.
diff --git a/test/test_css_parser_integrity.rb b/test/test_css_parser_integrity.rb
new file mode 100644
index 0000000..8727b90
--- /dev/null
+++ b/test/test_css_parser_integrity.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+require_relative 'test_helper'
+
+# Tests for `Parser#load_uri!`'s `:integrity` option, which verifies a
+# fetched remote stylesheet against a Subresource Integrity value
+# (https://www.w3.org/TR/SRI/) before it is parsed -- the same mechanism
+# an HTML `` attribute describes.
+class CssParserIntegrityTests < Minitest::Test
+ include CssParser
+ include WEBrick
+
+ PORT = 12_011
+
+ def setup
+ @www_root = File.expand_path('fixtures', __dir__)
+ @fixture_file = File.expand_path('fixtures/simple.css', __dir__)
+ @fixture_body = File.binread(@fixture_file)
+ @uri_base = "http://127.0.0.1:#{PORT}"
+
+ # `:integrity` verification only applies on the remote-fetch path, so
+ # these tests use `allow_local_network: true` against a loopback
+ # fixture server rather than mocking the HTTP layer -- matching the
+ # approach `test_allow_local_network_opt_in_permits_loopback` uses in
+ # test_css_parser_ssrf.rb.
+ @server_thread = Thread.new do
+ s = WEBrick::HTTPServer.new(
+ Port: PORT, BindAddress: '127.0.0.1', DocumentRoot: @www_root,
+ Logger: Log.new(nil, BasicLog::FATAL), AccessLog: []
+ )
+ begin
+ s.start
+ ensure
+ s.shutdown
+ end
+ end
+
+ sleep 1
+ end
+
+ def teardown
+ @server_thread.kill
+ @server_thread.join(5)
+ @server_thread = nil
+ end
+
+ def cp
+ Parser.new(allow_local_network: true)
+ end
+
+ def sha(algorithm, body = @fixture_body)
+ digest_class = {'sha256' => Digest::SHA256, 'sha384' => Digest::SHA384, 'sha512' => Digest::SHA512}.fetch(algorithm)
+ "#{algorithm}-#{Base64.strict_encode64(digest_class.digest(body))}"
+ end
+
+ def test_load_uri_without_integrity_option_is_unaffected
+ cp.load_uri!("#{@uri_base}/simple.css")
+ # no-op: reaching here without an exception is the assertion.
+ end
+
+ def test_matching_sha384_integrity_loads_normally
+ parser = cp
+ parser.load_uri!("#{@uri_base}/simple.css", integrity: sha('sha384'))
+ assert_equal 'margin: 0px;', parser.find_by_selector('p').join(' ')
+ end
+
+ def test_matching_sha256_integrity_loads_normally
+ parser = cp
+ parser.load_uri!("#{@uri_base}/simple.css", integrity: sha('sha256'))
+ assert_equal 'margin: 0px;', parser.find_by_selector('p').join(' ')
+ end
+
+ def test_matching_sha512_integrity_loads_normally
+ parser = cp
+ parser.load_uri!("#{@uri_base}/simple.css", integrity: sha('sha512'))
+ assert_equal 'margin: 0px;', parser.find_by_selector('p').join(' ')
+ end
+
+ def test_mismatched_integrity_is_refused
+ tampered = "#{sha('sha384')[0, 15]}not-the-real-digest-at-all=="
+ assert_raises(CssParser::RemoteFileError) do
+ cp.load_uri!("#{@uri_base}/simple.css", integrity: tampered)
+ end
+ end
+
+ def test_mismatched_integrity_without_io_exceptions_loads_nothing
+ parser = Parser.new(allow_local_network: true, io_exceptions: false)
+ tampered = "#{sha('sha384')[0, 15]}not-the-real-digest-at-all=="
+ parser.load_uri!("#{@uri_base}/simple.css", integrity: tampered)
+ assert_empty parser.find_by_selector('p')
+ end
+
+ def test_strongest_algorithm_wins_when_multiple_present_and_it_fails
+ # A correct sha256 paired with a wrong sha512 must fail -- the spec's
+ # "agility" rule means only the strongest present algorithm (sha512
+ # here) is authoritative, so a right-but-weaker value must not mask
+ # a wrong-but-stronger one.
+ value = "#{sha('sha256')} sha512-#{Base64.strict_encode64('not the real digest')}"
+ assert_raises(CssParser::RemoteFileError) do
+ cp.load_uri!("#{@uri_base}/simple.css", integrity: value)
+ end
+ end
+
+ def test_strongest_algorithm_wins_when_multiple_present_and_it_passes
+ # Mirror of the above: a wrong sha256 paired with a correct sha512
+ # must still pass, since sha512 is the one actually checked.
+ wrong_sha256 = "sha256-#{Base64.strict_encode64('not the real digest')}"
+ value = "#{wrong_sha256} #{sha('sha512')}"
+ parser = cp
+ parser.load_uri!("#{@uri_base}/simple.css", integrity: value)
+ assert_equal 'margin: 0px;', parser.find_by_selector('p').join(' ')
+ end
+
+ def test_multiple_values_for_the_same_algorithm_accepts_any_match
+ # The spec allows several acceptable digests for the same algorithm
+ # (e.g. during a stylesheet rotation) -- any match should pass.
+ other_value = "sha384-#{Base64.strict_encode64('some other build of the file')}"
+ value = "#{other_value} #{sha('sha384')}"
+ parser = cp
+ parser.load_uri!("#{@uri_base}/simple.css", integrity: value)
+ assert_equal 'margin: 0px;', parser.find_by_selector('p').join(' ')
+ end
+
+ def test_unrecognized_algorithm_only_value_is_unverifiable_and_passes
+ # md5 is not in INTEGRITY_ALGORITHM_PRIORITY. A value naming only an
+ # unsupported algorithm can't be checked either way, so it's treated
+ # as unverifiable rather than failing every such fetch.
+ parser = cp
+ parser.load_uri!("#{@uri_base}/simple.css", integrity: 'md5-deadbeef==')
+ assert_equal 'margin: 0px;', parser.find_by_selector('p').join(' ')
+ end
+
+ def test_blank_integrity_option_is_unaffected
+ parser = cp
+ parser.load_uri!("#{@uri_base}/simple.css", integrity: '')
+ assert_equal 'margin: 0px;', parser.find_by_selector('p').join(' ')
+ end
+end