Add Traefik X.509 client certificate authentication support#61
Add Traefik X.509 client certificate authentication support#61hanstrompert wants to merge 21 commits into
Conversation
hanstrompert
left a comment
There was a problem hiding this comment.
Thanks for the follow-up commits! Lots of progress here — the case-sensitive header lookup, the missing default case, the ARNOTODO, and the stray System.out.println in the PEM catch are all sorted, and the new tests for invalid/unauthorized PEM, problematic OIDs, and the multi-cert chain (happy path, badly-separated, wrong order) are really nice additions.
A few things I think would be worth another look before this comes out of draft — most are small, but a couple feel important. (A few of the items below are about files that aren't part of this PR's diff, so I couldn't pin them as inline comments — flagging them here with file:line references instead.)
Higher priority
-
Legacy config keys haven't been updated, which I think disables auth silently on upgrade. As far as I can tell, after deploy these still bind to nothing,
authorize-dn-typefalls back toNO, and the interceptor doesn't get installed → client-DN authorization is silently disabled. This was the critical item from the original review, so probably worth tackling before merge. A few possible directions: rename the keys + values everywhere to match the new schema, keep compat aliases for the legacy keys for a release, or fail fast at startup when the legacy keys are present.Spots I noticed (all outside the current PR diff, so couldn't pin inline):
src/main/resources/application.properties:70-72— comment on line 70 ("possible values for authorize-dn are no, header, certificate") +authorize-dn=noon line 71 +ssl-client-subject-dn-header=…on line 72.chart/values.yaml:158-159—verify-ssl-client-subject-dn/ssl-client-subject-dn-header.README.md:162— example using legacyauthorize-dn=no.README.md:205— example using legacyauthorize-dn=certificate. Also a natural place to add a short note showing what each newAuthorizeDnTypevalue (JAKARTA_SERVLET_TLS_CLIENT_CERT,NGINX_TLS_CLIENT_SUBJECT_DN,TRAEFIK_TLS_CLIENT_CERT,TRAEFIK_TLS_CLIENT_SUBJECT_DN) corresponds to.README.md:293— anotherauthorize-dn=header+ssl-client-subject-dn-headerexample.
-
Two
System.out.printlncalls slipped into the production code paths (AuthInterceptor.java:121,ClientPrincipals.java:78) — they end up logging the client DN and every allow-list comparison to stdout on every request. Probably want to drop those before merge. There's also a leftoverprintlnin one of the new tests. -
The
TRAEFIK_TLS_CLIENT_CERTpath still trusts the subject DN of whatever PEM lands in the header without verifying the chain. That's fine if Traefik is configured withRequireAndVerifyClientCertand strips the inboundX-Forwarded-Tls-Client-Certheader from external requests, but it would be great to call that out explicitly in the README (probably around line 205) so this isn't deployed unsafely.
Still open from the original list (no rush, just flagging)
- Malformed allow-list entries are still silently dropped (
ClientPrincipals.java:62-63) — a typo inapplication.propertieswould only become visible when an authorized client suddenly gets rejected. Failing fast at startup would be friendlier. @ComponentonClientPrincipalslooks like it can come off — the class is built vianewinAuthInterceptor.isAllowed(), never autowired.- The allow-list is reparsed on every request (
AuthInterceptor.java:135). Probably worth building it once at config-bind time. - Dead
if (cps == null)afternew ClientPrincipals(...)atAuthInterceptor.java:136. JAKARTA_SERVLET_TLS_CLIENT_CERT_HEADERis actually a servlet attribute name, not a header — the constant name is a bit misleading.- The
FINE-level log atAuthInterceptor.java:55includes the full PEM/DN value — might be worth redacting sinceFINEcan end up in shared sinks. - Coverage gaps: case-insensitive header lookup, the full
TRAEFIK_TLS_CLIENT_SUBJECT_DN(Info) path end-to-end, and a missing-header case for Traefik.
Small nits introduced by these commits
- The
default-case message atAuthInterceptor.java:116hardcodes "set to NO" regardless of which value triggered it. - Typo at
AuthInterceptor.java:108: missing space before"missing.". - Double-negative in the comment at
ClientPrincipals.java:51. - The invalid-PEM construction in the test (
AuthInterceptorTest.java:260) is a bit fragile. - Stray
;atAuthInterceptorTest.java:276.
I've left inline comments on the items that are in the PR diff so they're easier to track. Happy to chat through any of them if I've misread the intent anywhere.
| else LOG.fine(sslClientSubjectDn + " in list of allowed DNs"); | ||
| String rfc2253Dn = tlsClientSubjectPrincipal.getName(X500Principal.RFC2253); | ||
|
|
||
| System.out.println("ARNO GOT DN: " + rfc2253Dn); |
There was a problem hiding this comment.
I think this one slipped in by accident — it ends up writing the client DN to stdout on every authenticated request. The LOG.fine(rfc2253Dn + " in list of allowed DNs") just below already covers it, so probably safe to just drop this line.
| faultCode); | ||
| break; | ||
| default: | ||
| throw new SoapFault(clientCertificateProperties.getAuthorizeDnType() + " set to NO.", faultCode); |
There was a problem hiding this comment.
Small nit: the message says "set to NO" but default would fire for any unhandled enum value, not just NO. Maybe fold NO into the switch explicitly and let default say "unsupported AuthorizeDnType: " + at? That way a future enum value won't produce a misleading error.
| } | ||
| } | ||
| } else { | ||
| LOG.fine("Expected HTTP header " + expectHeaderName + "missing."); |
There was a problem hiding this comment.
Tiny one — missing space, so the log line reads …X-Forwarded-Tls-Client-Certmissing.. Just " missing." should do it.
| String expectHeaderName = clientCertificateProperties.getTlsClientAuthNHeader(); | ||
| String headerValue = request.getHeader(expectHeaderName); | ||
| if (headerValue != null) { | ||
| LOG.fine("Found HTTP header " + expectHeaderName + ": " + headerValue); |
There was a problem hiding this comment.
This logs the full header value at FINE — for TRAEFIK_TLS_CLIENT_CERT that's the entire PEM, and for the Info path it's the full DN. Even at FINE it can leak into shared log sinks. Could we log just the header name + a length or hash, or truncate?
| return clientCertificateProperties.getDistinguishedNames().contains(sslClientSubjectDn); | ||
| // Not ideal to convert strings to Objects on each call, but otherwise conflicts with Properties-based | ||
| // config. And we must use the Principal equals() method for comparison. | ||
| ClientPrincipals cps = new ClientPrincipals(distinguishedNames); |
There was a problem hiding this comment.
We rebuild the allow-list (parsing each DN into an X500Principal plus the OID map) on every request, which feels avoidable given this is the auth hot path. Could we construct ClientPrincipals once at config-bind time — e.g. via a @PostConstruct on ClientCertificateProperties or a small Spring bean — and reuse the parsed list here?
| // "The distinguished name must be specified using the grammar defined in RFC 1779 or RFC 2253 (either | ||
| // format is acceptable)." | ||
| Map<String, String> names2oid = new HashMap<>(); | ||
| // Not all x509 implementations do not know the symbolic names for all OIDs. |
There was a problem hiding this comment.
Tiny grammar thing — "Not all x509 implementations do not know …" is a double negative; reads as the opposite of what I think you meant. Maybe "Not all x509 implementations know the symbolic names …" or "Some x509 implementations don't know …".
| X500Principal p = new X500Principal(propDistinguishedName, names2oid); | ||
| this.allowedPrincipals.add(p); | ||
| } catch (Exception e) { | ||
| LOG.fine(propDistinguishedName + " not a proper DN:" + e); |
There was a problem hiding this comment.
Malformed allow-list entries get silently dropped at FINE level here, so a typo in application.properties wouldn't be visible in normal logs — the symptom would only show up later when an otherwise-authorized client gets rejected. Could we fail fast at startup instead (throw, or collect the errors and throw at the end of construction)? That should be easier to diagnose.
|
|
||
| SoapFault fault = assertThrows(SoapFault.class, () -> interceptor.handleMessage(message)); | ||
|
|
||
| System.out.println("UNAUTH MESSAGE " + fault.getMessage()); |
There was a problem hiding this comment.
Looks like a leftover debug print — could we drop it?
| when(httpRequest.getHeaderNames()) | ||
| .thenReturn(Collections.enumeration(List.of("X-Forwarded-Tls-Client-Cert"))); | ||
| when(httpRequest.getHeader("X-Forwarded-Tls-Client-Cert")).thenReturn(getPemCertString(_UNAUTH_CERT_PEM)); | ||
| ; |
There was a problem hiding this comment.
Stray ; on its own line after the when(...) — harmless, but worth tidying.
| @Test | ||
| void rejectsInvalidPEMHeader() { | ||
| String badPemString = getPemCertString(_AUTHORIZED_CERT_PEM); | ||
| badPemString = badPemString.replaceFirst("[MQPXY]+", "S"); |
There was a problem hiding this comment.
Just a thought: replaceFirst("[MQPXY]+", "S") happens to corrupt enough base64 to fail decoding for this specific fixture, but it depends on the character class hitting a critical position. Something more deterministic like truncating the body, flipping a single byte, or just using "not-a-cert" would be a bit less fragile if we ever swap the fixture.
Summary
AuthInterceptorto authenticate clients via Traefik'sX-Forwarded-Tls-Client-Cert(PEM) andX-Forwarded-Tls-Client-Cert-Info(subject DN) headers, alongside the existing nginx subject-DN header and the Jakarta servletX509Certificateattribute.AuthorizeDnTypevalues (NO/HEADER/CERTIFICATE) with explicit per-source variants:NO,NGINX_TLS_CLIENT_SUBJECT_DN,JAKARTA_SERVLET_TLS_CLIENT_CERT,TRAEFIK_TLS_CLIENT_CERT,TRAEFIK_TLS_CLIENT_SUBJECT_DN. Rename the related config keys toauthorize-dn-type/tls-client-auth-n-header.ClientPrincipalsso allowed-DN comparison goes throughX500Principal.equals()instead of raw string match, allowing more flexible DN formatting inapplication.properties.Known blockers (must be addressed before un-drafting)
Critical — silent auth bypass on upgrade
application.properties,chart/values.yaml, andREADME.mdstill use the old property names (authorize-dn,ssl-client-subject-dn-header) and old enum values (header,certificate). After upgrade, existing deployments bind to nothing,authorize-dn-typefalls back toNO,ServerConfigskips installing the interceptor, and client-DN authorization is silently bypassed. Needs migration path (alias old keys, or fail-fast when legacy keys are present) and full doc sync.Auth/security correctness
AuthInterceptor.java): iteratesgetHeaderNames()withString.equals. HTTP header names are case-insensitive (RFC 7230). Userequest.getHeader(name)directly.TRAEFIK_TLS_CLIENT_CERTpath: subject DN of the supplied PEM is trusted as-is. Safe only if Traefik enforces client-auth (RequireAndVerifyClientCert) AND the header is stripped from inbound external requests. Needs explicit ops docs.defaultcase in switch: today the interceptor isn't installed whenauthorizeDnType == NO, but the switch would NPE ontlsClientSubjectPrincipalfor any future enum value. Fail closed instead.ClientPrincipals(the per-entrytry { } catch (Exception)only logs atFINE). Fail-fast at startup instead.Code quality
@ComponentonClientPrincipalsis incorrect — it's instantiated vianewwith constructor args, not autowired. Remove the annotation.AuthInterceptor.isAllowed(). Build once at config bind time and cache.if (cps == null)check afternew ClientPrincipals(...).System.out.println(e.getMessage())in the PEM catch — should beLOG.warning(or rethrown asSoapFaultcause).ARNOTODOcomment left in source.JAKARTA_SERVLET_TLS_CLIENT_CERT_HEADERconstant misnames a servlet attribute as a header.FINE(currently logs full PEM/DN).Tests
TRAEFIK_TLS_CLIENT_SUBJECT_DN(Info) path, and case-insensitive header matching.Test plan
mvn testpasses locallyAuthorizeDnTypeagainst a deployed instance behind nginx and Traefik ingress🤖 Generated with Claude Code