Skip to content

SNI and TLS hostname verification support - #1638

Merged
scottf merged 6 commits into
mainfrom
tls-sni-hostname
Oct 8, 2026
Merged

scottf merged 6 commits into
mainfrom
tls-sni-hostname

Conversation

@scottf

@scottf scottf commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Since 2.16.9 the client resolves hostnames before connecting and hands the ip to the SSL socket factory, so no SNI is sent and an endpoint that selects its certificate by SNI serves its default certificate.

Features

  • The configured hostname is the TLS server name (SNI) in every HostnameResolveMode. NatsConnection passes the uri before resolution to the data port through a new DataPort.connect(conn, uri, unresolvedUri, timeoutNanos) default overload; SocketDataPort keeps it as tlsHost, uses it as the peer host and sets it with SSLParameters.setServerNames before the handshake.
  • Single label hostnames, such as a kubernetes service name nats, get SNI too. The JSSE does not derive SNI from a single label host argument; setting the name explicitly covers it.
  • A name the JSSE rejects, such as one with a trailing dot, falls back to the provider's default behavior instead of failing.
  • A server discovered as a bare ip presents the hostname of the server that supplied it, as nats.go has done since 2018. The connection passes the origin to a new ServerPool.acceptDiscoveredUrls(discoveredServers, origin) default overload; NatsServerPool stamps the origin's hostname on the new entry, one hop, without overriding explicit or already known servers; NatsUri.tlsHost with getTlsHost() and withTlsHost() carries it through reHost, taken from Preserve TLS hostname through DNS resolution and server discovery #1637; equality, hash code and string form unchanged.
  • Opt-in hostname verification: Options.Builder.tlsVerifyHostname(), property io.nats.client.tls.verify.hostname, isTlsVerifyHostname(), off by default. Sets the HTTPS endpoint identification algorithm on the socket; the trust manager checks the certificate against the server name, the gossiping server's name for a discovered bare ip, or the ip for an ip literal. Applies under the trust-all context, because the JDK wraps a plain X509TrustManager and checks in the wrapper; a custom X509ExtendedTrustManager is responsible for its own check.
  • Custom ServerPool and DataPort implementations are unaffected; both new methods are default methods that forward to the existing ones.

Bug fixes

  • HostnameResolveMode.Unresolved could not connect without a proxy: it always used InetSocketAddress.createUnresolved, which a plain socket rejects with UnknownHostException. Now createUnresolved only when a proxy is configured; otherwise one address is resolved at connect time through NatsInetAddress, as the client did before 2.16.9, with the hostname kept for TLS.
  • The deprecated noResolveHostnames(), the noResolveHostnames property and isNoResolveHostnames() mapped to ResolveToFirst since 2.25.2, which resolves in the client. They now map to Unresolved, their meaning from 2.16.9 through 2.25.1.

Tests

  • SniTestServer: a loopback NATS server that selects its certificate by SNI, with an expired default certificate; adapted from Preserve TLS hostname through DNS resolution and server discovery #1637 with a configurable certificate host, a DNS subject alternative name plus an optional ip one, and a mode that serves the valid certificate whatever name was requested. ExpiringClientCertUtil gains a subject alternative name overload.
  • hostnameIsSentAsSniInEveryMode: all six modes, TLS after INFO and TLS first, plus Unresolved through a loopback HTTP CONNECT tunnel, 14 executions; each asserts the pool was asked to resolve only in the resolving modes, and the tunneled runs assert the proxy received the hostname, not an ip.
  • singleLabelHostnameIsSentAsSniInEveryMode: the same 14 with the host nats.
  • explicitIpSendsNoSniAndGetsTheDefaultCertificate: the control case, 2 executions; no SNI, expired certificate, CertificateExpiredException.
  • dataPortIsGivenTheUnresolvedUri: the data port receives the resolved ip and the unresolved hostname and uses the hostname.
  • dataPortWithoutTheUnresolvedUriStillSendsAnUnresolvedHostname: a data port implementing only the older connect still sends the name in a non resolving mode.
  • verifyHostnameAcceptsACertificateForTheHostname: default mode, resolved to 127.0.0.1, certificate for the hostname with no ip SAN, accepted, 2 executions.
  • verifyHostnameRejectsACertificateForAnotherName: a valid, trusted certificate for other.test is rejected with the identity error, and accepted by the same server with the option off.
  • verifyHostnameChecksAnIpLiteralAgainstIpSubjectAlternativeNames: tls://127.0.0.1 accepted with an ip SAN and rejected without one.
  • verifyHostnameAppliesUnderTheTrustAllContext: the other.test certificate is rejected under SSLUtils.createTrustAllTlsContext().
  • discoveredIpRetainsHostnameOnReconnect: the first server gossips the second as a bare ip; after the first closes, the reconnect presents the first server's hostname and gets the valid certificate, 2 executions.
  • discoveredIpIsVerifiedByTheGossipingServersName: the same reconnect with verification on, against a certificate with a DNS name and no ip SAN.
  • tlsHostnameSurvivesResolutionWithoutChangingUriIdentity: reHost to IPv4 and IPv6 keeps the name; equality, hash code and string form unchanged; withTlsHost likewise.
  • discoverySavesTheOriginHostnameAndHonorsIgnoreDiscoveredServers: the origin's hostname is saved on a discovered bare ip, ignoreDiscoveredServers is honored, and the overload without an origin saves nothing.
  • discoveryKeepsTlsNamesPerEntryAndDoesNotOverrideExplicitServers: names are per entry, explicit servers and already known entries are not overridden, and an ip origin or a discovered ip carrying a name supplies nothing.
  • OptionsTests.testTlsVerifyHostname: default, builder, copy, property true and false.
  • OptionsTests: the noResolveHostnames builder method and property now expect Unresolved, and ResolveToFirst no longer reports isNoResolveHostnames.

