Skip to content

Add Subresource Integrity (SRI) verification for remote stylesheets - #190

Merged
grosser merged 4 commits into
premailer:masterfrom
JLLeitschuh:feature/subresource-integrity
Sep 9, 2026
Merged

Add Subresource Integrity (SRI) verification for remote stylesheets#190
grosser merged 4 commits into
premailer:masterfrom
JLLeitschuh:feature/subresource-integrity

Conversation

@JLLeitschuh

@JLLeitschuh JLLeitschuh commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

Adds an integrity: option to Parser#load_uri! that verifies a fetched remote stylesheet against a Subresource Integrity value before it's parsed — the same value an HTML <link integrity="..."> attribute carries.

parser.load_uri!(
  'http://example.com/styles/style.css',
  integrity: 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC'
)
  • Supports sha256/sha384/sha512, multiple space-separated values in one integrity: string (exactly like the HTML attribute), and the spec's "agility" rule: when a value lists more than one algorithm, only the strongest present one is actually checked, and every weaker one is ignored outright — e.g. sha256-... sha384-... only checks the sha384 value. When several values are given for that same strongest algorithm (e.g. during a planned key/stylesheet rotation), matching any one of them is enough.
  • A value naming only an algorithm this library doesn't recognize is treated as unverifiable (passes through) rather than failing every such fetch.
  • On mismatch, the fetch fails the same way other remote-fetch failures already do here: raises CssParser::RemoteFileError when io_exceptions is enabled, otherwise loads nothing.

Why

