RubyGems · css_parser
Ruby CSS Parser: SSRF and Local File Disclosure in `CssParser::Parser#read_remote_file`
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.
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:
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).| 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. |
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:
{ becomes the selector for that rule (including text spanning multiple lines).prop: value; pairs inside { ... } are retained. The Declarations parser splits on ;, requires :, and drops anything else (rule_set.rb#L655-L681).{ 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.
It is not a classic blind SSRF. The bug has two recovery channels:
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 repoSources: 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.