TlsHostnameTests 43 of 43 and OptionsTests 70 of 70 pass, along with TLSConnectTests, ServerPoolTests, ConnectTests, ReconnectTests, WebsocketConnectTests, MaxReconnectResolvedIpsTest, NatsProvidersAndImplementationsTests and SocketDataPortProxyHostnameTest.

…stname resolution mode

Since 2.16.9 the client resolves a hostname to ip addresses before connecting and hands
the ip to the SSL socket factory, so no SNI is sent. An endpoint that selects its
certificate by SNI, like a TLS terminating proxy or ingress, then serves its default
certificate. Before 2.16.9 the hostname reached the TLS layer because the JDK resolved
inside InetSocketAddress and the host string was kept.

- DataPort: a default connect overload that also receives the uri before resolution.
  Existing implementations are unchanged.
- NatsConnection: tryToConnect passes the unresolved uri to the data port.
- SocketDataPort: keeps the hostname as tlsHost, uses it as the peer host and sets it
  explicitly with SSLParameters.setServerNames before the handshake, in every mode.
  The explicit call also covers a single label hostname, such as a kubernetes service
  name, which the JSSE does not derive from the peer host on its own.
- Tests: SniTestServer, a loopback server that selects its certificate by SNI, adapted
  from PR #1637, and TlsHostnameTests covering all six HostnameResolveMode values with
  TLS after INFO and TLS first, a single label hostname, the explicit ip control case
  and the data port hand-off. Unresolved runs through a loopback HTTP CONNECT tunnel,
  the only way that mode connects.

Not covered: a bare ip learned from connect_urls still has no hostname to send.
@synadia-claude-reviewer

synadia-claude-reviewer Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Claude finished @scottf's task in 1m 44s —— View job


Review of PR #1638

  • Gather context (PR diff, description, related code)
  • Review DataPort.java changes
  • Review NatsConnection.java changes
  • Review SocketDataPort.java changes (core logic)
  • Review test files (TlsHostnameTests, SniTestServer, ExpiringClientCertUtil)
  • Post review summary

Summary

This is a small, well-targeted fix: SocketDataPort now remembers the pre-resolution hostname (tlsHost) and uses it both as the TLS peer host for createSocket and as an explicit SNIHostName via SSLParameters.setServerNames, so SNI reaches the server regardless of HostnameResolveMode.

Correctness checks performed:

  • NatsConnection.tryToConnect (line 603) passes cur (the server-pool entry before resolveHost) through both the initial-connect and reconnect paths — consistent with the new DataPort.connect(conn, uri, unresolvedUri, timeoutNanos) overload.
  • SocketDataPort is rebuilt per connection attempt (this.options.buildDataPort() in NatsConnection), so tlsHost can't leak stale state across reconnects.
  • The default method on DataPort (DataPort.java:42) preserves backward compatibility for data ports that only implement the 3-arg connect, and the fallback in SocketDataPort.connect (3-arg, line ~90) still sets tlsHost from host when the 4-arg overload wasn't used — matches the "legacy data port" test case.
  • The documented gap (bare IP learned from connect_urls has no hostname to send SNI for) is real and consistent with the code (unresolvedUri.hostIsIpAddress() guards tlsHost assignment) — correctly disclosed as out of scope rather than silently broken.
  • The IllegalArgumentException catch around new SNIHostName(tlsHost) (SocketDataPort.java:132) is narrowly scoped to the JSSE's own name validation and falls back to default behavior rather than failing the connection — reasonable.
  • SSLParameters is fetched, mutated, and set back (getSSLParameters()/setSSLParameters()) correctly — no shared mutable state since each socket gets its own params object.

