Passing client certificate to Tomcat

1. Caddy version (caddy version):

2.4.6 in docker container (2.4.6-alpine)

2. How I run Caddy:

With docker-compose

a. System environment:

b. Command:

docker-compose -f ds.yml up --build

c. Service/unit/compose file:

services:
  caddy:
    image: caddy:2.4.6-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile_completo:/etc/caddy/Caddyfile:ro
      - ./certs_servidor:/certs
  tomcat_dataserver:
    depends_on:
      - "db"  
    image: tomcat:6.0.43
    volumes:
      - ./dataserver.war:/usr/local/tomcat/webapps/dataserver.war
    restart: unless-stopped
    tty: true

d. My complete Caddyfile or JSON config:

`caddy fmt`
{
  auto_https disable_redirects
  debug
}
http://dataserver.local.domain {	
	reverse_proxy tomcat_dataserver:8080
}
https://dataserver.local.domain, dataserver.local.domain:443 {	
	tls /certs/dataserver_local_domain.crt /certs/dataserver_local_domain.key {
		protocols tls1.2 tls1.2
		ciphers TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_AES_128_GCM_SHA256 TLS_AES_256_GCM_SHA384 TLS_CHACHA20_POLY1305_SHA256 TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
		client_auth {
			mode require_and_verify
			trusted_ca_cert_file /certs/local_domain_ca.crt
		}

	}
	reverse_proxy tomcat_dataserver:8080
}

3. The problem I’m having:

I´m dockerizing two old application, one is a Java6 command line application (let´s call it client) and other a Java6 web application working on a tomcat6 container (called dataserver).
The old configuration was using an apache server acting as proxy for tomcat via ajp with SSL and client certificates:

<VirtualHost *:443>
ServerName dataserver.local.domain
ErrorLog logs/dataserver.local.domain-ssl-error_log
CustomLog logs/dataserver.local.domain-ssl-access_log common
<Proxy *>
AddDefaultCharset Off
Order deny,allow
Allow from all
</Proxy>
SSLEngine On
SSLCertificateFile /etc/pki/tls/certs/dataserver_local_domain.crt
SSLCertificateKeyFile /etc/pki/tls/private/dataserver_local_domain.key
<Location /dataserver>
SSLVerifyClient require
SSLVerifyDepth 2
</Location>
SSLCACertificateFile /etc/ssl/certs/local_domain_ca.crt
SSLOptions +ExportCertData +StdEnvVars
ProxyPass /dataserver/ ajp://dataserver.local.domain:8009/dataserver/
ProxyPassReverse /dataserver/ ajp://dataserver.local.domain:8009/dataserver/
</VirtualHost>

After some tweaks with the ciphers (java application is old and we can´t upgrade as per client requirements) and java truststores I have all the communication working between the client and the dataserver, with caddy handling the client authentication. The problem I have is thats there is an extra check in the dataserver code when receiving data, as it reads the client certificate, extracts the CN from it and do some checks with it against a database before allowing to go further:


    String boxNumber = "";
    String certificateDomain = PropertyReader.getInstance("general").get("certificate.domain");
    String[] subkeys = certificateDomain.split("\\.");
    String patternString = "box(\\d{6})"; 
    for (String subkey : subkeys) {
      patternString += "\\."+subkey;
    }
    Pattern pattern = Pattern.compile(patternString);

    X509Certificate[] certs = (X509Certificate[]) servletRequest.getAttribute("javax.servlet.request.X509Certificate");
    if (certs != null) {
      for (X509Certificate clientCert : certs) {
        try {
          // Trick: The DN obtained is in RFC2253 format, which is the same as used for LDAP DN.
          LdapName ldapDN = new LdapName(clientCert.getSubjectDN().getName());
          for (Rdn rdn : ldapDN.getRdns()) {
            if (rdn.getType().contentEquals("CN")) {
              Matcher matcher = pattern.matcher(rdn.getValue().toString());

              if (matcher.find()) {
                boxNumber = matcher.group(1);
                logger.trace(rdn.getType() + ": " + boxNumber);
              } else {
                logger.warn("Received valid certificate from a box, but that does not belong to a box. The {} is {}",
                    rdn.getType(), rdn.getValue().toString());
              }
            }
          }
        } catch (InvalidNameException e) {
          logger.error("Invalid name in client certificate", e);
        }
      }
    } else {
      logger.error("No certs available.");
    }

    return boxNumber;

