Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix TLS certificate validation in Java and Spring Boot

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • 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)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks4 cards
Download the project ZIP, 28 KB
On this page

Steps

  1. 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.
  2. For a public service, delete the custom SSLContext: java.net.http.HttpClient uses the JDK's cacerts and checks the host name by default.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerable…/partner/impl/VulnerablePartnerHttpClient.javaJava
// 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

Fixed…/partner/impl/FixedPartnerHttpClient.javaJava
// 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

Wrong fix…/partner/impl/WrongPartnerHttpClient.javaJava
// 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 regression test

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.

Test…/example/partner/RatesApiTest.javaJava
// 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");
}

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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'.

Scanner rules

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.

Scanner rules for Fix TLS certificate validation in Java and Spring Boot
ToolRuleWhat it found here
Semgrepjava.lang.security.audit.crypto.ssl.insecure-trust-manager.insecure-trust-manager2 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
Semgrepproblem-based-packs.insecure-transport.java-stdlib.bypass-tls-verification.bypass-tls-verificationNo 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)
Semgrepjava.lang.security.audit.weak-ssl-context.weak-ssl-context1 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
CodeQLjava/insecure-trustmanagerFrom 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
CodeQLjava/unsafe-hostname-verificationThe 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC, RestClient)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.springframework.boot:spring-boot (SslBundles)4.1.1
java.net.http.HttpClient via Spring's JdkClientHttpRequestFactoryOpenJDK 21 / Spring Framework 7.0.9
org.springframework.boot:spring-boot-starter-webmvc-test4.1.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3
org.bouncycastle:bcpkix-jdk18on (test PKI only)1.86

Run the tests

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).

Shell
mvn -B -q test
Shell
# 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 test

Each 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.

Download the project ZIP, 28 KB

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

  1. Spring Boot 4.1: SSL bundles
  2. Spring Boot 4.1: Calling REST services, RestClient SSL support
  3. Java SE 21: HttpClient.Builder.sslContext
  4. Java SE 21: TrustManagerFactory
  5. OpenJDK 21: AbstractAsyncSSLConnection (HTTPS endpoint identification, disableHostnameVerification)
  6. OpenJDK 21: SSLContextImpl (AbstractTrustManagerWrapper)
  7. JEP 418: Internet-Address Resolution SPI (used by the tests)
  8. Find Security Bugs: WEAK_TRUST_MANAGER
  9. CWE-295: Improper Certificate Validation
  10. CWE-296: Improper Following of a Certificate's Chain of Trust

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.