No bugs, security issues, or thread-safety concerns found in the production code changes (DataPort.java, NatsConnection.java, SocketDataPort.java). The test additions (SniTestServer, TlsHostnameTests) are thorough and self-contained (loopback only, no external DNS/network dependency), matching the PR's stated test matrix.

scottf added 3 commits October 8, 2026 12:14
…eHostnames maps to it

Unresolved built the socket address with InetSocketAddress.createUnresolved in every case.
A plain socket rejects an unresolved address with UnknownHostException, so the mode only
connected through a proxy. Now createUnresolved is used only when a proxy is configured,
which is what it is for; without one, a single address is resolved at connect time through
NatsInetAddress, as the client did before hostname resolution was added in 2.16.9. The
hostname is kept as the TLS server name in both cases.

The deprecated noResolveHostnames() builder method, the noResolveHostnames property and
isNoResolveHostnames() mapped to ResolveToFirst since 2.25.2, which resolves in the client.
From 2.16.9 through 2.25.1 they meant that the client does not resolve. They now map to
Unresolved, which restores that meaning.

Tests: the TlsHostnameTests matrix runs Unresolved on a direct connection in addition to the
proxied run, and every execution asserts whether the server pool was asked to resolve, which
only the resolving modes do. OptionsTests expectations follow the mapping.
…erifyHostname

Off by default. When on, SocketDataPort sets the HTTPS endpoint identification algorithm on
the SSL socket before the handshake, so the trust manager checks that the certificate the
server presents is issued for the server name: the configured hostname in every
HostnameResolveMode, since the hostname is now the SNI server name in every mode, or the ip
address when the server was configured or discovered by ip address.

The check is made by the trust manager in use. The JDK performs it for its own trust
managers and for any plain X509TrustManager it wraps, which includes the trust-all manager
of opentls; a custom X509ExtendedTrustManager is responsible for its own identity check.

Builder method tlsVerifyHostname(), property io.nats.client.tls.verify.hostname, getter
isTlsVerifyHostname(), copied by Builder(Options).

Tests: the generated server certificates now carry a DNS subject alternative name, with an
optional ip one, and SniTestServer can serve its valid certificate whatever name was
requested. TlsHostnameTests: a matching certificate is accepted in the default mode, a
valid certificate for another name is rejected with the identity error and accepted when
the option is off, an ip literal is checked against ip subject alternative names, and the
check applies under the trust-all context. OptionsTests covers the option.
… server that supplied it

A server learned from connect_urls as a bare ip address has no hostname to present as the
TLS server name, and nothing to verify its certificate against but the ip. As nats.go has
done since 2018 with tlsName, it now carries the hostname of the server whose INFO supplied
it, one hop only: an origin that is itself an ip address supplies nothing.

- ServerPool: a default overload acceptDiscoveredUrls(discoveredServers, origin) that
  forwards to the existing method, so custom pools are unaffected.
- NatsConnection: handleInfo passes the origin, currentServer or, while the initial INFO
  is being read, the server being connected to.
- NatsServerPool: a newly discovered entry whose host is an ip address gets the origin's
  hostname when the origin was configured with one. Explicit servers and already known
  entries are untouched.
- NatsUri: tlsHost with getTlsHost() and withTlsHost(), carried through reHost. Equality,
  hash code and the string form are unchanged. Taken from PR #1637.
- SocketDataPort: the unresolved uri's tls host is the server name to present.

Tests: reconnect to a gossiped bare ip presents the first server's hostname, TLS after INFO
and TLS first; the same reconnect with tlsVerifyHostname on, against a certificate with a DNS
name and no ip subject alternative name, passes because the check is against that hostname;
NatsUri identity is unchanged by the name; the pool saves the origin's hostname per entry,
honors ignoreDiscoveredServers, does not override explicit servers, saves nothing from an ip
origin or through the overload without an origin.
@scottf scottf changed the title Send the configured hostname as the TLS server name (SNI) in every hostname resolution mode SNI and TLS hostname verification support Oct 8, 2026
@scottf

scottf commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

@claude re-review there have been many changes since your first review

@synadia-claude-reviewer

synadia-claude-reviewer Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Claude finished @scottf's task in 3m 21s —— View job


Re-review of PR #1638

  • Gather context (PR diff, description, prior review)
  • Review Options.java changes (tlsVerifyHostname)
  • Review ServerPool.java / NatsServerPool.java changes (acceptDiscoveredUrls + origin)
  • Review NatsUri.java changes (tlsHost)
  • Review DataPort.java / SocketDataPort.java / NatsConnection.java changes
  • Review test changes (TlsHostnameTests, SniTestServer, ExpiringClientCertUtil, OptionsTests)
  • Post final review summary

Summary

