Your Rails app accepts revoked certificates
A certificate authority has been in the news this week for key material that turned out to be trivially breakable, with Scott Helme writing it up as eight bits of trust. Read the source for what happened to them. The question worth answering here's the one that lands on you afterwards: when certificates get revoked, does your Rails application care?
Measured on Ruby 4.0.5 with OpenSSL 3.6.2, using badssl.com, which exists for exactly this.
Four rejections and one acceptance
%w[expired self-signed wrong.host untrusted-root revoked].each do |name|
Net::HTTP.start("#{name}.badssl.com", 443, use_ssl: true) { |http| http.get("/").code }
end
What came back:
| Host | Result |
|---|---|
expired.badssl.com |
rejected, certificate has expired |
self-signed.badssl.com |
rejected, self-signed certificate |
wrong.host.badssl.com |
rejected, hostname mismatch |
untrusted-root.badssl.com |
rejected, self-signed certificate in certificate chain |
revoked.badssl.com |
accepted, HTTP 200 |
That last row is the article. verify_mode was 1, which is VERIFY_PEER, the default and the
correct setting. Nothing was turned off. Ruby checked the signature chain, checked the dates,
checked the hostname, and never asked whether the certificate was still valid.
A revoked certificate is one whose issuer has publicly said: stop trusting this. It's the mechanism the whole system falls back on when a key is stolen or a CA is compromised. Your application doesn't consult it.
The switch that looks like the fix
OpenSSL has a flag for this and Ruby exposes it, so the obvious move's to turn it on:
store = OpenSSL::X509::Store.new
store.set_default_paths
store.flags = OpenSSL::X509::V_FLAG_CRL_CHECK
Net::HTTP.start("revoked.badssl.com", 443, use_ssl: true, cert_store: store) { |h| h.get("/") }
# OpenSSL::SSL::SSLError: unable to get certificate CRL
It didn't start checking revocation. It started failing.
V_FLAG_CRL_CHECK tells OpenSSL a revocation list is required, and OpenSSL hasn't any mechanism to
go and fetch one. You'd have to download every issuer's CRL yourself, keep them fresh, and
load them into the store, for every CA that might sign something you talk to. Turn the flag on
without doing that and every HTTPS call in your application fails, including the ones to
certificates that are perfectly fine.
OCSP, the online protocol that was supposed to replace CRLs, is not wired up either:
Net::HTTP.instance_methods.grep(/ocsp/i) # => []
OpenSSL::SSL::SSLContext.instance_methods.grep(/ocsp/i) # => []
The primitives exist deeper in the OpenSSL binding. Nothing in the HTTP client reaches for them.
This is the web, not Ruby
Worth being fair about. Revocation checking over the network was tried and it didn't work: a CRL for a large CA is megabytes, OCSP added a round trip to a third party on every connection, and both fail open, because a browser that refuses to load a page when a revocation server's down is a browser nobody uses.
So browsers stopped asking. Chrome and Firefox ship their own curated revocation lists, pushed out with updates, which is the only approach that has worked. Ruby hasn't an equivalent, there's no update channel to push one through, and nothing's watching for compromised CAs on your behalf.
Which means the honest summary is not that Ruby is broken. It is that revocation is not a thing you have, and if your threat model includes a compromised CA, you need something else.
Pinning, for the calls that matter
You won't do this for every outbound request. You might do it for the one that moves money.
EXPECTED = "a1b2c3..." # SHA256 of the server's public key, obtained out of band
http = Net::HTTP.new("api.payments.example", 443)
http.use_ssl = true
http.verify_callback = lambda do |preverify_ok, store_context|
return false unless preverify_ok
cert = store_context.current_cert
return true unless cert.subject == store_context.chain.first.subject # leaf only
OpenSSL::Digest::SHA256.hexdigest(cert.public_key.to_der) == EXPECTED
end
Pinning the public key rather than the certificate is the version that survives renewal, as long as the provider reuses the key pair. It also means an expiring pin becomes an outage, so whatever you pin needs a calendar entry and a documented rotation, and that operational cost is the real reason most teams should pin exactly one thing and not fifteen.
The alternative for a bigger estate is to move the problem outward: an egress proxy that does revocation properly, in one place, with one team maintaining the lists. That's the same answer browsers arrived at, just with you running the update channel.
And the 161 certificates you already trust
OpenSSL::X509::DEFAULT_CERT_FILE # => "/opt/homebrew/etc/openssl@3/cert.pem"
# certificates inside it: 161
Any one of those authorities can issue a certificate for any hostname, and your application will accept it. That's the design, it's what makes the web work, and it's also why a single CA having a bad week is everybody's problem.
Worth knowing where that file comes from on your production image, because it isn't the same file
as the one on your laptop. A Ruby built against a different OpenSSL, a slim container with a
stale ca-certificates package, or a SSL_CERT_FILE environment variable someone set years ago
all change the answer, and none of them announce themselves.
What this page doesn't cover
No inbound TLS. Everything here is your application acting as a client. What your own certificate does, how it's served and whether your load balancer staples an OCSP response is a separate subject with different answers.
The pinning snippet is written from the API documentation and was not run against a live pinned
endpoint, which is the one piece of code in this post that isn't measured. Nothing here tests
Faraday, HTTParty or any other client either: they sit on Net::HTTP or on libcurl and the
answer may differ, particularly for the libcurl ones, which is worth checking for whichever your
payment provider's SDK uses.
Comments
No comments yet. Be the first.