Skip to content

Commit 44bce7d

Browse files
karesclaude
andcommitted
enforce verify_hostname during SSLSocket#connect
CRuby checks verify_hostname inside the OpenSSL verify callback during the TLS handshake (ossl_ssl.c, depth 0). JSSE has no equivalent hook, so we check after the handshake completes — connect raises SSLError on hostname mismatch, matching CRuby behavior. Libraries like net/http and net/imap that already call post_connection_check will double-check harmlessly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4d78c3c commit 44bce7d

2 files changed

Lines changed: 91 additions & 0 deletions

File tree

src/main/java/org/jruby/ext/openssl/SSLSocket.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,15 @@ private IRubyObject connectImpl(final ThreadContext context, final boolean block
321321
catch (NotYetConnectedException e) {
322322
throw newErrnoEPIPEError(context.runtime, "SSL_connect");
323323
}
324+
325+
// CRuby enforces verify_hostname inside the OpenSSL verify callback
326+
// during the handshake (ossl_ssl.c ossl_ssl_verify_callback, depth 0).
327+
// JSSE has no equivalent hook, so we check after the handshake completes.
328+
// This is functionally equivalent — connect raises SSLError on mismatch.
329+
// Note: net/http and net/imap also call post_connection_check explicitly,
330+
// so this may double-check in those cases (harmless, same result).
331+
verifyHostnameIfRequired(context);
332+
324333
return this;
325334
}
326335

@@ -410,6 +419,18 @@ private IRubyObject acceptImpl(final ThreadContext context, final boolean blocki
410419
return this;
411420
}
412421

422+
private void verifyHostnameIfRequired(final ThreadContext context) {
423+
final IRubyObject verifyHostname = sslContext.getInstanceVariable("@verify_hostname");
424+
if (verifyHostname == null || !verifyHostname.isTrue()) return;
425+
426+
final IRubyObject hostname = getInstanceVariable("@hostname");
427+
if (hostname == null || hostname.isNil()) return;
428+
429+
// delegates to post_connection_check (defined in Ruby) which calls
430+
// OpenSSL::SSL.verify_certificate_identity(peer_cert, hostname)
431+
callMethod(context, "post_connection_check", hostname);
432+
}
433+
413434
final IRubyObject verify_mode(final ThreadContext context) {
414435
final CallSite[] sites = getMetaClass().getExtraCallSites();
415436
if (sites == null) return fallback_verify_mode(context);

src/test/ruby/ssl/test_ssl.rb

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,76 @@ def create_cert_with_san(san)
119119

120120
public
121121

122+
# Ported from CRuby's test_verify_hostname_on_connect (test/openssl/test_ssl.rb).
123+
# Verifies that SSLSocket#connect enforces verify_hostname automatically.
124+
# On CRuby this is checked inside the OpenSSL verify callback during handshake;
125+
# on JRuby it is checked after the JSSE handshake completes (equivalent effect).
126+
def test_verify_hostname_on_connect
127+
now = Time.now
128+
exts = [
129+
["keyUsage", "keyEncipherment,digitalSignature", true],
130+
["subjectAltName", "DNS:a.example.com,DNS:*.b.example.com", false],
131+
]
132+
@svr_cert = issue_cert(@svr, @svr_key, 4, exts, @ca_cert, @ca_key,
133+
not_before: now, not_after: now + 1800)
134+
135+
start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true) do |server, port|
136+
ctx = OpenSSL::SSL::SSLContext.new
137+
ctx.verify_hostname = true
138+
ctx.cert_store = OpenSSL::X509::Store.new
139+
ctx.cert_store.add_cert(@ca_cert)
140+
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
141+
142+
[
143+
["a.example.com", true],
144+
["A.Example.Com", true],
145+
["x.example.com", false],
146+
["b.example.com", false],
147+
["x.b.example.com", true],
148+
].each do |name, expected_ok|
149+
begin
150+
sock = TCPSocket.new("127.0.0.1", port)
151+
ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
152+
ssl.hostname = name
153+
if expected_ok
154+
ssl.connect
155+
else
156+
assert_raise(OpenSSL::SSL::SSLError) { ssl.connect }
157+
end
158+
ensure
159+
ssl&.close rescue nil
160+
sock&.close rescue nil
161+
end
162+
end
163+
end
164+
end
165+
166+
def test_verify_hostname_not_enforced_when_disabled
167+
now = Time.now
168+
exts = [
169+
["keyUsage", "keyEncipherment,digitalSignature", true],
170+
["subjectAltName", "DNS:a.example.com", false],
171+
]
172+
@svr_cert = issue_cert(@svr, @svr_key, 4, exts, @ca_cert, @ca_key,
173+
not_before: now, not_after: now + 1800)
174+
175+
start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true) do |server, port|
176+
# verify_hostname defaults to false/nil — mismatched hostname should succeed
177+
ctx = OpenSSL::SSL::SSLContext.new
178+
ctx.cert_store = OpenSSL::X509::Store.new
179+
ctx.cert_store.add_cert(@ca_cert)
180+
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
181+
182+
sock = TCPSocket.new("127.0.0.1", port)
183+
ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
184+
ssl.hostname = "wrong.example.com"
185+
ssl.connect # should succeed — verify_hostname is not set
186+
ensure
187+
ssl&.close rescue nil
188+
sock&.close rescue nil
189+
end
190+
end
191+
122192
def test_post_connect_check_with_anon_ciphers
123193
unless OpenSSL::ExtConfig::TLS_DH_anon_WITH_AES_256_GCM_SHA384
124194
return skip('OpenSSL::ExtConfig::TLS_DH_anon_WITH_AES_256_GCM_SHA384 not enabled')

0 commit comments

Comments
 (0)