Add Subresource Integrity (SRI) verification for remote stylesheets - #190
Conversation
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>
|
Fixed — Ruby 3.4+/4.0 removed |
|
Re: the JRuby job failure (
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>
| # 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 |
There was a problem hiding this comment.
why the strongest and not the first ?
There was a problem hiding this comment.
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.
TL;DR: This is just following the specification
| digest_class = { | ||
| 'sha512' => Digest::SHA512, | ||
| 'sha384' => Digest::SHA384, | ||
| 'sha256' => Digest::SHA256 | ||
| }.fetch(algorithm) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Good callout. Will do!
| 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 } |
There was a problem hiding this comment.
multiple values for the same algorithm is expected ?
There was a problem hiding this comment.
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
| # 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 |
There was a problem hiding this comment.
doubt many ppl will use this, so put it further at the back + in it's own section
There was a problem hiding this comment.
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).
| end | ||
|
|
||
| if integrity && !integrity_matches?(res.body, integrity) | ||
| raise RemoteFileError, uri.to_s if @options[:io_exceptions] |
There was a problem hiding this comment.
maybe new error that inherits from RemoteFileError ?
There was a problem hiding this comment.
Adding a new IntegrityError < RemoteFileError. Thanks for the suggestion!
grosser
left a comment
There was a problem hiding this comment.
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>
|
v3.1.0 |
What
Adds an
integrity:option toParser#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.sha256/sha384/sha512, multiple space-separated values in oneintegrity: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.CssParser::RemoteFileErrorwhenio_exceptionsis 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 askload_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
test/test_css_parser_integrity.rb, covering: normal pass-through when the option is omitted, all three supported algorithms, mismatch handling (with and withoutio_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.bundle exec rake test) — no behavior change whenintegrity:isn't passed.bundle exec rubocopclean 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!.