Consumers of this gem that already know the expected digest of a linked stylesheet (for example, because it came from an HTML <link integrity="..."> attribute they're processing) currently have no way to ask load_uri! to verify it — the fetched body is trusted unconditionally regardless of what the caller expected. This gives callers an opt-in way to fail closed instead.

Testing

  • New test/test_css_parser_integrity.rb, covering: normal pass-through when the option is omitted, all three supported algorithms, mismatch handling (with and without io_exceptions), the multi-algorithm "agility" rule in both directions (weaker-correct+stronger-wrong fails, weaker-wrong+stronger-correct passes), multiple acceptable values for one algorithm, and the unsupported-algorithm-is-unverifiable case.
  • Full existing suite passes (bundle exec rake test) — no behavior change when integrity: isn't passed.
  • bundle exec rubocop clean on the changed files.

Happy to adjust the option name/shape or add doc updates elsewhere if you'd rather it live somewhere other than load_uri!.

JLLeitschuh and others added 2 commits September 1, 2026 14:48
Adds an `integrity:` option to `Parser#load_uri!` that verifies a fetched
remote stylesheet against a Subresource Integrity value
(https://www.w3.org/TR/SRI/) before it is parsed, mirroring the
`integrity` attribute browsers already support on `<link>`/`<script>`
tags. Supports sha256/sha384/sha512, multiple space-separated values,
and the SRI "agility" rule (only the strongest present algorithm is
checked). A value naming only an unrecognized algorithm is treated as
unverifiable rather than failing the fetch.

On mismatch, the fetch fails the same way other remote-fetch failures
already do: raises CssParser::RemoteFileError when io_exceptions is
enabled, otherwise loads nothing. Useful for any caller that already
knows the expected digest of a linked stylesheet and wants a stale or
unexpectedly-changed response to fail closed rather than be silently
parsed and applied.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Ruby 3.4+ removed `base64` from default gems (it's now a bundled gem
that must be an explicit dependency to be resolvable). `require 'base64'`
in parser.rb was relying on it still being present by default, which
breaks under Ruby 4.0 CI with a LoadError. Declare it in the gemspec.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@JLLeitschuh

Copy link
Copy Markdown
Contributor Author

Fixed — Ruby 3.4+/4.0 removed base64 from default gems, so require 'base64' needs it declared as an explicit runtime dependency. Added s.add_dependency 'base64' and updated Gemfile.lock accordingly. Full suite + rubocop pass locally on 3.3.9; should now pass on the 4.0 CI job too.

@JLLeitschuh

Copy link
Copy Markdown
Contributor Author

Re: the JRuby job failure (CssParserOffsetCaptureTests#test_capturing_offsets_from_remote_file, RemoteFileError: https://dialect.ca/inc/screen.css) — that's pre-existing and unrelated to this PR:

  • It reproduces identically against unmodified master (I hit the same error before making any changes here).
  • The test never passes the new integrity: option, so this PR's new code path isn't reached by it at all.
  • The test itself is unchanged since 2021 (3a3b21e) and makes a live network call to a real third-party site — dialect.ca/inc/screen.css is reachable right now, so it's not permanently dead, just flaky in CI (possibly a JRuby/JVM TLS-stack quirk against that host, since JRuby uses its own TLS implementation rather than MRI's OpenSSL, per this repo's "Drop jruby-openssl dependency" history).

Flagging so it's not mistaken for a regression from this change.

Adds a second README example showing the multi-value/multi-algorithm
form of the `integrity:` option (as SRI itself allows), since the
existing example only showed a single sha384 value and didn't make
the "strongest algorithm wins" behavior visible without reading the
implementation.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread lib/css_parser/parser.rb
# Subresource Integrity value -- a single `<algorithm>-<base64 digest>` 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the strongest and not the first ?

@JLLeitschuh JLLeitschuh Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the spec:

3.2.1. Agility
Multiple sets of integrity metadata may be associated with a single resource in order to provide agility in the face of future cryptographic discoveries.
...
In this case, the user agent will choose the strongest hash function in the list, and use that metadata to validate the response (as described below in the § 3.3.2 Parse metadata and § 3.3.3 Get the strongest metadata from set algorithms).

https://www.w3.org/TR/sri/#agility

Per the documentation:

How browsers handle Subresource Integrity

When a browser encounters a <script> or element with an integrity attribute, before executing the script or before applying any stylesheet specified by the element, the browser must first compare the script or stylesheet to the expected hashes given in the integrity value.

The different hash functions have different strengths: from weaker to stronger, the order is SHA-256, SHA-384, SHA-512. When the browser downloads a resource with the integrity attribute set, it will first select the set of hashes that were generated using the strongest hash function present. That is, if the attribute contains values generated with SHA-256 and SHA-384, it will only use the hashes generated using SHA-384. It will ignore all other hashes.

The browser will then calculate the hash of the resource contents using the specified function, and compare the result with all the specified values: if the actual value matches any of the specified values, then the browser will load the resource, otherwise it will refuse to load the resource, and return a network error.

This means that developers can:

  • Provide multiple values using different hash functions, and the browser will use only the strongest function provided.
  • Provide multiple values using the same hash function, and the browser will validate the attribute if any of them match: this enables a developer to provide alternate versions of a resource, while still checking their integrity.

https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_Integrity#how_browsers_handle_subresource_integrity

TL;DR: This is just following the specification

Comment thread lib/css_parser/parser.rb Outdated
Comment on lines +771 to +775
digest_class = {
'sha512' => Digest::SHA512,
'sha384' => Digest::SHA384,
'sha256' => Digest::SHA256
}.fetch(algorithm)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about we make INTEGRITY_ALGORITHM_PRIORITY = {"sha512" => [0, Digest::SHA512], ...} so we don't have 2 different structures that need to be in sync

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. Will do!

Comment thread lib/css_parser/parser.rb
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 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

multiple values for the same algorithm is expected ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes... One browser use-case is a stylesheet change that requires supporting two hashes during a change and knowing a CDN will serve one for a while until the cache expires.

This is tested by test_multiple_values_for_the_same_algorithm_accepts_any_match

Comment thread README.md Outdated
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doubt many ppl will use this, so put it further at the back + in it's own section

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reorganized -- moved the SRI usage examples out of the main Usage block into their own "# Subresource Integrity" section further down the README, right before Testing. Also pushed the other two changes from your comments below (merged the algorithm/digest-class structures, added IntegrityError).

Comment thread lib/css_parser/parser.rb Outdated
end

if integrity && !integrity_matches?(res.body, integrity)
raise RemoteFileError, uri.to_s if @options[:io_exceptions]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe new error that inherits from RemoteFileError ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a new IntegrityError < RemoteFileError. Thanks for the suggestion!

@grosser grosser left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks pretty good, a few nit comments/questions, otherwise 👍

…ap, README reorg)

Per review from @grosser:

- Add CssParser::IntegrityError < RemoteFileError, raised specifically on
  an SRI mismatch, so callers can distinguish it from other fetch
  failures (404, SSRF rejection, timeout) while remaining catchable by
  existing `rescue RemoteFileError` code. Required a small fix to the
  method's catch-all rescue, which previously downgraded any exception
  raised inside it (including the new IntegrityError) back to a plain
  RemoteFileError -- now only IntegrityError specifically is let through
  unchanged, preserving existing behavior/tests for other error paths.
- Merge INTEGRITY_ALGORITHM_PRIORITY and the inline digest-class lookup
  into one INTEGRITY_ALGORITHMS hash (algorithm => Digest class, ordered
  strongest first), so there's a single structure to keep in sync instead
  of two.
- Move the Subresource Integrity usage examples out of the main Usage
  code block into their own README section, since it's a less-common,
  advanced option.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@grosser
grosser merged commit 2625755 into premailer:master Sep 9, 2026
5 checks passed
@grosser

grosser commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

v3.1.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants