LaunchKit
← All posts
· 6 min read · by The LaunchKit team · 1 views

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.

Five certificates offered to Ruby's Net::HTTP with verify_mode at its default of VERIFY_PEER. An expired certificate is rejected with certificate has expired. A self-signed one is rejected as self-signed certificate. A certificate for the wrong hostname is rejected with hostname mismatch. One signed by an untrusted root is rejected as self-signed certificate in certificate chain. A revoked certificate is accepted and the request returns HTTP 200. Underneath, an attempt to fix this by setting the CRL check flag on the certificate store is shown failing with unable to get certificate CRL, because OpenSSL requires a revocation list it has no way to fetch, so the flag breaks every connection rather than adding the missing check.

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.

#rails #security #http

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.