Never install a TrustManager or HostnameVerifier that accepts everything. Put the partner's private CA in a Spring Boot SSL bundle (spring.ssl.bundle.pem.partner.truststore.certificate) and build the HttpClient from sslBundles.getBundle("partner").createSslContext(). The JDK then checks the chain, the dates and the host name; leave jdk.internal.httpclient.disableHostnameVerification unset.
- Find every place that loosens TLS: trust managers with an empty or name-only checkServerTrusted, HostnameVerifiers that return true (Apache HttpClient's NoopHostnameVerifier, HttpsURLConnection.setDefaultHostnameVerifier), TrustAllStrategy and TrustSelfSignedStrategy, Netty's InsecureTrustManagerFactory, and jdk.internal.httpclient.disableHostnameVerification in JVM options or JAVA_TOOL_OPTIONS.
- For a public service, delete the custom SSLContext: java.net.http.HttpClient uses the JDK's cacerts and checks the host name by default.
- For a private service, declare the CA as an SSL bundle: spring.ssl.bundle.pem.partner.truststore.certificate=file:/etc/partner/ca.pem. The PEM file may hold several CA certificates during a rotation.
- Build only that client's HttpClient with .sslContext(sslBundles.getBundle("partner").createSslContext()). The bundle's trust store holds just the partner's CA, and the JDK's PKIX validation and host name check run as usual. With spring-boot-starter-restclient, restClientBuilder.apply(restClientSsl.fromBundle("partner")) does the same.
- Without Spring Boot, load the PEM into a KeyStore, initialise a PKIX TrustManagerFactory with it and pass its trust managers to SSLContext.init. Never write the TrustManager yourself.
- If the certificate does not name the host you connect to, add the name to its subjectAltName rather than reaching for a HostnameVerifier or the JVM flag.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
// Staging failed with "PKIX path building failed", so a trust manager that trusts
// everything went in. This is the bug: every certificate chain is accepted.
private static final X509TrustManager TRUST_ALL = new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
};
public static HttpClient create(SslBundles sslBundles) throws GeneralSecurityException {
SSLContext trustAll = SSLContext.getInstance("TLS");
trustAll.init(null, new TrustManager[] {TRUST_ALL}, null);
return HttpClient.newBuilder()
.sslContext(trustAll)
.connectTimeout(Duration.ofSeconds(5))
.build();
}With IMPL=vulnerable, 3 of 5 tests fail (3 exploit tests).
The fix: trust only the partner's CA
// application.properties (PEM; the file may hold several CAs during a rotation):
// spring.ssl.bundle.pem.partner.truststore.certificate=file:/etc/partner/ca.pem
public static HttpClient create(SslBundles sslBundles) {
// The bundle's trust store holds only the partner's CA, so the JDK's PKIX checks run
// against it alone. java.net.http.HttpClient checks the host name itself; never set
// jdk.internal.httpclient.disableHostnameVerification.
SSLContext partnerTrust = sslBundles.getBundle("partner").createSslContext();
return HttpClient.newBuilder()
.sslContext(partnerTrust)
.connectTimeout(Duration.ofSeconds(5))
.build();
}With IMPL=fixed, 5 of 5 tests pass.
A fix that looks safe but is not
// The partner's CA is not in the JDK's cacerts, so "PKIX path building failed". Accept
// the server when its certificate is issued to the partner's host name.
private static final X509TrustManager PARTNER_BY_NAME = new X509TrustManager() {
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
String subject = chain[0].getSubjectX500Principal().getName();
if (!subject.equals("CN=api.partner.test")) {
throw new CertificateException("not the partner: " + subject);
}
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
throw new CertificateException("client certificates are not used");
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
};
public static HttpClient create(SslBundles sslBundles) throws GeneralSecurityException {
SSLContext byName = SSLContext.getInstance("TLS");
byName.init(null, new TrustManager[] {PARTNER_BY_NAME}, null);
return HttpClient.newBuilder()
.sslContext(byName)
.connectTimeout(Duration.ofSeconds(5))
.build();
}With IMPL=wrong, 3 of 5 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Each server below answers at api.partner.test but must not be trusted. The client has to
// refuse the TLS handshake before it sends the request carrying the bearer token.
@Test
void exploitSelfSignedCertificateForThePartnerHostIsRefused() throws Exception {
assertRefused("selfSigned");
}
@Test
void exploitCertificateFromTheTrustedCaForAnotherHostIsRefused() throws Exception {
assertRefused("otherHost");
}
@Test
void exploitCertificateFromAnUnknownCaNamedLikeTheTrustedCaIsRefused() throws Exception {
assertRefused("rogueCa");
}
@Test
void exploitExpiredCertificateFromTheTrustedCaIsRefused() throws Exception {
assertRefused("expired");
}- A trust manager with an empty checkServerTrusted accepts any chain. java.net.http.HttpClient still checks the host name, because the JDK wraps a plain X509TrustManager and applies the HTTPS endpoint check, so this card's vulnerable client refused the billing.partner.test certificate. That protects nothing: it accepted a self-signed certificate made with the right name.
- Comparing the subject CN, or the issuer's name, inside a trust manager is not pinning. The impostor certificates here carry exactly the expected names, and the wrong implementation accepted the self-signed, look-alike-CA and expired ones.
- jdk.internal.httpclient.disableHostnameVerification turns the host name check off for every java.net.http.HttpClient in the JVM. It is read once, when the client classes load, and an application cannot override it per client.
- With HttpsURLConnection or Apache HttpClient, a HostnameVerifier that returns true (NoopHostnameVerifier) disables the host check even when the trust manager is correct; that is the Java form of CWE-297.
- Importing the private CA into the JVM's cacerts with keytool makes every client in the JVM trust it. A bundle scoped to the partner client keeps the private CA from vouching for anything else.
- SSLContext.getInstance("TLS") is fine on JDK 21, where jdk.tls.disabledAlgorithms disables SSLv3, TLS 1.0 and TLS 1.1, although Semgrep's weak-ssl-context rule still flags it.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs tls-certificate-validation/java-spring does this for you).
- The exploit tests make the loopback partner server present a self-signed certificate for api.partner.test, a trusted-CA certificate for billing.partner.test, a certificate from an unknown CA named like the trusted one and an expired certificate, and check that GET /rates returns 502 and no request reached the server. A test-only JEP 418 resolver maps *.partner.test to 127.0.0.1; the client still verifies api.partner.test.
- Against your own service, point the client at a server with a self-signed certificate for the real name and expect 'unable to find valid certification path', then at a valid certificate for another name and expect 'No subject alternative DNS name matching'.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | java.lang.security.audit.crypto.ssl.insecure-trust-manager.insecure-trust-manager | 2 findings, both in VulnerablePartnerHttpClient.java: the empty checkClientTrusted and checkServerTrusted. None on WrongPartnerHttpClient.java, whose checkServerTrusted has a body (the CN comparison), or on FixedPartnerHttpClient.java.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/java.lang.security.audit.crypto.ssl.insecure-trust-manager.insecure-trust-manager java-spring/src/main/java; also part of p/security-audit |
| Semgrep | problem-based-packs.insecure-transport.java-stdlib.bypass-tls-verification.bypass-tls-verification | No findings. The pattern spells the parameters X509Certificate[] certs, String authType; this card uses chain, the name in the JDK's own X509TrustManager interface, so nothing matched. A copy of VulnerablePartnerHttpClient.java with the parameter renamed to certs produced 1 finding. The rule also excludes any checkServerTrusted that throws CertificateException, so a CN-only check is out of scope by design.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/problem-based-packs.insecure-transport.java-stdlib.bypass-tls-verification.bypass-tls-verification java-spring/src/main/java, plus a probe copy (rule text read at semgrep-rules 9bbf021) |
| Semgrep | java.lang.security.audit.weak-ssl-context.weak-ssl-context | 1 finding each on VulnerablePartnerHttpClient.java and WrongPartnerHttpClient.java, for SSLContext.getInstance("TLS"). It is a protocol-version rule (CWE-326) and says nothing about certificate checks. On JDK 21 it is a false positive, because jdk.tls.disabledAlgorithms already disables SSLv3, TLS 1.0 and TLS 1.1. Included because a scan will show it.Checked: Semgrep 1.180.0 (CE), p/security-audit |
| CodeQL | java/insecure-trustmanager | From the query and InsecureTrustManager.qll: it reports an X509TrustManager whose checkServerTrusted can never throw CertificateException when it reaches SSLContext.init, which covers the vulnerable implementation. The wrong implementation throws CertificateException when the CN differs, so the query treats it as safe.Checked: github/codeql main at b5b8165; not executed |
| CodeQL | java/unsafe-hostname-verification | The CWE-297 query looks for HostnameVerifier implementations that accept every host, as used by HttpsURLConnection and Apache HttpClient. java.net.http.HttpClient has no HostnameVerifier, so it does not apply to this card's client.Checked: github/codeql main at b5b8165; not executed |
| Find Security Bugs (SpotBugs plugin) | WEAK_TRUST_MANAGERTrustManager that accept any certificates (SECWTM, CWE-295) | 3 findings in VulnerablePartnerHttpClient.java (lines 26, 29 and 33: the empty checkClientTrusted and checkServerTrusted, and getAcceptedIssuers returning an empty array). 1 finding in WrongPartnerHttpClient.java, line 41, only for getAcceptedIssuers returning an empty array; the CN-only checkServerTrusted is not reported. None in FixedPartnerHttpClient.java.Checked: SpotBugs 4.10.4 with findsecbugs-plugin 1.14.0 (SHA-1 checked against Maven Central); spotbugs -textui -pluginList findsecbugs-plugin-1.14.0.jar -effort:max -low -auxclasspath <compile classpath> target/classes |
| Component | Version |
|---|---|
| Language | Java 21 |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC, RestClient) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework.boot:spring-boot (SslBundles) | 4.1.1 |
| java.net.http.HttpClient via Spring's JdkClientHttpRequestFactory | OpenJDK 21 / Spring Framework 7.0.9 |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
| org.bouncycastle:bcpkix-jdk18on (test PKI only) | 1.86 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the tls-certificate-validation/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs tls-certificate-validation/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- jdk OpenJDK 21.0.12.1
- maven 3.9.16
- springBoot 4.1.1
- springFramework 7.0.9
- junitJupiter 6.0.3
- bouncyCastle 1.86
- Matrix
- fixed: 5 of 5 tests pass; vulnerable: 3 of 5 tests fail (3 exploit tests); wrong: 3 of 5 tests fail (3 exploit tests)
- Test results
- fixed: 5/5 pass; vulnerable: 3/5 fail (3 exploit); wrong: 3/5 fail (3 exploit)
- Dependency freshness
- Spring Boot 4.1.1 manages Tomcat 11.0.24 and Jackson 3.1.5; the pom overrides them to 11.0.26 and 3.1.7 for published advisories, as the other cards do. Bouncy Castle 1.86 (2026-09-11) has no OSV advisories and is not managed by Boot. Maven on this machine defaults to a newer JDK, hence javaHome; .mvn/maven.config pins the file-lock sync context because several agents run Maven at once.
- Notes
- The vulnerable and wrong implementations fail the same three tests and both pass the host name test, because the JDK applies the HTTPS endpoint check to any plain X509TrustManager (SSLContextImpl.AbstractTrustManagerWrapper and AbstractAsyncSSLConnection in OpenJDK 21). One Spring context serves every test; the loopback server picks a separate server SSLContext per certificate, so no TLS session can be resumed across tests. A plain path in spring.ssl.bundle.pem.partner.truststore.certificate (without file:) also loaded in Boot 4.1.1.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- Spring Boot 4.1: SSL bundles
- Spring Boot 4.1: Calling REST services, RestClient SSL support
- Java SE 21: HttpClient.Builder.sslContext
- Java SE 21: TrustManagerFactory
- OpenJDK 21: AbstractAsyncSSLConnection (HTTPS endpoint identification, disableHostnameVerification)
- OpenJDK 21: SSLContextImpl (AbstractTrustManagerWrapper)
- JEP 418: Internet-Address Resolution SPI (used by the tests)
- Find Security Bugs: WEAK_TRUST_MANAGER
- CWE-295: Improper Certificate Validation
- CWE-296: Improper Following of a Certificate's Chain of Trust
Attack patterns: