Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dd5f8de
Add per-domain mTLS configuration support to GoRouter config
rkoster Mar 4, 2026
3d7f921
Implement per-domain TLS configuration via GetConfigForClient
rkoster Mar 4, 2026
fa7eb12
Make clientcert handler domain-aware
rkoster Mar 4, 2026
c8268e6
Add BOSH configuration support for mTLS domains
rkoster Mar 4, 2026
2f74b43
Add AllowedSources support for mTLS authorization (Phase 1b partial)
rkoster Mar 4, 2026
8bb62d7
Copy AllowedSourceAppGUIDs in NewEndpoint constructor
rkoster Mar 4, 2026
247a5c6
Add identity extraction handler for mTLS caller identification
rkoster Mar 4, 2026
21109f8
Add mTLS authorization handler for app-to-app access control
rkoster Mar 4, 2026
3ae33f8
Wire identity and authorization handlers into proxy chain
rkoster Mar 4, 2026
fe80b6f
Add AllowedSourceAppGUIDs support to route-registrar
rkoster Mar 4, 2026
0a13b3e
Fix domain names to match RFC specification
rkoster Mar 4, 2026
5ca858b
Expand AllowedSources to full RFC specification
rkoster Mar 4, 2026
253dc28
Add comprehensive integration tests for mTLS app-to-app routing
rkoster Mar 4, 2026
452099c
Fix vendor dependencies broken by commit 0a13b3ed3
rkoster Mar 4, 2026
69bb5cf
Fix mTLS authorization to use RoutePool instead of RouteEndpoint
rkoster Mar 5, 2026
7988aa0
Support allowed_sources nested in options for CAPI/Diego integration
rkoster Mar 5, 2026
2c67c31
Fix identity extraction to handle GoRouter XFCC format (raw base64)
rkoster Mar 5, 2026
c074c6b
Rename AllowedSources to MtlsAllowedSources for clarity
rkoster Mar 5, 2026
ec5aece
Add configurable XFCC format support (raw/envoy)
rkoster Mar 5, 2026
f79d082
Emit RTR access logs for denied mTLS requests
rkoster Mar 5, 2026
08527d9
Refactor mTLS route options to RFC-0027 compliant flat format
rkoster Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions jobs/gorouter/spec
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,31 @@ properties:
router.only_trust_client_ca_certs:
description: "When router.only_trust_client_ca_certs is true, router.client_ca_certs are the only trusted CA certs for client requests. When router.only_trust_client_ca_certs is false, router.client_ca_certs are trusted in addition to router.ca_certs and the CA certificates installed on the filesystem. This will have no affect if the `router.client_cert_validation` property is set to none."
default: false
router.mtls_domains:
description: |
Array of domains requiring mutual TLS authentication. Each domain can have its own CA certificate pool, forwarded_client_cert mode, and xfcc_format.
For non-wildcard domains, the domain must match the request host exactly.
For wildcard domains (e.g., *.apps.mtls.internal), the wildcard must be the leftmost label and matches any single label.

xfcc_format controls the format of the X-Forwarded-Client-Cert header:
- "raw" (default): Full base64-encoded certificate (~1.5KB)
- "envoy": Compact Hash=<sha256>;Subject="<DN>" format (~300 bytes)
default: []
example:
- domain: "*.apps.mtls.internal"
ca_certs: |
-----BEGIN CERTIFICATE-----
<CA certificate for apps.mtls.internal domain>
-----END CERTIFICATE-----
forwarded_client_cert: sanitize_set
xfcc_format: envoy
- domain: "secure.example.com"
ca_certs: |
-----BEGIN CERTIFICATE-----
<CA certificate for secure.example.com>
-----END CERTIFICATE-----
forwarded_client_cert: forward
xfcc_format: raw
router.backends.max_attempts:
description: |
Maximum number of attempts on failing requests against backend routes.
Expand Down
48 changes: 48 additions & 0 deletions jobs/gorouter/templates/gorouter.yml.erb
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,54 @@ if p('router.client_ca_certs')
params['client_ca_certs'] = client_ca_certs
end

if_p('router.mtls_domains') do |mtls_domains|
if !mtls_domains.is_a?(Array)
raise 'router.mtls_domains must be provided as an array'
end

processed_domains = []
mtls_domains.each do |domain_config|
if !domain_config.is_a?(Hash)
raise 'Each entry in router.mtls_domains must be a hash'
end

if !domain_config.key?('domain') || domain_config['domain'].nil? || domain_config['domain'].strip.empty?
raise 'Each entry in router.mtls_domains must have a "domain" key'
end

if !domain_config.key?('ca_certs') || domain_config['ca_certs'].nil? || domain_config['ca_certs'].strip.empty?
raise 'Each entry in router.mtls_domains must have a "ca_certs" key with certificate content'
end

processed_entry = {
'domain' => domain_config['domain'],
'ca_certs' => domain_config['ca_certs']
}

if domain_config.key?('forwarded_client_cert') && !domain_config['forwarded_client_cert'].nil?
valid_modes = ['always_forward', 'forward', 'sanitize_set']
mode = domain_config['forwarded_client_cert']
unless valid_modes.include?(mode)
raise "Invalid forwarded_client_cert mode '#{mode}' for domain '#{domain_config['domain']}'. Must be one of: #{valid_modes.join(', ')}"
end
processed_entry['forwarded_client_cert'] = mode
end

if domain_config.key?('xfcc_format') && !domain_config['xfcc_format'].nil?
valid_formats = ['raw', 'envoy']
format = domain_config['xfcc_format']
unless valid_formats.include?(format)
raise "Invalid xfcc_format '#{format}' for domain '#{domain_config['domain']}'. Must be one of: #{valid_formats.join(', ')}"
end
processed_entry['xfcc_format'] = format
end

processed_domains << processed_entry
end

params['mtls_domains'] = processed_domains
end

if_p('router.http_rewrite') do |r|
params['http_rewrite'] = r
end
Expand Down
2 changes: 2 additions & 0 deletions src/code.cloudfoundry.org/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,8 @@ code.cloudfoundry.org/localip v0.63.0 h1:+y0oLWHyPkB60Cps0pI+Rl+NfGEvD9MsR13A0IM
code.cloudfoundry.org/localip v0.63.0/go.mod h1:jmsUN4TXWswGH1YWsto7ZmKhe06MqF2fJnFiPM+AnkY=
code.cloudfoundry.org/locket v0.0.0-20251117222557-be612341b29d h1:UQBC4hxKpaSc0lNcVafX71I8NLBncxDoWdSX2JTtRBA=
code.cloudfoundry.org/locket v0.0.0-20251117222557-be612341b29d/go.mod h1:AwHLRkdXtttLXNB8RHgLfErJ2kKafH62AR2OClhy6xI=
code.cloudfoundry.org/routing-api v0.0.0-20260121170017-b01cc94c976d h1:FrY6CqmjxZz2Y6HoxjNdBGC0TXXcwEi83LpTHUsz5TU=
code.cloudfoundry.org/routing-api v0.0.0-20260121170017-b01cc94c976d/go.mod h1:zvOkz/tZMCCr9jvq4xeFf8kzTnRjLrJQZn6jMtShfCA=
code.cloudfoundry.org/tlsconfig v0.46.0 h1:i9F12K8EWBwL5mBd/rUm/rYAV/Ky34lYyoLzngLXAV8=
code.cloudfoundry.org/tlsconfig v0.46.0/go.mod h1:RsHyB52jxwVeKn1loOEOrgEEA84vTqwFeOq933uVe+g=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
Expand Down
97 changes: 97 additions & 0 deletions src/code.cloudfoundry.org/gorouter/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const (
REDACT_QUERY_PARMS_NONE string = "none"
REDACT_QUERY_PARMS_ALL string = "all"
REDACT_QUERY_PARMS_HASH string = "hash"

// XFCC format constants for mTLS domains
XFCC_FORMAT_RAW string = "raw" // Full base64-encoded certificate
XFCC_FORMAT_ENVOY string = "envoy" // Hash=<sha256>;Subject="<DN>" format
)