In this case when the client sends data to the dataserver, the line

  X509Certificate[] certs = (X509Certificate[]) servletRequest.getAttribute("javax.servlet.request.X509Certificate")

Produces null so the variable boxNumber is empty and no further checks can be made.

In order to work in apache proxy, the directive SSLOptions +ExportCertData +StdEnvVars solved this problem as stated in apache documentation:

ExportCertData
When this option is enabled, additional CGI/SSI environment variables are created: SSL_SERVER_CERT, SSL_CLIENT_CERT and SSL_CLIENT_CERT_CHAIN_n (with n = 0,1,2,..). These contain the PEM-encoded X.509 Certificates of server and client for the current HTTPS connection and can be used by CGI scripts for deeper Certificate checking. Additionally all other certificates of the client certificate chain are provided, too. This bloats up the environment a little bit which is why you have to use this option to enable it on demand.

Additional documentation from Tomcat

I´m unable to replicate this last part of the configuration in Caddy. It´s there any way of getting the same config as apache?

Thanks in advance for the help

4. Error messages and/or full log output:

No errors, apart from the log on the dataserver stating “No certs available”

5. What I already tried:

Lots of forum and Google searching without results. Also been through all the old documentation for deploying the dataserver and it doesn´t state anything about extra configuration or headers on Tomcat.

6. Links to relevant resources:

So you want to pass through the client certs that Caddy validated with client_auth to your upstream?

Since you’re proxying over HTTP, I think the only option you have to pass them up to the upstream is via HTTP headers.

You can use header_up with placeholders to pass it through:

reverse_proxy tomcat_dataserver:8080 {
	header_up Client-Cert :{tls_client_certificate_der_base64}:
}

You can find the list of HTTP placeholders at the top of the JSON docs for the http server:

And the Caddyfile placeholder shortcuts:

The Client-Cert header is suggested according to this draft RFC, using : around the base64 encoded client cert:

https://httpwg.org/http-extensions/draft-ietf-httpbis-client-cert-field.html

I have to strongly suggest you remove this line. This turns off tls1.3, which harms security of your server, for clients that do support TLS 1.3, and it would prevent Caddy from automatically using some hypothetical tls1.4 if it were to be released.

The TLS handshake will use the highest commonly supported version and cipher, so there’s no need to lower the maximum supported version.

2 Likes

Following your advice I´ve just added to the caddyfile (without : ):

reverse_proxy tomcat_dataserver:8080 {
		header_up Client-Cert {tls_client_certificate_der_base64}
	}

Checked that tomcat was indeed receiving the new header, so I have to make some changes in the java code in order to aceppt it (keep in mind that is a Java 6 app)

//old method
certs = (X509Certificate[]) servletRequest.getAttribute("javax.servlet.request.X509Certificate");
    if (certs == null) {
// New method in case we are working with Caddy
      logger.info("No certs available on enviroment. Trying headers");
      /* logger.info("List of headers received");
      java.util.Enumeration<String> e = servletRequest.getHeaderNames();
      while (e.hasMoreElements()){
        logger.info("Value is: " + e.nextElement());
      }
      logger.info("CLIENT CERT VALUE: " + servletRequest.getHeader("client-cert")); */

      String certB64 = servletRequest.getHeader("client-cert");
      byte encodedCert[] = DatatypeConverter.parseBase64Binary(certB64);//Base64.getDecoder().decode(certB64);
      ByteArrayInputStream inputStream  =  new ByteArrayInputStream(encodedCert);
      try{
        CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
        cert = (X509Certificate)certFactory.generateCertificate(inputStream);
      }catch(CertificateException ex){
        logger.error("x509CERTERR: " + ex.toString());
      }

      if(cert != null){
        certs = new X509Certificate[] {cert};
        logger.info("CERT AVAILABLE");
      }else{
        logger.error("No cert available.");
      }
    }

As for the TLS, I´ve just updated it to working with max tls1.3. In this case the max my client is using is 1.2, but we will correct it as soon as we update the code to a new Java version

Thanks for the advice!!!

To clarify in case you didn’t understand what I was trying to say – remove protocols from your config entirely, don’t just change the maximum. There’s no reason to re-specify the current defaults, it just harms future config agility.

Glad I could help!

This topic was automatically closed after 30 days. New replies are no longer allowed.