Reviewed the full diff against origin/main (4 commits, 11 files). The branch has grown substantially since the first pass (items 2–4 from the PR description — Unresolved mode, tlsVerifyHostname, and bare-IP origin stamping — are all new since then). Everything checks out:

Options.java — tlsVerifyHostname boolean is threaded consistently through Builder, property parsing (PROP_TLS_VERIFY_HOSTNAME), the copy constructor, and the getter. noResolveHostnames()/PROP_NO_RESOLVE_HOSTNAMES and isNoResolveHostnames() now correctly map to/from Unresolved instead of ResolveToFirst, restoring pre-2.16.9 semantics as described.

NatsUri.java — tlsHost/withTlsHost/getTlsHost are a clean addition. reHost preserves the TLS host correctly: it carries forward getTlsHost() only when there's something meaningful to preserve (tlsHost != null || !hostIsIpAddress), so a plain IP-to-IP rehost (e.g. 127.0.0.1 → 127.0.0.2 with no saved name) correctly leaves tlsHost unset. Confirmed equals/hashCode/toString are untouched by the new field (verified via tlsHostnameSurvivesResolutionWithoutChangingUriIdentity).

ServerPool.java / NatsServerPool.java — The new acceptDiscoveredUrls(urls, origin) default method preserves binary compatibility for custom pools. NatsServerPool only stamps the origin's hostname onto newly discovered bare-IP entries (not already-known ones), and only one hop (an IP-literal or already-tagged origin passes nothing onward) — matches discoveryKeepsTlsNamesPerEntryAndDoesNotOverrideExplicitServers and discoverySavesTheOriginHostnameAndHonorsIgnoreDiscoveredServers.

NatsConnection.java — The new connectingServer field, read in handleInfo as currentServer == null ? connectingServer : currentServer, correctly resolves the chicken-and-egg problem: the initial INFO (which carries the first connect_urls) is handled synchronously inside tryToConnect before currentServer is set, so it needs the pre-assignment value. Later async INFO updates (via the reader thread) run after currentServer is set and after connectingServer's only writer (tryToConnect, which stops reader/writer before writing it) has quiesced, so there's no race between the two paths.

SocketDataPort.java — The 4-arg connect sets tlsHost from unresolvedUri.getTlsHost() precisely when there's a real hostname to present (not a bare IP with no saved name); the 3-arg fallback still derives tlsHost from host for data ports that don't implement the new overload, matching the dataPortWithoutTheUnresolvedUriStillSendsAnUnresolvedHostname test. SNI is set via SSLParameters.setServerNames, with a narrow IllegalArgumentException catch for names JSSE's SNIHostName rejects. tlsVerifyHostname sets setEndpointIdentificationAlgorithm("HTTPS") so the trust manager performs the check — applies even under opentls()'s trust-all context per verifyHostnameAppliesUnderTheTrustAllContext.

Tests — SniTestServer (SNI-based cert selection via a custom X509ExtendedKeyManager) and TlsHostnameTests (43 executions across modes/proxy/verification/discovery) are self-contained on loopback, no external DNS. The matrix in allModes() and the IP-literal, single-label-hostname, legacy-data-port, reconnect-to-discovered-IP, and verification accept/reject cases all align with the claims in the PR description. OptionsTests covers the Unresolved remapping and the new property/builder flag.

No bugs, thread-safety issues, or correctness gaps found. Nothing further to request.

@scottf

scottf commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Why hostname verification is opt-in in this PR

Turning it on changes which connections succeed, and the Java client has never checked, so an existing deployment could break on upgrade without changing a line of its own configuration.

The connections that would start failing with it on by default:

  • A server configured by ip literal whose certificate has no ip subject alternative name. Common with internal certificates.
  • A server whose certificate names something other than the host in the URL: an internal alias, a load balancer name, a certificate issued for the backend name but reached through a different front name.
  • A self-signed or test certificate that carries only a Common Name that does not match. The Common Name fallback no longer applies once the certificate has any subject alternative name.
  • Before the carry-over in this PR, every reconnect to a bare ip learned from connect_urls. That case is covered here, but only here.

Every other client verifies by default, and that is the right end state. They have done so from the start, so their users have always had matching certificates. Java users have not had to, so some certificates in the field are valid only because nothing checks them. Flipping the default in a 2.x release would surface those as connection failures with no code change on the user's side. Opt-in gets the capability into 2.x now, lets the deployments that want it turn it on, and leaves the default flip for V3, where a behavior change is expected.

The same reasoning does not apply to SNI itself, which this PR does turn on by default: sending a name the server can ignore does not fail a connection that worked before, except at an endpoint that rejects unrecognized names.

@wallyqs wallyqs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@scottf
scottf merged commit f73eac6 into main Oct 8, 2026
5 checks passed
@scottf
scottf deleted the tls-sni-hostname branch October 8, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants