high

CVE-2026-53727

RubyGems · css_parser

Summary

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

Severity
high
EPSS
0.4% (p30)
CWE
CWE-918
Also known as
GHSA-9pmc-p236-855h
Published
2026-07-09
Updated
2026-07-09

Advisory details

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)

    if uri.scheme == 'file'
      # local file
      path = uri.path
      path.gsub!(%r{^/}, '') if Gem.win_platform?
      src = File.read(path, mode: 'rb')        # <-- arbitrary local read
    else
      # remote file
      if uri.scheme == 'https'
        uri.port = 443 unless uri.port
        http = Net::HTTP.new(uri.host, uri.port)
        http.use_ssl = true
      else
        http = Net::HTTP.new(uri.host, uri.port)  # <-- arbitrary host:port
      end

      res = http.get(uri.request_uri, ...)
      ...
      elsif res.code.to_i >= 300 and res.code.to_i < 400
        unless res['Location'].nil?
          return read_remote_file Addressable::URI.parse(...)  # <-- cross-scheme redirect
        end
      end
    ...

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:

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:

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 parse

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.