Release Notes — Router TLS validation tightening + certificate pinning
Sprint: SPRINT_20260501_013_Router_tls_pinning Module: Router transport (src/Router/__Libraries/StellaOps.Router.Transport.Tls/) Audit finding closed: Pass-2 §C8 (TLS self-signed bypass too broad)
Summary
Closes audit finding C8 in microservice-audit-pass2-2026-04-29.md: the previous TlsTransportServer.ValidateClientCertificate accepted a self-signed certificate with the wrong hostname when AllowSelfSigned=true was configured — a self-signed cert presenting any name passed validation, collapsing the TLS identity guarantee entirely.
The validator has been rewritten with strict, ordered rules. The same logic applies to the client-side ValidateServerCertificate callback so microservices validating the gateway use the identical contract.
See Router architecture, TLS Validation Contract for the full rules and TLS Transport guide, “Self-signed + pinning (worked example)” for a compose-file example.
⚠️ Breaking change — operator action required
Any deployment using AllowSelfSigned=true without certificate pinning will now fail to start. This is intentional — the previous configuration was the audit-finding C8 collapse and silently accepted self-signed certs from any peer with any hostname.
Service startup throws:
System.InvalidOperationException: TlsTransportOptions: AllowSelfSigned=true
requires at least one of CertificatePinning.ThumbprintsSha256,
CertificatePinning.AllowedSubjects, or CertificatePinning.AllowedSans to be
populated. Audit finding C8 (docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md) blocked
the previous unconstrained self-signed bypass; configure a pin set or set
AllowSelfSigned=false. See SPRINT_20260501_013 for the worked example.
Recovery — add a pin set
Compute the SHA-256 thumbprint of each peer leaf certificate:
openssl x509 -in cert.pem -outform DER | openssl dgst -sha256 -hex \
| awk '{print toupper($2)}'
Configure pinning (one of three modes; any-of matches):
Router:
Transport:
Type: tls
Tls:
AllowSelfSigned: true
CertificatePinning:
ThumbprintsSha256:
- "F2A1B3C4D5E6F708091A2B3C4D5E6F708091A2B3C4D5E6F708091A2B3C4D5E6F"
# OR (more permissive — pin a private CA subject for leaf rotation):
# AllowedSubjects:
# - "CN=stellaops-internal-ca, O=stellaops"
# OR (pin DNS SAN entries — exact match, no wildcard expansion):
# AllowedSans:
# - "service-a.stellaops.local"
Recovery — disable self-signed acceptance (recommended for production)
Use a properly trusted CA chain and set AllowSelfSigned: false (the default). Pinning remains optional but is applied as defence-in-depth when configured.
Validation rules
The new validator (TlsCertificateValidator) applies these rules in order:
- Required peer cert missing → reject (
certificate_required_but_missing). RemoteCertificateNotAvailable→ reject.RemoteCertificateNameMismatch→ reject unconditionally, even withAllowSelfSigned=trueand a matching pin (name_mismatch). This is the central C8 fix.RemoteCertificateChainErrors:AllowSelfSigned=false→ reject.AllowSelfSigned=true→ accept only if every chain status flag isUntrustedRootorPartialChainAND the cert matches at least one entry in the configured pin set.
- No errors → accept.
Pin matching uses StringComparer.Ordinal on a normalised SHA-256 hex thumbprint (separators stripped, upper-case).
Telemetry
Each rejection increments router_tls_validate_rejected_total{reason, role} (meter StellaOps.Router.Transport.Tls) and emits a structured-log warning. role is client_certificate (server-side mTLS validation) or server_certificate (microservice validating gateway). Reason codes: certificate_required_but_missing, remote_certificate_not_available, name_mismatch, chain_error_and_self_signed_disallowed, chain_status_not_permitted, pin_set_empty, pin_mismatch, other_ssl_policy_error.
Files changed
src/Router/__Libraries/StellaOps.Router.Transport.Tls/CertificatePinningOptions.cs— new pinning options model.src/Router/__Libraries/StellaOps.Router.Transport.Tls/CertificatePinMatcher.cs— new shared pin-matching primitive (thumbprint/subject/SAN any-of, ordinal compare on normalised hex).src/Router/__Libraries/StellaOps.Router.Transport.Tls/TlsCertificateValidator.cs— new strict validator withrouter_tls_validate_rejected_totalcounter.src/Router/__Libraries/StellaOps.Router.Transport.Tls/TlsTransportOptions.cs— addedCertificatePinningandValidate()(called by server/client constructors).src/Router/__Libraries/StellaOps.Router.Transport.Tls/TlsTransportServer.cs—ValidateClientCertificatenow delegates toTlsCertificateValidator.src/Router/__Libraries/StellaOps.Router.Transport.Tls/TlsTransportClient.cs—ValidateServerCertificatenow delegates toTlsCertificateValidator.src/Router/__Tests/StellaOps.Router.Transport.Tls.Tests/TlsCertificateValidatorTests.cs— new test matrix (25 tests covering name mismatch, chain errors, pin match/mismatch, dev-compose-bypass-now-rejects).