var (
Expand All @@ -45,6 +49,7 @@ var (
AllowedShardingModes = []string{SHARD_ALL, SHARD_SEGMENTS, SHARD_SHARED_AND_SEGMENTS}
AllowedForwardedClientCertModes = []string{ALWAYS_FORWARD, FORWARD, SANITIZE_SET}
AllowedQueryParmRedactionModes = []string{REDACT_QUERY_PARMS_NONE, REDACT_QUERY_PARMS_ALL, REDACT_QUERY_PARMS_HASH}
AllowedXFCCFormats = []string{XFCC_FORMAT_RAW, XFCC_FORMAT_ENVOY}
)

type StringSet map[string]struct{}
Expand Down Expand Up @@ -367,6 +372,17 @@ func InitClientCertMetadataRules(rules []VerifyClientCertificateMetadataRule, ce
return nil
}

// MtlsDomainConfig defines TLS settings for a specific domain that requires mutual TLS
type MtlsDomainConfig struct {
Domain string `yaml:"domain"`
CAPool *x509.CertPool `yaml:"-"`
CACerts string `yaml:"ca_certs"`
ForwardedClientCert string `yaml:"forwarded_client_cert"`
XFCCFormat string `yaml:"xfcc_format"` // "raw" (default) or "envoy"
// Computed fields
RequireClientCert bool `yaml:"-"` // Always true for mTLS domains
}

type Config struct {
Status StatusConfig `yaml:"status,omitempty"`
Nats NatsConfig `yaml:"nats,omitempty"`
Expand All @@ -393,6 +409,12 @@ type Config struct {
ClientCACerts string `yaml:"client_ca_certs,omitempty"`
ClientCAPool *x509.CertPool `yaml:"-"`

// MtlsDomains configures domains that require client certificates (mTLS)
// Routes on these domains will require valid instance identity certificates
MtlsDomains []MtlsDomainConfig `yaml:"mtls_domains,omitempty"`
// Computed: map of domain -> config for fast lookup
mtlsDomainMap map[string]*MtlsDomainConfig `yaml:"-"`

SkipSSLValidation bool `yaml:"skip_ssl_validation,omitempty"`
ForwardedClientCert string `yaml:"forwarded_client_cert,omitempty"`
ForceForwardedProtoHttps bool `yaml:"force_forwarded_proto_https,omitempty"`
Expand Down Expand Up @@ -801,6 +823,9 @@ func (c *Config) Process() error {
if err := c.buildClientCertPool(); err != nil {
return err
}
if err := c.processMtlsDomains(); err != nil {
return err
}
return nil
}

Expand Down Expand Up @@ -901,6 +926,54 @@ func (c *Config) buildClientCertPool() error {
return nil
}

func (c *Config) processMtlsDomains() error {
// Initialize mTLS domain map
c.mtlsDomainMap = make(map[string]*MtlsDomainConfig)

for i := range c.MtlsDomains {
domain := &c.MtlsDomains[i]
domain.RequireClientCert = true

// Validate forwarded_client_cert mode
if domain.ForwardedClientCert == "" {
domain.ForwardedClientCert = SANITIZE_SET // Default to most secure
}
if !slices.Contains(AllowedForwardedClientCertModes, domain.ForwardedClientCert) {
return fmt.Errorf("mtls_domains[%d].forwarded_client_cert must be one of %v",
i, AllowedForwardedClientCertModes)
}

// Validate xfcc_format
if domain.XFCCFormat == "" {
domain.XFCCFormat = XFCC_FORMAT_RAW // Default to raw for backwards compatibility
}
if !slices.Contains(AllowedXFCCFormats, domain.XFCCFormat) {
return fmt.Errorf("mtls_domains[%d].xfcc_format must be one of %v",
i, AllowedXFCCFormats)
}

// Build CA pool for this domain
if domain.CACerts != "" {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(domain.CACerts)) {
return fmt.Errorf("mtls_domains[%d].ca_certs contains invalid certificates", i)
}
domain.CAPool = pool
} else {
return fmt.Errorf("mtls_domains[%d].ca_certs is required", i)
}

// Validate domain is not empty
if domain.Domain == "" {
return fmt.Errorf("mtls_domains[%d].domain is required", i)
}

c.mtlsDomainMap[domain.Domain] = domain
}

return nil
}

func convertCipherStringToInt(cipherStrs []string, cipherMap map[string]uint16) ([]uint16, error) {
ciphers := []uint16{}
for _, cipher := range cipherStrs {
Expand Down Expand Up @@ -936,6 +1009,30 @@ func (c *Config) RoutingApiEnabled() bool {
return (c.RoutingApi.Uri != "") && (c.RoutingApi.Port != 0)
}

// GetMtlsDomainConfig returns the mTLS domain configuration for a given host.
// It checks for exact matches first, then wildcard matches (e.g., *.apps.mtls.internal).
// Returns nil if the host is not an mTLS domain.
func (c *Config) GetMtlsDomainConfig(host string) *MtlsDomainConfig {
// Check exact match first
if cfg, ok := c.mtlsDomainMap[host]; ok {
return cfg
}
// Check wildcard match (e.g., *.apps.mtls.internal)
parts := strings.SplitN(host, ".", 2)
if len(parts) == 2 {
wildcardDomain := "*." + parts[1]
if cfg, ok := c.mtlsDomainMap[wildcardDomain]; ok {
return cfg
}
}
return nil
}

// IsMtlsDomain returns true if the given host is configured as an mTLS domain
func (c *Config) IsMtlsDomain(host string) bool {
return c.GetMtlsDomainConfig(host) != nil
}

func (c *Config) Initialize(configYAML []byte) error {
return yaml.Unmarshal(configYAML, &c)
}
Expand Down
90 changes: 88 additions & 2 deletions src/code.cloudfoundry.org/gorouter/handlers/clientcert.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package handlers

import (
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
Expand All @@ -22,6 +26,7 @@ type clientCert struct {
skipSanitization func(req *http.Request) bool
forceDeleteHeader func(req *http.Request) (bool, error)
forwardingMode string
config *config.Config
logger *slog.Logger
errorWriter errorwriter.ErrorWriter
}
Expand All @@ -30,13 +35,15 @@ func NewClientCert(
skipSanitization func(req *http.Request) bool,
forceDeleteHeader func(req *http.Request) (bool, error),
forwardingMode string,
cfg *config.Config,
logger *slog.Logger,
ew errorwriter.ErrorWriter,
) negroni.Handler {
return &clientCert{
skipSanitization: skipSanitization,
forceDeleteHeader: forceDeleteHeader,
forwardingMode: forwardingMode,
config: cfg,
logger: logger,
errorWriter: ew,
}
Expand All @@ -45,16 +52,34 @@ func NewClientCert(
func (c *clientCert) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
logger := LoggerWithTraceInfo(c.logger, r)
skip := c.skipSanitization(r)

// Determine forwarding mode and XFCC format - use domain-specific if on mTLS domain
forwardingMode := c.forwardingMode
xfccFormat := config.XFCC_FORMAT_RAW // Default for non-mTLS domains
mtlsDomainConfig := c.config.GetMtlsDomainConfig(r.Host)
if mtlsDomainConfig != nil {
forwardingMode = mtlsDomainConfig.ForwardedClientCert
xfccFormat = mtlsDomainConfig.XFCCFormat
c.logger.Debug("using-mtls-domain-xfcc-config",
slog.String("host", r.Host),
slog.String("mode", forwardingMode),
slog.String("xfcc_format", xfccFormat))
}

if !skip {
switch c.forwardingMode {
switch forwardingMode {
case config.FORWARD:
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
r.Header.Del(xfcc)
}
case config.SANITIZE_SET:
r.Header.Del(xfcc)
if r.TLS != nil {
replaceXFCCHeader(r)
if xfccFormat == config.XFCC_FORMAT_ENVOY {
replaceXFCCHeaderEnvoyFormat(r)
} else {
replaceXFCCHeader(r)
}
}
}
}
Expand Down Expand Up @@ -95,6 +120,67 @@ func replaceXFCCHeader(r *http.Request) {
}
}

// replaceXFCCHeaderEnvoyFormat sets the X-Forwarded-Client-Cert header using Envoy's
// compact format: Hash=<sha256>;Subject="<DN>"
// This is significantly smaller than the raw certificate format (~300 bytes vs ~1.5KB)
func replaceXFCCHeaderEnvoyFormat(r *http.Request) {
if len(r.TLS.PeerCertificates) > 0 {
cert := r.TLS.PeerCertificates[0]
r.Header.Add(xfcc, formatXFCCEnvoy(cert))
}
}

// formatXFCCEnvoy generates the Envoy-style XFCC header value:
// Hash=<sha256-hex>;Subject="<X.509 DN>"
func formatXFCCEnvoy(cert *x509.Certificate) string {
// Calculate SHA-256 hash of the DER-encoded certificate
hash := sha256.Sum256(cert.Raw)
hashHex := hex.EncodeToString(hash[:])

// Format Subject DN using standard X.509 format
subject := formatSubjectDN(cert.Subject)

return fmt.Sprintf("Hash=%s;Subject=\"%s\"", hashHex, subject)
}

// formatSubjectDN formats an X.509 Distinguished Name in the standard format
// e.g., "CN=instance-id,OU=app:guid,OU=space:guid,OU=organization:guid"
func formatSubjectDN(name pkix.Name) string {
var parts []string

// Add CN first (if present)
if name.CommonName != "" {
parts = append(parts, "CN="+name.CommonName)
}

// Add OUs (preserve order from certificate)
for _, ou := range name.OrganizationalUnit {
parts = append(parts, "OU="+ou)
}

// Add O (Organization)
for _, o := range name.Organization {
parts = append(parts, "O="+o)
}

// Add L (Locality)
for _, l := range name.Locality {
parts = append(parts, "L="+l)
}

// Add ST (State/Province)
for _, st := range name.Province {
parts = append(parts, "ST="+st)
}

// Add C (Country)
for _, c := range name.Country {
parts = append(parts, "C="+c)
}

return strings.Join(parts, ",")
}

func sanitize(cert []byte) string {
s := string(cert)
r := strings.NewReplacer("-----BEGIN CERTIFICATE-----", "",
Expand Down
Loading