diff --git a/jobs/gorouter/spec b/jobs/gorouter/spec index 2c94ffc69..c0b761a6f 100644 --- a/jobs/gorouter/spec +++ b/jobs/gorouter/spec @@ -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=;Subject="" format (~300 bytes) + default: [] + example: + - domain: "*.apps.mtls.internal" + ca_certs: | + -----BEGIN CERTIFICATE----- + + -----END CERTIFICATE----- + forwarded_client_cert: sanitize_set + xfcc_format: envoy + - domain: "secure.example.com" + ca_certs: | + -----BEGIN CERTIFICATE----- + + -----END CERTIFICATE----- + forwarded_client_cert: forward + xfcc_format: raw router.backends.max_attempts: description: | Maximum number of attempts on failing requests against backend routes. diff --git a/jobs/gorouter/templates/gorouter.yml.erb b/jobs/gorouter/templates/gorouter.yml.erb index c153bf239..503c1a82b 100644 --- a/jobs/gorouter/templates/gorouter.yml.erb +++ b/jobs/gorouter/templates/gorouter.yml.erb @@ -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 diff --git a/src/code.cloudfoundry.org/go.sum b/src/code.cloudfoundry.org/go.sum index e840b7076..eec8a8d38 100644 --- a/src/code.cloudfoundry.org/go.sum +++ b/src/code.cloudfoundry.org/go.sum @@ -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= diff --git a/src/code.cloudfoundry.org/gorouter/config/config.go b/src/code.cloudfoundry.org/gorouter/config/config.go index 9815763c7..8fcef97f9 100644 --- a/src/code.cloudfoundry.org/gorouter/config/config.go +++ b/src/code.cloudfoundry.org/gorouter/config/config.go @@ -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=;Subject="" format ) var ( @@ -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{} @@ -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"` @@ -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"` @@ -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 } @@ -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 { @@ -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) } diff --git a/src/code.cloudfoundry.org/gorouter/handlers/clientcert.go b/src/code.cloudfoundry.org/gorouter/handlers/clientcert.go index 437387f64..71377a753 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/clientcert.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/clientcert.go @@ -1,6 +1,10 @@ package handlers import ( + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" "encoding/pem" "errors" "fmt" @@ -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 } @@ -30,6 +35,7 @@ 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 { @@ -37,6 +43,7 @@ func NewClientCert( skipSanitization: skipSanitization, forceDeleteHeader: forceDeleteHeader, forwardingMode: forwardingMode, + config: cfg, logger: logger, errorWriter: ew, } @@ -45,8 +52,22 @@ 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) @@ -54,7 +75,11 @@ func (c *clientCert) ServeHTTP(rw http.ResponseWriter, r *http.Request, next htt 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) + } } } } @@ -95,6 +120,67 @@ func replaceXFCCHeader(r *http.Request) { } } +// replaceXFCCHeaderEnvoyFormat sets the X-Forwarded-Client-Cert header using Envoy's +// compact format: Hash=;Subject="" +// 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=;Subject="" +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-----", "", diff --git a/src/code.cloudfoundry.org/gorouter/handlers/clientcert_test.go b/src/code.cloudfoundry.org/gorouter/handlers/clientcert_test.go index f8caa1bdf..44e42fcf0 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/clientcert_test.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/clientcert_test.go @@ -45,7 +45,8 @@ var _ = Describe("Clientcert", func() { DescribeTable("Client Cert Error Handling", func(forceDeleteHeaderFunc func(*http.Request) (bool, error), skipSanitizationFunc func(*http.Request) bool, errorCase string) { logger = test_util.NewTestLogger("") - clientCertHandler := handlers.NewClientCert(skipSanitizationFunc, forceDeleteHeaderFunc, config.SANITIZE_SET, logger.Logger, errorWriter) + cfg, _ := config.DefaultConfig() + clientCertHandler := handlers.NewClientCert(skipSanitizationFunc, forceDeleteHeaderFunc, config.SANITIZE_SET, cfg, logger.Logger, errorWriter) nextHandlerWasCalled := false nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { nextHandlerWasCalled = true }) @@ -82,7 +83,8 @@ var _ = Describe("Clientcert", func() { DescribeTable("Client Cert Result", func(forceDeleteHeaderFunc func(*http.Request) (bool, error), skipSanitizationFunc func(*http.Request) bool, forwardedClientCert string, noTLSCertStrip bool, TLSCertStrip bool, mTLSCertStrip string) { logger = test_util.NewTestLogger("test") - clientCertHandler := handlers.NewClientCert(skipSanitizationFunc, forceDeleteHeaderFunc, forwardedClientCert, logger.Logger, errorWriter) + cfg, _ := config.DefaultConfig() + clientCertHandler := handlers.NewClientCert(skipSanitizationFunc, forceDeleteHeaderFunc, forwardedClientCert, cfg, logger.Logger, errorWriter) nextReq := &http.Request{} nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { nextReq = r }) @@ -209,3 +211,201 @@ func sanitize(cert []byte) string { "\n", "") return r.Replace(s) } + +var _ = Describe("Clientcert mTLS Domain XFCC Format", func() { + var ( + dontForceDeleteHeader = func(req *http.Request) (bool, error) { return false, nil } + dontSkipSanitization = func(req *http.Request) bool { return false } + errorWriter = errorwriter.NewPlaintextErrorWriter() + logger *test_util.TestLogger + ) + + Describe("Envoy XFCC Format", func() { + It("uses Envoy format when configured for mTLS domain", func() { + logger = test_util.NewTestLogger("test") + + // Create instance identity cert with Diego format OUs + certChain := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "instance-id-123", + AppGUID: "app-guid-456", + SpaceGUID: "space-guid-789", + OrgGUID: "org-guid-abc", + }) + + // Configure mTLS domain with Envoy format + cfg, err := config.DefaultConfig() + Expect(err).NotTo(HaveOccurred()) + + cfg.MtlsDomains = []config.MtlsDomainConfig{{ + Domain: "*.apps.mtls.internal", + CACerts: string(certChain.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + XFCCFormat: config.XFCC_FORMAT_ENVOY, + }} + err = cfg.Process() + Expect(err).NotTo(HaveOccurred()) + + clientCertHandler := handlers.NewClientCert(dontSkipSanitization, dontForceDeleteHeader, config.SANITIZE_SET, cfg, logger.Logger, errorWriter) + + var capturedXFCC string + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedXFCC = r.Header.Get("X-Forwarded-Client-Cert") + }) + + n := negroni.New() + n.Use(clientCertHandler) + n.UseHandlerFunc(nextHandler) + + // Setup mTLS test server + tlsCert, err := tls.X509KeyPair(certChain.CertPEM, certChain.PrivKeyPEM) + Expect(err).ToNot(HaveOccurred()) + + certPool := x509.NewCertPool() + certPool.AddCert(certChain.CACert) + + serverTLSConfig := &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + ClientCAs: certPool, + ClientAuth: tls.RequestClientCert, + } + + server := httptest.NewUnstartedServer(n) + server.TLS = serverTLSConfig + server.StartTLS() + defer server.Close() + + // Create client with mTLS cert + clientTLSConfig := &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + RootCAs: certPool, + InsecureSkipVerify: true, // Test server uses 127.0.0.1 which isn't in cert SANs + } + + transport := &http.Transport{TLSClientConfig: clientTLSConfig} + client := &http.Client{Transport: transport} + + // Make request to mTLS domain + req, err := http.NewRequest("GET", server.URL, nil) + Expect(err).NotTo(HaveOccurred()) + req.Host = "myapp.apps.mtls.internal" + + _, err = client.Do(req) + Expect(err).ToNot(HaveOccurred()) + + // Verify Envoy format: Hash=;Subject="" + Expect(capturedXFCC).To(HavePrefix("Hash=")) + Expect(capturedXFCC).To(ContainSubstring(";Subject=\"")) + + // Verify Subject contains OUs + Expect(capturedXFCC).To(ContainSubstring("OU=app:app-guid-456")) + Expect(capturedXFCC).To(ContainSubstring("OU=space:space-guid-789")) + Expect(capturedXFCC).To(ContainSubstring("OU=organization:org-guid-abc")) + Expect(capturedXFCC).To(ContainSubstring("CN=instance-id-123")) + + // Verify it doesn't contain base64-encoded cert (which would be much longer) + Expect(len(capturedXFCC)).To(BeNumerically("<", 500)) // Envoy format is ~300 bytes + }) + + It("uses raw format when configured for mTLS domain", func() { + logger = test_util.NewTestLogger("test") + + // Create instance identity cert + certChain := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "instance-id-123", + AppGUID: "app-guid-456", + }) + + // Configure mTLS domain with raw format (default) + cfg, err := config.DefaultConfig() + Expect(err).NotTo(HaveOccurred()) + + cfg.MtlsDomains = []config.MtlsDomainConfig{{ + Domain: "*.apps.mtls.internal", + CACerts: string(certChain.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + XFCCFormat: config.XFCC_FORMAT_RAW, + }} + err = cfg.Process() + Expect(err).NotTo(HaveOccurred()) + + clientCertHandler := handlers.NewClientCert(dontSkipSanitization, dontForceDeleteHeader, config.SANITIZE_SET, cfg, logger.Logger, errorWriter) + + var capturedXFCC string + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedXFCC = r.Header.Get("X-Forwarded-Client-Cert") + }) + + n := negroni.New() + n.Use(clientCertHandler) + n.UseHandlerFunc(nextHandler) + + // Setup mTLS test server + tlsCert, err := tls.X509KeyPair(certChain.CertPEM, certChain.PrivKeyPEM) + Expect(err).ToNot(HaveOccurred()) + + certPool := x509.NewCertPool() + certPool.AddCert(certChain.CACert) + + serverTLSConfig := &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + ClientCAs: certPool, + ClientAuth: tls.RequestClientCert, + } + + server := httptest.NewUnstartedServer(n) + server.TLS = serverTLSConfig + server.StartTLS() + defer server.Close() + + // Create client with mTLS cert + clientTLSConfig := &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + RootCAs: certPool, + InsecureSkipVerify: true, // Test server uses 127.0.0.1 which isn't in cert SANs + } + + transport := &http.Transport{TLSClientConfig: clientTLSConfig} + client := &http.Client{Transport: transport} + + // Make request to mTLS domain + req, err := http.NewRequest("GET", server.URL, nil) + Expect(err).NotTo(HaveOccurred()) + req.Host = "myapp.apps.mtls.internal" + + _, err = client.Do(req) + Expect(err).ToNot(HaveOccurred()) + + // Verify raw format: base64-encoded certificate (no "Hash=" or "Subject=") + Expect(capturedXFCC).NotTo(HavePrefix("Hash=")) + Expect(capturedXFCC).NotTo(ContainSubstring("Subject=")) + + // Raw format is base64-encoded cert, much larger + Expect(len(capturedXFCC)).To(BeNumerically(">", 1000)) + }) + + It("defaults to raw format when xfcc_format is not specified", func() { + logger = test_util.NewTestLogger("test") + + certChain := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "instance-id-123", + AppGUID: "app-guid-456", + }) + + // Configure mTLS domain without xfcc_format + cfg, err := config.DefaultConfig() + Expect(err).NotTo(HaveOccurred()) + + cfg.MtlsDomains = []config.MtlsDomainConfig{{ + Domain: "*.apps.mtls.internal", + CACerts: string(certChain.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + // XFCCFormat not set - should default to "raw" + }} + err = cfg.Process() + Expect(err).NotTo(HaveOccurred()) + + // After Process(), XFCCFormat should be set to "raw" + Expect(cfg.MtlsDomains[0].XFCCFormat).To(Equal(config.XFCC_FORMAT_RAW)) + }) + }) +}) diff --git a/src/code.cloudfoundry.org/gorouter/handlers/identity.go b/src/code.cloudfoundry.org/gorouter/handlers/identity.go new file mode 100644 index 000000000..2959e0e98 --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/identity.go @@ -0,0 +1,200 @@ +package handlers + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "net/http" + "strings" + + "github.com/urfave/negroni/v3" +) + +// CallerIdentity represents the identity of the calling application extracted from mTLS +// certificate. The certificate OU field contains: +// - app: for the application GUID +// - space: for the space GUID +// - organization: for the organization GUID +type CallerIdentity struct { + AppGUID string + SpaceGUID string + OrgGUID string +} + +// identityHandler extracts the caller identity from the X-Forwarded-Client-Cert header +// on mTLS domains. The identity is stored in the RequestInfo context for use by +// authorization handlers. +type identityHandler struct{} + +// NewIdentity creates a new identity extraction handler +func NewIdentity() negroni.Handler { + return &identityHandler{} +} + +func (h *identityHandler) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := ContextRequestInfo(r) + if err != nil { + // If RequestInfo is not available, continue without setting identity + next(w, r) + return + } + + // Extract identity from X-Forwarded-Client-Cert header + xfccHeader := r.Header.Get("X-Forwarded-Client-Cert") + if xfccHeader != "" { + identity, err := extractIdentityFromXFCC(xfccHeader) + if err == nil { + reqInfo.CallerIdentity = identity + } + // If extraction fails, continue without setting identity + // The authorization handler will deny access if identity is required + } + + next(w, r) +} + +// extractIdentityFromXFCC parses the X-Forwarded-Client-Cert header and extracts +// the application, space, and organization GUIDs from the client certificate's +// OU (Organizational Unit) field. +// +// Supported XFCC formats: +// 1. Envoy compact format: Hash=;Subject="" - parse OUs from Subject string +// 2. Envoy format with cert: Cert="" +// 3. GoRouter format: raw base64 (no PEM markers) - produced by clientcert.go sanitize() +// +// Expected OU formats: +// - "app:" +// - "space:" +// - "organization:" +func extractIdentityFromXFCC(xfcc string) (*CallerIdentity, error) { + // Try Envoy compact format first: Subject="" + // This is the most efficient format since we don't need to decode a certificate + if subjectStart := strings.Index(xfcc, "Subject=\""); subjectStart != -1 { + subjectStart += len("Subject=\"") + subjectEnd := strings.Index(xfcc[subjectStart:], "\"") + if subjectEnd == -1 { + return nil, errors.New("malformed Subject field in XFCC header") + } + subjectDN := xfcc[subjectStart : subjectStart+subjectEnd] + return extractIdentityFromSubjectDN(subjectDN) + } + + // Try Envoy format with cert: Cert="" + var certDER []byte + var err error + + if certStart := strings.Index(xfcc, "Cert=\""); certStart != -1 { + certStart += len("Cert=\"") + certEnd := strings.Index(xfcc[certStart:], "\"") + if certEnd == -1 { + return nil, errors.New("malformed Cert field in XFCC header") + } + pemData := xfcc[certStart : certStart+certEnd] + + // Decode PEM block + block, _ := pem.Decode([]byte(pemData)) + if block == nil { + return nil, errors.New("failed to decode PEM certificate") + } + certDER = block.Bytes + } else { + // GoRouter format: raw base64 without PEM markers + // The clientcert.go sanitize() function strips PEM markers and newlines + certDER, err = base64.StdEncoding.DecodeString(strings.TrimSpace(xfcc)) + if err != nil { + return nil, errors.New("failed to decode base64 certificate: " + err.Error()) + } + } + + // Parse X.509 certificate + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, err + } + + return extractIdentityFromCert(cert) +} + +// extractIdentityFromSubjectDN parses a Subject DN string and extracts GUIDs +// DN format: "CN=instance-id,OU=app:guid,OU=space:guid,OU=organization:guid" +func extractIdentityFromSubjectDN(subjectDN string) (*CallerIdentity, error) { + identity := &CallerIdentity{} + + // Split DN into RDNs (Relative Distinguished Names) + // Handle both comma and slash separators + var rdns []string + if strings.Contains(subjectDN, ",") { + rdns = strings.Split(subjectDN, ",") + } else if strings.Contains(subjectDN, "/") { + // Some formats use "/" as separator + rdns = strings.Split(subjectDN, "/") + } else { + return nil, errors.New("unrecognized DN format") + } + + for _, rdn := range rdns { + rdn = strings.TrimSpace(rdn) + if rdn == "" { + continue + } + + // Parse OU fields + if strings.HasPrefix(rdn, "OU=") { + ouValue := strings.TrimPrefix(rdn, "OU=") + if strings.HasPrefix(ouValue, "app:") { + appGUID := strings.TrimPrefix(ouValue, "app:") + if appGUID != "" { + identity.AppGUID = appGUID + } + } else if strings.HasPrefix(ouValue, "space:") { + spaceGUID := strings.TrimPrefix(ouValue, "space:") + if spaceGUID != "" { + identity.SpaceGUID = spaceGUID + } + } else if strings.HasPrefix(ouValue, "organization:") { + orgGUID := strings.TrimPrefix(ouValue, "organization:") + if orgGUID != "" { + identity.OrgGUID = orgGUID + } + } + } + } + + // At minimum, require app GUID to be present + if identity.AppGUID == "" { + return nil, errors.New("no app GUID found in Subject DN") + } + + return identity, nil +} + +// extractIdentityFromCert extracts GUIDs from an X.509 certificate's OU fields +func extractIdentityFromCert(cert *x509.Certificate) (*CallerIdentity, error) { + identity := &CallerIdentity{} + for _, ou := range cert.Subject.OrganizationalUnit { + if strings.HasPrefix(ou, "app:") { + appGUID := strings.TrimPrefix(ou, "app:") + if appGUID != "" { + identity.AppGUID = appGUID + } + } else if strings.HasPrefix(ou, "space:") { + spaceGUID := strings.TrimPrefix(ou, "space:") + if spaceGUID != "" { + identity.SpaceGUID = spaceGUID + } + } else if strings.HasPrefix(ou, "organization:") { + orgGUID := strings.TrimPrefix(ou, "organization:") + if orgGUID != "" { + identity.OrgGUID = orgGUID + } + } + } + + // At minimum, require app GUID to be present + if identity.AppGUID == "" { + return nil, errors.New("no app GUID found in certificate OU") + } + + return identity, nil +} diff --git a/src/code.cloudfoundry.org/gorouter/handlers/identity_test.go b/src/code.cloudfoundry.org/gorouter/handlers/identity_test.go new file mode 100644 index 000000000..246bc14a9 --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/identity_test.go @@ -0,0 +1,557 @@ +package handlers_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/urfave/negroni/v3" + + "code.cloudfoundry.org/gorouter/handlers" + "code.cloudfoundry.org/gorouter/test_util" +) + +var _ = Describe("Identity", func() { + var ( + handler negroni.Handler + nextCalled bool + nextHandler http.HandlerFunc + recorder *httptest.ResponseRecorder + request *http.Request + requestInfo *handlers.RequestInfo + ) + + BeforeEach(func() { + handler = handlers.NewIdentity() + nextCalled = false + recorder = httptest.NewRecorder() + + nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + request = r + }) + + request = test_util.NewRequest("GET", "backend.apps.mtls.internal", "/", nil) + }) + + Context("when RequestInfo is not in context", func() { + It("calls next handler without setting identity", func() { + handler.ServeHTTP(recorder, request, nextHandler) + + Expect(nextCalled).To(BeTrue()) + Expect(recorder.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("when RequestInfo is in context", func() { + var runHandler = func() { + // Add RequestInfo to context + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.Use(handler) + n.UseHandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + request = r + // Capture RequestInfo for assertions + var err error + requestInfo, err = handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + }) + + n.ServeHTTP(recorder, request) + } + + Context("when X-Forwarded-Client-Cert header is not present", func() { + It("calls next handler without setting identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("when X-Forwarded-Client-Cert header is present", func() { + Context("with valid cert containing app GUID in OU", func() { + BeforeEach(func() { + cert := generateTestCert("app:test-app-guid-123") + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts caller identity with app GUID", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("test-app-guid-123")) + }) + }) + + Context("with valid cert in GoRouter format (raw base64)", func() { + BeforeEach(func() { + cert := generateTestCert("app:gorouter-format-app-guid") + xfccHeader := buildGoRouterXFCCHeader(cert) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts caller identity with app GUID", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("gorouter-format-app-guid")) + }) + }) + + Context("with cert containing multiple OUs including app GUID", func() { + BeforeEach(func() { + cert := generateTestCertWithMultipleOUs([]string{ + "organization-unit-1", + "app:another-app-guid", + "organization-unit-2", + }) + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts the app GUID from the correct OU", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("another-app-guid")) + }) + }) + + Context("with malformed XFCC header", func() { + Context("missing Cert field", func() { + BeforeEach(func() { + request.Header.Set("X-Forwarded-Client-Cert", "Hash=123;Subject=\"CN=test\"") + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("missing closing quote in Cert field", func() { + BeforeEach(func() { + request.Header.Set("X-Forwarded-Client-Cert", "Cert=\"-----BEGIN CERTIFICATE-----\nMIIB") + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("invalid PEM data", func() { + BeforeEach(func() { + request.Header.Set("X-Forwarded-Client-Cert", "Cert=\"not-a-valid-pem-cert\"") + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("invalid certificate data", func() { + BeforeEach(func() { + invalidPEM := "-----BEGIN CERTIFICATE-----\nSW52YWxpZCBjZXJ0aWZpY2F0ZSBkYXRh\n-----END CERTIFICATE-----" + request.Header.Set("X-Forwarded-Client-Cert", buildXFCCHeader(invalidPEM)) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + }) + + Context("with cert missing app GUID in OU", func() { + Context("no OU fields", func() { + BeforeEach(func() { + cert := generateTestCertWithMultipleOUs([]string{}) + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("OU fields without app: prefix", func() { + BeforeEach(func() { + cert := generateTestCertWithMultipleOUs([]string{ + "organization-unit-1", + "organization-unit-2", + }) + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("OU with app: prefix but empty GUID", func() { + BeforeEach(func() { + cert := generateTestCert("app:") + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + }) + }) + }) +}) + +func generateTestCertWithOrgAndSpace() *x509.Certificate { + return generateTestCertWithMultipleOUs([]string{ + "app:test-app-guid", + "space:test-space-guid", + "organization:test-org-guid", + }) +} + +func buildTestCertWithIdentity(appGUID, spaceGUID, orgGUID string) *x509.Certificate { + ous := []string{} + if appGUID != "" { + ous = append(ous, "app:"+appGUID) + } + if spaceGUID != "" { + ous = append(ous, "space:"+spaceGUID) + } + if orgGUID != "" { + ous = append(ous, "organization:"+orgGUID) + } + return generateTestCertWithMultipleOUs(ous) +} + +var _ = Describe("Identity with Space and Org extraction", func() { + var ( + handler negroni.Handler + nextCalled bool + recorder *httptest.ResponseRecorder + request *http.Request + requestInfo *handlers.RequestInfo + ) + + BeforeEach(func() { + handler = handlers.NewIdentity() + nextCalled = false + recorder = httptest.NewRecorder() + + request = test_util.NewRequest("GET", "backend.apps.mtls.internal", "/", nil) + }) + + var runHandler = func() { + // Add RequestInfo to context + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.Use(handler) + n.UseHandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + request = r + // Capture RequestInfo for assertions + var err error + requestInfo, err = handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + }) + + n.ServeHTTP(recorder, request) + } + + Context("when cert contains app, space, and org GUIDs", func() { + BeforeEach(func() { + cert := generateTestCertWithOrgAndSpace() + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts all three GUIDs", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("test-app-guid")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("test-space-guid")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("test-org-guid")) + }) + }) + + Context("when cert contains only app and space GUIDs", func() { + BeforeEach(func() { + cert := buildTestCertWithIdentity("my-app", "my-space", "") + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts app and space GUIDs", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("my-app")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("my-space")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("")) + }) + }) + + Context("when cert contains only app GUID", func() { + BeforeEach(func() { + cert := buildTestCertWithIdentity("my-app", "", "") + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts only app GUID", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("my-app")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("")) + }) + }) + + Context("when cert contains space and org but no app GUID", func() { + BeforeEach(func() { + cert := buildTestCertWithIdentity("", "my-space", "my-org") + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity (app GUID required)", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) +}) + +// Helper functions for generating test certificates + +func generateTestCert(ou string) *x509.Certificate { + return generateTestCertWithMultipleOUs([]string{ou}) +} + +func generateTestCertWithMultipleOUs(ous []string) *x509.Certificate { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "test-instance", + OrganizationalUnit: ous, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + Expect(err).NotTo(HaveOccurred()) + + cert, err := x509.ParseCertificate(certDER) + Expect(err).NotTo(HaveOccurred()) + + return cert +} + +func encodeCertToPEM(cert *x509.Certificate) string { + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) + return string(certPEM) +} + +func buildXFCCHeader(certPEM string) string { + // XFCC header format: Cert="" + return "Cert=\"" + certPEM + "\"" +} + +// buildGoRouterXFCCHeader produces the format that GoRouter's clientcert.go uses: +// raw base64 without PEM markers (produced by sanitize() function) +func buildGoRouterXFCCHeader(cert *x509.Certificate) string { + return base64.StdEncoding.EncodeToString(cert.Raw) +} + +var _ = Describe("Identity with Envoy Subject DN format", func() { + var ( + handler negroni.Handler + nextCalled bool + recorder *httptest.ResponseRecorder + request *http.Request + requestInfo *handlers.RequestInfo + ) + + BeforeEach(func() { + handler = handlers.NewIdentity() + nextCalled = false + recorder = httptest.NewRecorder() + + request = test_util.NewRequest("GET", "backend.apps.mtls.internal", "/", nil) + }) + + var runHandler = func() { + // Add RequestInfo to context + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.Use(handler) + n.UseHandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + request = r + // Capture RequestInfo for assertions + var err error + requestInfo, err = handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + }) + + n.ServeHTTP(recorder, request) + } + + Context("when XFCC header is in Envoy compact format with Subject DN", func() { + Context("with comma-separated DN format", func() { + BeforeEach(func() { + // Envoy format: Hash=;Subject="" + xfccHeader := `Hash=abc123;Subject="CN=instance-id,OU=app:envoy-app-guid,OU=space:envoy-space-guid,OU=organization:envoy-org-guid"` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts all GUIDs from Subject DN", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("envoy-app-guid")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("envoy-space-guid")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("envoy-org-guid")) + }) + }) + + Context("with slash-separated DN format", func() { + BeforeEach(func() { + // Some systems use slash-separated format + xfccHeader := `Hash=abc123;Subject="/CN=instance-id/OU=app:slash-app-guid/OU=space:slash-space-guid/OU=organization:slash-org-guid"` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts all GUIDs from Subject DN", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("slash-app-guid")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("slash-space-guid")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("slash-org-guid")) + }) + }) + + Context("with only app GUID in Subject", func() { + BeforeEach(func() { + xfccHeader := `Hash=def456;Subject="CN=instance,OU=app:only-app-guid"` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts app GUID", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("only-app-guid")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("")) + }) + }) + + Context("with Subject but no app GUID", func() { + BeforeEach(func() { + xfccHeader := `Hash=ghi789;Subject="CN=instance,OU=space:some-space,OU=organization:some-org"` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity (app GUID required)", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("with malformed Subject field", func() { + BeforeEach(func() { + // Missing closing quote + xfccHeader := `Hash=jkl012;Subject="CN=instance,OU=app:test-app` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("with empty Subject", func() { + BeforeEach(func() { + xfccHeader := `Hash=mno345;Subject=""` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("with Subject containing extra whitespace", func() { + BeforeEach(func() { + xfccHeader := `Hash=pqr678;Subject="CN=instance, OU=app:whitespace-app-guid, OU=space:whitespace-space-guid"` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("trims whitespace and extracts GUIDs", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("whitespace-app-guid")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("whitespace-space-guid")) + }) + }) + }) +}) diff --git a/src/code.cloudfoundry.org/gorouter/handlers/mtls_authorization.go b/src/code.cloudfoundry.org/gorouter/handlers/mtls_authorization.go new file mode 100644 index 000000000..ad5d5dfba --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/mtls_authorization.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "log/slog" + "net/http" + "slices" + + "github.com/urfave/negroni/v3" + + "code.cloudfoundry.org/gorouter/config" + "code.cloudfoundry.org/gorouter/logger" + "code.cloudfoundry.org/gorouter/route" +) + +// mtlsAuthorization enforces authorization checks on mTLS domains by verifying +// that the calling application is in the allowed sources list for the target endpoint. +type mtlsAuthorization struct { + config *config.Config + logger *slog.Logger +} + +// NewMtlsAuthorization creates a new mTLS authorization handler +func NewMtlsAuthorization(cfg *config.Config, logger *slog.Logger) negroni.Handler { + return &mtlsAuthorization{ + config: cfg, + logger: logger, + } +} + +// setRouteEndpointForAccessLog sets the RouteEndpoint on reqInfo so that access logs +// are emitted to the target app even when the request is denied by authorization. +// This allows operators to see denied requests in the app's log stream. +func setRouteEndpointForAccessLog(reqInfo *RequestInfo, pool *route.EndpointPool, logger *slog.Logger) { + if pool == nil || reqInfo.RouteEndpoint != nil { + return + } + // Get an endpoint from the pool for access logging purposes + iter := pool.Endpoints(logger, "", false, "", "") + if endpoint := iter.Next(0); endpoint != nil { + reqInfo.RouteEndpoint = endpoint + } +} + +func (h *mtlsAuthorization) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := ContextRequestInfo(r) + if err != nil { + // If RequestInfo is not available, return 500 + h.logger.Error("mtls-authorization-failed", logger.ErrAttr(err), slog.String("reason", "request-info-missing")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Check if this is an mTLS domain + if !h.config.IsMtlsDomain(r.Host) { + // Not an mTLS domain, no authorization required + next(w, r) + return + } + + // On mTLS domains, we need a valid route pool to check authorization + // Note: RoutePool is set by the Lookup handler, RouteEndpoint is set later by the proxy + if reqInfo.RoutePool == nil || reqInfo.RoutePool.IsEmpty() { + h.logger.Info("mtls-authorization-denied", + slog.String("host", r.Host), + slog.String("reason", "no-route-pool")) + w.WriteHeader(http.StatusNotFound) + return + } + + pool := reqInfo.RoutePool + applicationId := pool.ApplicationId() + + // Get MtlsAllowedSources from the pool + // All endpoints in a pool have the same MtlsAllowedSources + mtlsAllowedSources := pool.MtlsAllowedSources() + + // If pool has no allowed sources, deny by default on mTLS domains + // Per RFC: if Any is not set and no Apps/Spaces/Orgs are specified, default-deny + if mtlsAllowedSources == nil { + h.logger.Info("mtls-authorization-denied", + slog.String("host", r.Host), + slog.String("endpoint-app", applicationId), + slog.String("reason", "no-mtls-allowed-sources")) + setRouteEndpointForAccessLog(reqInfo, pool, h.logger) + w.WriteHeader(http.StatusForbidden) + return + } + + // If Any is true, allow any authenticated app + if mtlsAllowedSources.Any { + // Check that caller identity exists (authenticated) + if reqInfo.CallerIdentity == nil { + h.logger.Info("mtls-authorization-denied", + slog.String("host", r.Host), + slog.String("endpoint-app", applicationId), + slog.String("reason", "no-caller-identity")) + setRouteEndpointForAccessLog(reqInfo, pool, h.logger) + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Any authenticated app is allowed + h.logger.Debug("mtls-authorization-granted", + slog.String("host", r.Host), + slog.String("endpoint-app", applicationId), + slog.String("caller-app", reqInfo.CallerIdentity.AppGUID), + slog.String("reason", "any-authenticated-app")) + next(w, r) + return + } + + // If Any is false, check specific Apps/Spaces/Orgs + // At least one of Apps/Spaces/Orgs must be specified (RFC requirement) + if len(mtlsAllowedSources.Apps) == 0 && len(mtlsAllowedSources.Spaces) == 0 && len(mtlsAllowedSources.Orgs) == 0 { + h.logger.Info("mtls-authorization-denied", + slog.String("host", r.Host), + slog.String("endpoint-app", applicationId), + slog.String("reason", "empty-mtls-allowed-sources")) + setRouteEndpointForAccessLog(reqInfo, pool, h.logger) + w.WriteHeader(http.StatusForbidden) + return + } + + // Check if caller identity was extracted from client certificate + if reqInfo.CallerIdentity == nil { + h.logger.Info("mtls-authorization-denied", + slog.String("host", r.Host), + slog.String("endpoint-app", applicationId), + slog.String("reason", "no-caller-identity")) + setRouteEndpointForAccessLog(reqInfo, pool, h.logger) + w.WriteHeader(http.StatusUnauthorized) + return + } + + identity := reqInfo.CallerIdentity + + // Check if caller's app GUID is in the allowed apps list + if slices.Contains(mtlsAllowedSources.Apps, identity.AppGUID) { + h.logger.Debug("mtls-authorization-granted", + slog.String("host", r.Host), + slog.String("endpoint-app", applicationId), + slog.String("caller-app", identity.AppGUID), + slog.String("reason", "app-in-allowed-list")) + next(w, r) + return + } + + // Check if caller's space GUID is in the allowed spaces list + if identity.SpaceGUID != "" && slices.Contains(mtlsAllowedSources.Spaces, identity.SpaceGUID) { + h.logger.Debug("mtls-authorization-granted", + slog.String("host", r.Host), + slog.String("endpoint-app", applicationId), + slog.String("caller-app", identity.AppGUID), + slog.String("caller-space", identity.SpaceGUID), + slog.String("reason", "space-in-allowed-list")) + next(w, r) + return + } + + // Check if caller's org GUID is in the allowed orgs list + if identity.OrgGUID != "" && slices.Contains(mtlsAllowedSources.Orgs, identity.OrgGUID) { + h.logger.Debug("mtls-authorization-granted", + slog.String("host", r.Host), + slog.String("endpoint-app", applicationId), + slog.String("caller-app", identity.AppGUID), + slog.String("caller-org", identity.OrgGUID), + slog.String("reason", "org-in-allowed-list")) + next(w, r) + return + } + + // Caller not authorized + h.logger.Info("mtls-authorization-denied", + slog.String("host", r.Host), + slog.String("endpoint-app", applicationId), + slog.String("caller-app", identity.AppGUID), + slog.String("caller-space", identity.SpaceGUID), + slog.String("caller-org", identity.OrgGUID), + slog.String("reason", "not-in-mtls-allowed-sources")) + setRouteEndpointForAccessLog(reqInfo, pool, h.logger) + w.WriteHeader(http.StatusForbidden) +} diff --git a/src/code.cloudfoundry.org/gorouter/handlers/mtls_authorization_test.go b/src/code.cloudfoundry.org/gorouter/handlers/mtls_authorization_test.go new file mode 100644 index 000000000..b0b6c7f2d --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/mtls_authorization_test.go @@ -0,0 +1,936 @@ +package handlers_test + +import ( + "log/slog" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/urfave/negroni/v3" + + "code.cloudfoundry.org/gorouter/config" + "code.cloudfoundry.org/gorouter/handlers" + "code.cloudfoundry.org/gorouter/route" + "code.cloudfoundry.org/gorouter/test_util" +) + +var _ = Describe("MtlsAuthorization", func() { + var ( + handler negroni.Handler + cfg *config.Config + logger *test_util.TestLogger + nextCalled bool + nextHandler http.HandlerFunc + recorder *httptest.ResponseRecorder + request *http.Request + ) + + // Helper to create a pool with an endpoint + createPoolWithEndpoint := func(endpoint *route.Endpoint) *route.EndpointPool { + pool := route.NewPool(&route.PoolOpts{ + Host: "backend.apps.mtls.internal", + Logger: slog.Default(), + LoadBalancingAlgorithm: config.LOAD_BALANCE_RR, + }) + pool.Put(endpoint) + return pool + } + + BeforeEach(func() { + logger = test_util.NewTestLogger("mtls-authorization") + cfg, _ = config.DefaultConfig() + + // Generate a valid CA certificate for mTLS domain config + _, caCertPEM := test_util.CreateKeyPair("test-ca") + + // Configure an mTLS domain + cfg.MtlsDomains = []config.MtlsDomainConfig{ + { + Domain: "*.apps.mtls.internal", + CACerts: string(caCertPEM), + ForwardedClientCert: config.SANITIZE_SET, + }, + } + err := cfg.Process() + Expect(err).NotTo(HaveOccurred()) + + handler = handlers.NewMtlsAuthorization(cfg, logger.Logger) + nextCalled = false + recorder = httptest.NewRecorder() + + nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + }) + }) + + var runHandler = func() { + // Set up handler chain with RequestInfo + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + } + + Context("when RequestInfo is not in context", func() { + BeforeEach(func() { + request = test_util.NewRequest("GET", "backend.apps.mtls.internal", "/", nil) + }) + + It("returns 500 Internal Server Error", func() { + handler.ServeHTTP(recorder, request, nextHandler) + + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Context("when request is not on an mTLS domain", func() { + BeforeEach(func() { + request = test_util.NewRequest("GET", "regular.example.com", "/", nil) + }) + + It("calls next handler without authorization", func() { + runHandler() + + Expect(nextCalled).To(BeTrue()) + Expect(recorder.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("when request is on an mTLS domain", func() { + BeforeEach(func() { + request = test_util.NewRequest("GET", "backend.apps.mtls.internal", "/", nil) + }) + + Context("when no route pool is set", func() { + BeforeEach(func() { + // Don't set RoutePool in RequestInfo + }) + + It("returns 404 Not Found", func() { + runHandler() + + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Context("when route pool has no allowed sources", func() { + BeforeEach(func() { + // Create endpoint without allowed sources + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + }) + + pool := createPoolWithEndpoint(endpoint) + + // Set up request with pool but no allowed sources + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("returns 403 Forbidden", func() { + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusForbidden)) + }) + }) + + Context("when route pool has empty allowed sources", func() { + BeforeEach(func() { + // Create endpoint with empty allowed sources (default deny) + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{}, + }) + + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("returns 403 Forbidden", func() { + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusForbidden)) + }) + }) + + Context("when route pool has allowed sources", func() { + var endpoint *route.Endpoint + var pool *route.EndpointPool + + BeforeEach(func() { + // Create endpoint with allowed sources + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{ + Apps: []string{"allowed-app-1", "allowed-app-2"}, + }, + }) + pool = createPoolWithEndpoint(endpoint) + }) + + Context("when caller identity is not set", func() { + BeforeEach(func() { + // Set up request with pool but no caller identity + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + // Don't set CallerIdentity + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("returns 401 Unauthorized", func() { + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Context("when caller is not in allowed sources list", func() { + BeforeEach(func() { + // Set up request with pool and caller identity that's not allowed + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "unauthorized-app", + } + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("returns 403 Forbidden", func() { + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusForbidden)) + }) + }) + + Context("when caller is in allowed sources list", func() { + BeforeEach(func() { + // Set up request with pool and authorized caller identity + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "allowed-app-2", + } + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("calls next handler", func() { + Expect(nextCalled).To(BeTrue()) + Expect(recorder.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("when caller matches first app in allowed sources list", func() { + BeforeEach(func() { + // Test that authorization works for first app in list + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "allowed-app-1", + } + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("calls next handler", func() { + Expect(nextCalled).To(BeTrue()) + Expect(recorder.Code).To(Equal(http.StatusOK)) + }) + }) + }) + + Context("with wildcard mTLS domain matching", func() { + BeforeEach(func() { + // Test with specific subdomain under wildcard + request = test_util.NewRequest("GET", "my-service.apps.mtls.internal", "/", nil) + }) + + Context("when pool has no allowed sources", func() { + BeforeEach(func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + }) + + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("returns 403 Forbidden", func() { + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusForbidden)) + }) + }) + }) + }) + + Context("when multiple mTLS domains are configured", func() { + BeforeEach(func() { + // Generate valid CA certificates for mTLS domain configs + _, caCertPEM1 := test_util.CreateKeyPair("test-ca-1") + _, caCertPEM2 := test_util.CreateKeyPair("test-ca-2") + + // Configure multiple mTLS domains + cfg.MtlsDomains = []config.MtlsDomainConfig{ + { + Domain: "*.apps.mtls.internal", + CACerts: string(caCertPEM1), + ForwardedClientCert: config.SANITIZE_SET, + }, + { + Domain: "*.services.mtls.internal", + CACerts: string(caCertPEM2), + ForwardedClientCert: config.SANITIZE_SET, + }, + } + err := cfg.Process() + Expect(err).NotTo(HaveOccurred()) + + handler = handlers.NewMtlsAuthorization(cfg, logger.Logger) + }) + + Context("when request is on first mTLS domain", func() { + BeforeEach(func() { + request = test_util.NewRequest("GET", "api.apps.mtls.internal", "/", nil) + }) + + It("enforces authorization for first domain", func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "instance-id", + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusForbidden)) + }) + }) + + Context("when request is on second mTLS domain", func() { + BeforeEach(func() { + request = test_util.NewRequest("GET", "db.services.mtls.internal", "/", nil) + }) + + It("enforces authorization for second domain", func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "instance-id", + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusForbidden)) + }) + }) + }) + + Context("with RFC-compliant MtlsAllowedSources authorization", func() { + BeforeEach(func() { + request = test_util.NewRequest("GET", "backend.apps.mtls.internal", "/", nil) + }) + + Context("when MtlsAllowedSources.Any is true", func() { + var endpoint *route.Endpoint + var pool *route.EndpointPool + + BeforeEach(func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{ + Any: true, + }, + }) + pool = createPoolWithEndpoint(endpoint) + }) + + Context("when caller is authenticated", func() { + BeforeEach(func() { + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "random-app-guid", + } + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("allows any authenticated app", func() { + Expect(nextCalled).To(BeTrue()) + Expect(recorder.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("when caller is not authenticated", func() { + BeforeEach(func() { + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + // Don't set CallerIdentity + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("returns 401 Unauthorized", func() { + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + }) + + Context("when caller's space is in MtlsAllowedSources.Spaces", func() { + BeforeEach(func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{ + Spaces: []string{"allowed-space-1", "allowed-space-2"}, + }, + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app-guid", + SpaceGUID: "allowed-space-2", + } + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("allows the request", func() { + Expect(nextCalled).To(BeTrue()) + Expect(recorder.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("when caller's space is not in MtlsAllowedSources.Spaces", func() { + BeforeEach(func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{ + Spaces: []string{"allowed-space-1", "allowed-space-2"}, + }, + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app-guid", + SpaceGUID: "different-space", + } + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("returns 403 Forbidden", func() { + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusForbidden)) + }) + }) + + Context("when caller's org is in MtlsAllowedSources.Orgs", func() { + BeforeEach(func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{ + Orgs: []string{"allowed-org-1", "allowed-org-2"}, + }, + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app-guid", + OrgGUID: "allowed-org-1", + } + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("allows the request", func() { + Expect(nextCalled).To(BeTrue()) + Expect(recorder.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("when caller's org is not in MtlsAllowedSources.Orgs", func() { + BeforeEach(func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{ + Orgs: []string{"allowed-org-1", "allowed-org-2"}, + }, + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app-guid", + OrgGUID: "different-org", + } + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("returns 403 Forbidden", func() { + Expect(nextCalled).To(BeFalse()) + Expect(recorder.Code).To(Equal(http.StatusForbidden)) + }) + }) + + Context("with multiple authorization levels", func() { + BeforeEach(func() { + // Endpoint allows specific apps, specific spaces, and specific orgs + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{ + Apps: []string{"app-1", "app-2"}, + Spaces: []string{"space-1"}, + Orgs: []string{"org-1"}, + }, + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + // Caller is not in the app list, but is in the allowed space + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "app-3", + SpaceGUID: "space-1", + OrgGUID: "different-org", + } + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("allows if any level matches", func() { + Expect(nextCalled).To(BeTrue()) + Expect(recorder.Code).To(Equal(http.StatusOK)) + }) + }) + }) + + Context("RouteEndpoint is set for access logging on denial", func() { + // These tests verify that when a request is denied, RouteEndpoint is set + // so that RTR logs are emitted to the target app's log stream + BeforeEach(func() { + request = test_util.NewRequest("GET", "backend.apps.mtls.internal", "/", nil) + }) + + Context("when route pool has no allowed sources (403)", func() { + var capturedReqInfo *handlers.RequestInfo + + BeforeEach(func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + capturedReqInfo = reqInfo + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("sets RouteEndpoint for access logging", func() { + Expect(recorder.Code).To(Equal(http.StatusForbidden)) + Expect(capturedReqInfo.RouteEndpoint).NotTo(BeNil()) + Expect(capturedReqInfo.RouteEndpoint.ApplicationId).To(Equal("backend-app-id")) + }) + }) + + Context("when route pool has empty allowed sources (403)", func() { + var capturedReqInfo *handlers.RequestInfo + + BeforeEach(func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{}, + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + capturedReqInfo = reqInfo + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("sets RouteEndpoint for access logging", func() { + Expect(recorder.Code).To(Equal(http.StatusForbidden)) + Expect(capturedReqInfo.RouteEndpoint).NotTo(BeNil()) + Expect(capturedReqInfo.RouteEndpoint.ApplicationId).To(Equal("backend-app-id")) + }) + }) + + Context("when caller is not authenticated with Any=true (401)", func() { + var capturedReqInfo *handlers.RequestInfo + + BeforeEach(func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{ + Any: true, + }, + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + // Don't set CallerIdentity + capturedReqInfo = reqInfo + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("sets RouteEndpoint for access logging", func() { + Expect(recorder.Code).To(Equal(http.StatusUnauthorized)) + Expect(capturedReqInfo.RouteEndpoint).NotTo(BeNil()) + Expect(capturedReqInfo.RouteEndpoint.ApplicationId).To(Equal("backend-app-id")) + }) + }) + + Context("when caller is not authenticated with specific sources (401)", func() { + var capturedReqInfo *handlers.RequestInfo + + BeforeEach(func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{ + Apps: []string{"allowed-app"}, + }, + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + // Don't set CallerIdentity + capturedReqInfo = reqInfo + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("sets RouteEndpoint for access logging", func() { + Expect(recorder.Code).To(Equal(http.StatusUnauthorized)) + Expect(capturedReqInfo.RouteEndpoint).NotTo(BeNil()) + Expect(capturedReqInfo.RouteEndpoint.ApplicationId).To(Equal("backend-app-id")) + }) + }) + + Context("when caller is not in allowed sources list (403)", func() { + var capturedReqInfo *handlers.RequestInfo + + BeforeEach(func() { + endpoint := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-id", + Host: "192.168.1.1", + Port: 8080, + PrivateInstanceId: "backend-instance-id", + MtlsAllowedSources: &route.MtlsAllowedSources{ + Apps: []string{"allowed-app-1", "allowed-app-2"}, + }, + }) + pool := createPoolWithEndpoint(endpoint) + + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "unauthorized-app", + SpaceGUID: "some-space", + OrgGUID: "some-org", + } + capturedReqInfo = reqInfo + request = r + next(w, r) + }) + n.Use(handler) + n.UseHandlerFunc(nextHandler) + + n.ServeHTTP(recorder, request) + }) + + It("sets RouteEndpoint for access logging", func() { + Expect(recorder.Code).To(Equal(http.StatusForbidden)) + Expect(capturedReqInfo.RouteEndpoint).NotTo(BeNil()) + Expect(capturedReqInfo.RouteEndpoint.ApplicationId).To(Equal("backend-app-id")) + }) + }) + }) +}) diff --git a/src/code.cloudfoundry.org/gorouter/handlers/requestinfo.go b/src/code.cloudfoundry.org/gorouter/handlers/requestinfo.go index 6d2a76819..a025e6887 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/requestinfo.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/requestinfo.go @@ -81,6 +81,10 @@ type RequestInfo struct { TraceInfo TraceInfo BackendReqHeaders http.Header + + // CallerIdentity contains the identity of the calling application extracted + // from the client certificate on mTLS domains. Will be nil for non-mTLS requests. + CallerIdentity *CallerIdentity } func (r *RequestInfo) ProvideTraceInfo() (TraceInfo, error) { diff --git a/src/code.cloudfoundry.org/gorouter/integration/common_integration_test.go b/src/code.cloudfoundry.org/gorouter/integration/common_integration_test.go index 74ec5d238..c92173d10 100644 --- a/src/code.cloudfoundry.org/gorouter/integration/common_integration_test.go +++ b/src/code.cloudfoundry.org/gorouter/integration/common_integration_test.go @@ -245,6 +245,35 @@ func (s *testState) registerWithInternalRouteService(appBackend, routeServiceSer s.registerAndWait(rm) } +func (s *testState) registerWithAllowedSources(backend *httptest.Server, routeURI string, mtlsAllowedSources map[string]interface{}) { + _, backendPort := hostnameAndPort(backend.Listener.Addr().String()) + + // Convert map to MtlsAllowedSources struct + as := &mbus.MtlsAllowedSources{} + if apps, ok := mtlsAllowedSources["apps"].([]string); ok { + as.Apps = apps + } + if spaces, ok := mtlsAllowedSources["spaces"].([]string); ok { + as.Spaces = spaces + } + if orgs, ok := mtlsAllowedSources["orgs"].([]string); ok { + as.Orgs = orgs + } + if any, ok := mtlsAllowedSources["any"].(bool); ok { + as.Any = any + } + + rm := mbus.RegistryMessage{ + Host: "127.0.0.1", + Port: uint16(backendPort), + Uris: []route.Uri{route.Uri(routeURI)}, + StaleThresholdInSeconds: 10, + PrivateInstanceID: fmt.Sprintf("%x", rand.Int31()), + MtlsAllowedSources: as, + } + s.registerAndWait(rm) +} + func (s *testState) registerAndWait(rm mbus.RegistryMessage) { b, _ := json.Marshal(rm) s.mbusClient.Publish("router.register", b) diff --git a/src/code.cloudfoundry.org/gorouter/integration/mtls_app_to_app_test.go b/src/code.cloudfoundry.org/gorouter/integration/mtls_app_to_app_test.go new file mode 100644 index 000000000..f7ac17a17 --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/integration/mtls_app_to_app_test.go @@ -0,0 +1,630 @@ +package integration + +import ( + "crypto/tls" + "fmt" + "io" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "code.cloudfoundry.org/gorouter/config" + "code.cloudfoundry.org/gorouter/test_util" +) + +var _ = Describe("App-to-App mTLS Routing", func() { + var testState *testState + + BeforeEach(func() { + testState = NewTestState() + }) + + AfterEach(func() { + if testState != nil { + testState.StopAndCleanup() + } + }) + + Describe("mTLS domain configuration", func() { + var ( + mtlsDomainCA *test_util.CertChain + appInstanceCert *test_util.CertChain + backendApp *httptest.Server + backendReceivedReqs chan *http.Request + ) + + BeforeEach(func() { + // Create CA for mTLS domain (simulates Diego instance identity CA) + mtlsDomainCA = &test_util.CertChain{} + *mtlsDomainCA = test_util.CreateSignedCertWithRootCA(test_util.CertNames{CommonName: "Diego Instance Identity CA"}) + + // Setup backend app + backendReceivedReqs = make(chan *http.Request, 10) + backendApp = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + backendReceivedReqs <- r + w.WriteHeader(http.StatusOK) + w.Write([]byte("backend-response")) + })) + + // Configure GoRouter with mTLS domain + testState.cfg.EnableSSL = true + testState.cfg.ClientCertificateValidationString = "request" + }) + + AfterEach(func() { + if backendApp != nil { + backendApp.Close() + } + }) + + Context("when a request is made to an mTLS domain", func() { + var mtlsDomain string + + BeforeEach(func() { + mtlsDomain = "my-app.apps.mtls.internal" + + // Configure mTLS domain in GoRouter + testState.cfg.MtlsDomains = []config.MtlsDomainConfig{ + { + Domain: "*.apps.mtls.internal", + CACerts: string(mtlsDomainCA.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + }, + } + + testState.StartGorouterOrFail() + }) + + It("requires a client certificate", func() { + // Register route on mTLS domain + testState.register(backendApp, mtlsDomain) + + // Attempt request without client certificate + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + _, err := testState.client.Do(req) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("tls")) + }) + + It("accepts valid client certificate from the configured CA", func() { + // Create instance identity certificate (need to use the same CA!) + appInstanceCert = &test_util.CertChain{} + // Recreate with SAME CA as configured in GoRouter + *appInstanceCert = test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "app-instance", + AppGUID: "app-guid-123", + SpaceGUID: "space-guid-456", + OrgGUID: "org-guid-789", + }) + + // Register route on mTLS domain with allowed sources + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{"app-guid-123"}, + }, + ) + + // Configure client to use instance identity cert + clientTLSConfig := &tls.Config{ + RootCAs: testState.client.Transport.(*http.Transport).TLSClientConfig.RootCAs, + Certificates: []tls.Certificate{ + appInstanceCert.TLSCert(), + }, + } + testState.client.Transport.(*http.Transport).TLSClientConfig = clientTLSConfig + + // Make request + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + Expect(string(body)).To(Equal("backend-response")) + + // Verify backend received the request + Eventually(backendReceivedReqs).Should(Receive()) + }) + + It("rejects client certificate from unknown CA", func() { + // Create certificate from different CA (not the configured mtlsDomainCA) + unknownCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "app-instance", + AppGUID: "app-guid-123", + }) + + // Register route + testState.register(backendApp, mtlsDomain) + + // Configure client with unknown cert + clientTLSConfig := &tls.Config{ + RootCAs: testState.client.Transport.(*http.Transport).TLSClientConfig.RootCAs, + Certificates: []tls.Certificate{ + unknownCert.TLSCert(), + }, + } + testState.client.Transport.(*http.Transport).TLSClientConfig = clientTLSConfig + + // Make request - should fail TLS handshake + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + _, err := testState.client.Do(req) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("tls")) + }) + }) + + Context("when requests are made to non-mTLS domains", func() { + var regularDomain string + + BeforeEach(func() { + regularDomain = "my-app.apps.internal" + + // Configure only the mTLS domain + testState.cfg.MtlsDomains = []config.MtlsDomainConfig{ + { + Domain: "*.apps.mtls.internal", + CACerts: string(mtlsDomainCA.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + }, + } + + testState.StartGorouterOrFail() + }) + + It("does not require client certificates", func() { + // Register route on regular domain + testState.register(backendApp, regularDomain) + + // Make request without client certificate (using HTTPS) + req := testState.newGetRequest(fmt.Sprintf("https://%s", regularDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, _ := io.ReadAll(resp.Body) + Expect(string(body)).To(Equal("backend-response")) + }) + }) + }) + + Describe("App-to-App authorization", func() { + var ( + mtlsDomainCA *test_util.CertChain + backendApp *httptest.Server + backendReceivedReqs chan *http.Request + mtlsDomain string + ) + + BeforeEach(func() { + mtlsDomain = "secure-api.apps.mtls.internal" + + // Create CA for mTLS domain + mtlsDomainCA = &test_util.CertChain{} + *mtlsDomainCA = test_util.CreateSignedCertWithRootCA(test_util.CertNames{CommonName: "Diego Instance Identity CA"}) + + // Setup backend app + backendReceivedReqs = make(chan *http.Request, 10) + backendApp = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + backendReceivedReqs <- r + w.WriteHeader(http.StatusOK) + w.Write([]byte("authorized")) + })) + + // Configure GoRouter + testState.cfg.EnableSSL = true + testState.cfg.ClientCertificateValidationString = "request" + testState.cfg.MtlsDomains = []config.MtlsDomainConfig{ + { + Domain: "*.apps.mtls.internal", + CACerts: string(mtlsDomainCA.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + }, + } + + testState.StartGorouterOrFail() + }) + + AfterEach(func() { + if backendApp != nil { + backendApp.Close() + } + }) + + Describe("app-level authorization", func() { + It("allows requests from apps in the allowed list", func() { + callerAppGUID := "caller-app-guid-123" + + // Register route with app-level allowed sources + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{callerAppGUID, "other-app-guid"}, + }, + ) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: callerAppGUID, + SpaceGUID: "caller-space-guid", + OrgGUID: "caller-org-guid", + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + Expect(string(body)).To(Equal("authorized")) + }) + + It("denies requests from apps not in the allowed list", func() { + // Register route with app-level allowed sources + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{"allowed-app-guid"}, + }, + ) + + // Create caller certificate with different app GUID + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "unauthorized-app-guid", + SpaceGUID: "caller-space-guid", + OrgGUID: "caller-org-guid", + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("space-level authorization", func() { + It("allows requests from apps in allowed spaces", func() { + callerSpaceGUID := "dev-space-guid" + + // Register route with space-level allowed sources + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "spaces": []string{callerSpaceGUID, "other-space-guid"}, + }, + ) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: callerSpaceGUID, + OrgGUID: "caller-org-guid", + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + Expect(string(body)).To(Equal("authorized")) + }) + + It("denies requests from apps in non-allowed spaces", func() { + // Register route with space-level allowed sources + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "spaces": []string{"allowed-space-guid"}, + }, + ) + + // Create caller certificate with different space GUID + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: "unauthorized-space-guid", + OrgGUID: "caller-org-guid", + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("org-level authorization", func() { + It("allows requests from apps in allowed orgs", func() { + callerOrgGUID := "my-org-guid" + + // Register route with org-level allowed sources + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "orgs": []string{callerOrgGUID}, + }, + ) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: "caller-space-guid", + OrgGUID: callerOrgGUID, + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("denies requests from apps in non-allowed orgs", func() { + // Register route with org-level allowed sources + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "orgs": []string{"allowed-org-guid"}, + }, + ) + + // Create caller certificate with different org GUID + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: "caller-space-guid", + OrgGUID: "unauthorized-org-guid", + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("multi-level authorization", func() { + It("allows requests if ANY authorization level matches", func() { + // Register route with multiple authorization levels + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{"specific-app-guid"}, + "spaces": []string{"dev-space-guid"}, + "orgs": []string{"my-org-guid"}, + }, + ) + + // Create caller that matches space level but not app level + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "different-app-guid", + SpaceGUID: "dev-space-guid", // Matches allowed space + OrgGUID: "different-org-guid", + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request - should succeed because space matches + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("denies requests if NO authorization level matches", func() { + // Register route with multiple authorization levels + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{"allowed-app-guid"}, + "spaces": []string{"allowed-space-guid"}, + "orgs": []string{"allowed-org-guid"}, + }, + ) + + // Create caller that matches none + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "different-app-guid", + SpaceGUID: "different-space-guid", + OrgGUID: "different-org-guid", + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request - should fail + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("'any authenticated app' authorization", func() { + It("allows any authenticated app when any=true", func() { + // Register route with any=true + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "any": true, + }, + ) + + // Create arbitrary caller certificate + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "any-app-instance", + AppGUID: "random-app-guid-999", + SpaceGUID: "random-space-guid", + OrgGUID: "random-org-guid", + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request - should succeed + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + }) + + Describe("default-deny behavior", func() { + It("denies requests when no mtls_allowed_sources are configured", func() { + // Register route WITHOUT allowed sources + testState.register(backendApp, mtlsDomain) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request - should fail (default deny) + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + + It("denies requests when mtls_allowed_sources are empty", func() { + // Register route with empty allowed sources + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{}, + "spaces": []string{}, + "orgs": []string{}, + "any": false, + }, + ) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request - should fail + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("X-Forwarded-Client-Cert header", func() { + It("forwards sanitized client certificate to backend on mTLS domains", func() { + // Register route with allowed sources + testState.registerWithAllowedSources( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{"caller-app-guid"}, + }, + ) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: "caller-space-guid", + OrgGUID: "caller-org-guid", + }) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req := testState.newGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + resp.Body.Close() + + // Check backend received XFCC header + var backendReq *http.Request + Eventually(backendReceivedReqs).Should(Receive(&backendReq)) + Expect(backendReq.Header.Get("X-Forwarded-Client-Cert")).NotTo(BeEmpty()) + }) + }) + }) +}) diff --git a/src/code.cloudfoundry.org/gorouter/mbus/registry_message_test.go b/src/code.cloudfoundry.org/gorouter/mbus/registry_message_test.go index 9162a97e6..c1f91b7f3 100644 --- a/src/code.cloudfoundry.org/gorouter/mbus/registry_message_test.go +++ b/src/code.cloudfoundry.org/gorouter/mbus/registry_message_test.go @@ -60,4 +60,119 @@ var _ = Describe("RegistryMessage", func() { }) }) }) + + Describe("MakeEndpoint with MtlsAllowedSources", func() { + var message *RegistryMessage + var payload []byte + + JustBeforeEach(func() { + message = new(RegistryMessage) + err := json.Unmarshal(payload, message) + Expect(err).NotTo(HaveOccurred()) + }) + + Describe("With mtls_allowed_sources at top level", func() { + BeforeEach(func() { + payload = []byte(`{ + "app":"app1", + "uris":["test.com"], + "host":"1.2.3.4", + "port":1234, + "tags":{}, + "private_instance_id":"private_instance_id", + "mtls_allowed_sources": { + "apps": ["app-guid-1", "app-guid-2"], + "spaces": ["space-guid-1"], + "orgs": ["org-guid-1"], + "any": false + } + }`) + }) + + It("parses mtls_allowed_sources correctly", func() { + endpoint, err := message.MakeEndpoint(false) + Expect(err).NotTo(HaveOccurred()) + Expect(endpoint.MtlsAllowedSources).NotTo(BeNil()) + Expect(endpoint.MtlsAllowedSources.Apps).To(ConsistOf("app-guid-1", "app-guid-2")) + Expect(endpoint.MtlsAllowedSources.Spaces).To(ConsistOf("space-guid-1")) + Expect(endpoint.MtlsAllowedSources.Orgs).To(ConsistOf("org-guid-1")) + Expect(endpoint.MtlsAllowedSources.Any).To(BeFalse()) + }) + }) + + Describe("With flat mTLS options in options (RFC-0027 compliant CAPI/Diego format)", func() { + BeforeEach(func() { + payload = []byte(`{ + "app":"app1", + "uris":["test.com"], + "host":"1.2.3.4", + "port":1234, + "tags":{}, + "private_instance_id":"private_instance_id", + "options": { + "loadbalancing": "round-robin", + "mtls_allowed_apps": "app-guid-1,app-guid-2", + "mtls_allowed_spaces": "space-guid-1", + "mtls_allowed_orgs": "org-guid-1", + "mtls_allow_any": true + } + }`) + }) + + It("parses flat mTLS options correctly", func() { + endpoint, err := message.MakeEndpoint(false) + Expect(err).NotTo(HaveOccurred()) + Expect(endpoint.MtlsAllowedSources).NotTo(BeNil()) + Expect(endpoint.MtlsAllowedSources.Apps).To(ConsistOf("app-guid-1", "app-guid-2")) + Expect(endpoint.MtlsAllowedSources.Spaces).To(ConsistOf("space-guid-1")) + Expect(endpoint.MtlsAllowedSources.Orgs).To(ConsistOf("org-guid-1")) + Expect(endpoint.MtlsAllowedSources.Any).To(BeTrue()) + }) + }) + + Describe("With mtls_allowed_sources at top-level and flat options", func() { + BeforeEach(func() { + payload = []byte(`{ + "app":"app1", + "uris":["test.com"], + "host":"1.2.3.4", + "port":1234, + "tags":{}, + "private_instance_id":"private_instance_id", + "mtls_allowed_sources": { + "apps": ["top-level-app"] + }, + "options": { + "mtls_allowed_apps": "flat-options-app" + } + }`) + }) + + It("uses top-level mtls_allowed_sources (takes precedence)", func() { + endpoint, err := message.MakeEndpoint(false) + Expect(err).NotTo(HaveOccurred()) + Expect(endpoint.MtlsAllowedSources).NotTo(BeNil()) + Expect(endpoint.MtlsAllowedSources.Apps).To(ConsistOf("top-level-app")) + }) + }) + + Describe("With no mtls_allowed_sources", func() { + BeforeEach(func() { + payload = []byte(`{ + "app":"app1", + "uris":["test.com"], + "host":"1.2.3.4", + "port":1234, + "tags":{}, + "private_instance_id":"private_instance_id" + }`) + }) + + It("returns nil for mtls_allowed_sources", func() { + endpoint, err := message.MakeEndpoint(false) + Expect(err).NotTo(HaveOccurred()) + Expect(endpoint.MtlsAllowedSources).To(BeNil()) + }) + }) + }) }) diff --git a/src/code.cloudfoundry.org/gorouter/mbus/subscriber.go b/src/code.cloudfoundry.org/gorouter/mbus/subscriber.go index af17a19b4..1c917219b 100644 --- a/src/code.cloudfoundry.org/gorouter/mbus/subscriber.go +++ b/src/code.cloudfoundry.org/gorouter/mbus/subscriber.go @@ -38,15 +38,90 @@ type RegistryMessage struct { Tags map[string]string `json:"tags"` Uris []route.Uri `json:"uris"` Options RegistryMessageOpts `json:"options"` + MtlsAllowedSources *MtlsAllowedSources `json:"mtls_allowed_sources,omitempty"` } type RegistryMessageOpts struct { LoadBalancingAlgorithm string `json:"loadbalancing"` HashHeaderName string `json:"hash_header"` HashBalance float64 `json:"hash_balance"` + // RFC-0027 compliant flat mTLS options (comma-separated GUIDs) + MtlsAllowedApps string `json:"mtls_allowed_apps,omitempty"` + MtlsAllowedSpaces string `json:"mtls_allowed_spaces,omitempty"` + MtlsAllowedOrgs string `json:"mtls_allowed_orgs,omitempty"` + MtlsAllowAny bool `json:"mtls_allow_any,omitempty"` } -func (rm *RegistryMessage) makeEndpoint(http2Enabled bool) (*route.Endpoint, error) { +// MtlsAllowedSources contains authorization rules for which sources can communicate +// with this endpoint on mTLS domains. Per RFC specification: +// - If Any is true, any authenticated app is allowed (mutually exclusive with Apps/Spaces/Orgs) +// - If Any is false, at least one of Apps/Spaces/Orgs must be specified (default-deny) +type MtlsAllowedSources struct { + Apps []string `json:"apps,omitempty"` + Spaces []string `json:"spaces,omitempty"` + Orgs []string `json:"orgs,omitempty"` + Any bool `json:"any,omitempty"` +} + +// parseCommaSeparatedGUIDs splits a comma-separated string into a slice of GUIDs +func parseCommaSeparatedGUIDs(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + if len(result) == 0 { + return nil + } + return result +} + +// getMtlsAllowedSources returns the MtlsAllowedSources, or nil if not present +func getMtlsAllowedSources(as *MtlsAllowedSources) *route.MtlsAllowedSources { + if as == nil { + return nil + } + return &route.MtlsAllowedSources{ + Apps: as.Apps, + Spaces: as.Spaces, + Orgs: as.Orgs, + Any: as.Any, + } +} + +// getEffectiveMtlsAllowedSources returns MtlsAllowedSources from either top-level or flat options. +// Top-level takes precedence (used by route-registrar), flat options are RFC-0027 compliant (used by CAPI/Diego). +func (rm *RegistryMessage) getEffectiveMtlsAllowedSources() *route.MtlsAllowedSources { + // Top-level mtls_allowed_sources takes precedence (route-registrar uses this) + if rm.MtlsAllowedSources != nil { + return getMtlsAllowedSources(rm.MtlsAllowedSources) + } + // Fall back to RFC-0027 compliant flat options + apps := parseCommaSeparatedGUIDs(rm.Options.MtlsAllowedApps) + spaces := parseCommaSeparatedGUIDs(rm.Options.MtlsAllowedSpaces) + orgs := parseCommaSeparatedGUIDs(rm.Options.MtlsAllowedOrgs) + allowAny := rm.Options.MtlsAllowAny + + // If no mTLS options are set, return nil + if apps == nil && spaces == nil && orgs == nil && !allowAny { + return nil + } + + return &route.MtlsAllowedSources{ + Apps: apps, + Spaces: spaces, + Orgs: orgs, + Any: allowAny, + } +} + +func (rm *RegistryMessage) MakeEndpoint(http2Enabled bool) (*route.Endpoint, error) { port, useTLS, err := rm.port() if err != nil { return nil, err @@ -80,6 +155,7 @@ func (rm *RegistryMessage) makeEndpoint(http2Enabled bool) (*route.Endpoint, err LoadBalancingAlgorithm: rm.Options.LoadBalancingAlgorithm, HashHeaderName: rm.Options.HashHeaderName, HashBalanceFactor: rm.Options.HashBalance, + MtlsAllowedSources: rm.getEffectiveMtlsAllowedSources(), }), nil } @@ -243,7 +319,7 @@ func (s *Subscriber) subscribeRoutes() (*nats.Subscription, error) { } func (s *Subscriber) registerEndpoint(msg *RegistryMessage) { - endpoint, err := msg.makeEndpoint(s.http2Enabled) + endpoint, err := msg.MakeEndpoint(s.http2Enabled) if err != nil { s.logger.Error("Unable to register route", log.ErrAttr(err), @@ -258,7 +334,7 @@ func (s *Subscriber) registerEndpoint(msg *RegistryMessage) { } func (s *Subscriber) unregisterEndpoint(msg *RegistryMessage) { - endpoint, err := msg.makeEndpoint(s.http2Enabled) + endpoint, err := msg.MakeEndpoint(s.http2Enabled) if err != nil { s.logger.Error("Unable to unregister route", log.ErrAttr(err), diff --git a/src/code.cloudfoundry.org/gorouter/proxy/proxy.go b/src/code.cloudfoundry.org/gorouter/proxy/proxy.go index 6cea064b1..4fa942392 100644 --- a/src/code.cloudfoundry.org/gorouter/proxy/proxy.go +++ b/src/code.cloudfoundry.org/gorouter/proxy/proxy.go @@ -163,9 +163,12 @@ func NewProxy( SkipSanitize(routeServiceHandler.(*handlers.RouteService)), ForceDeleteXFCCHeader(routeServiceHandler.(*handlers.RouteService), cfg.ForwardedClientCert, logger), cfg.ForwardedClientCert, + cfg, logger, errorWriter, )) + n.Use(handlers.NewIdentity()) + n.Use(handlers.NewMtlsAuthorization(cfg, logger)) n.Use(handlers.NewHopByHop(cfg, logger)) n.Use(&handlers.XForwardedProto{ SkipSanitization: SkipSanitizeXFP(routeServiceHandler.(*handlers.RouteService)), diff --git a/src/code.cloudfoundry.org/gorouter/route/pool.go b/src/code.cloudfoundry.org/gorouter/route/pool.go index f089fc15b..ef2dcb207 100644 --- a/src/code.cloudfoundry.org/gorouter/route/pool.go +++ b/src/code.cloudfoundry.org/gorouter/route/pool.go @@ -63,6 +63,31 @@ type Stats struct { NumberConnections *Counter } +// MtlsAllowedSources contains authorization rules for which sources can communicate +// with this endpoint on mTLS domains. Per RFC specification: +// - If Any is true, any authenticated app is allowed (mutually exclusive with Apps/Spaces/Orgs) +// - If Any is false, at least one of Apps/Spaces/Orgs must be specified (default-deny) +type MtlsAllowedSources struct { + Apps []string + Spaces []string + Orgs []string + Any bool +} + +// Equal compares two MtlsAllowedSources for equality +func (as *MtlsAllowedSources) Equal(other *MtlsAllowedSources) bool { + if as == nil && other == nil { + return true + } + if as == nil || other == nil { + return false + } + return slices.Equal(as.Apps, other.Apps) && + slices.Equal(as.Spaces, other.Spaces) && + slices.Equal(as.Orgs, other.Orgs) && + as.Any == other.Any +} + func NewStats() *Stats { return &Stats{ NumberConnections: &Counter{}, @@ -96,6 +121,7 @@ type Endpoint struct { LoadBalancingAlgorithm string HashHeaderName string HashBalanceFactor float64 + MtlsAllowedSources *MtlsAllowedSources } func (e *Endpoint) RoundTripper() ProxyRoundTripper { @@ -141,7 +167,8 @@ func (e *Endpoint) Equal(e2 *Endpoint) bool { e.LoadBalancingAlgorithm == e2.LoadBalancingAlgorithm && e.HashHeaderName == e2.HashHeaderName && e.HashBalanceFactor == e2.HashBalanceFactor && - maps.Equal(e.Tags, e2.Tags) + maps.Equal(e.Tags, e2.Tags) && + e.MtlsAllowedSources.Equal(e2.MtlsAllowedSources) } @@ -207,6 +234,7 @@ type EndpointOpts struct { LoadBalancingAlgorithm string HashHeaderName string HashBalanceFactor float64 + MtlsAllowedSources *MtlsAllowedSources } func NewEndpoint(opts *EndpointOpts) *Endpoint { @@ -227,6 +255,7 @@ func NewEndpoint(opts *EndpointOpts) *Endpoint { IsolationSegment: opts.IsolationSegment, UpdatedAt: opts.UpdatedAt, LoadBalancingAlgorithm: opts.LoadBalancingAlgorithm, + MtlsAllowedSources: opts.MtlsAllowedSources, } if opts.LoadBalancingAlgorithm == config.LOAD_BALANCE_HB && opts.HashHeaderName != "" { // BalanceFactor is optional @@ -472,6 +501,33 @@ func (p *EndpointPool) IsEmpty() bool { return l == 0 } +// MtlsAllowedSources returns the MtlsAllowedSources from the first endpoint in the pool. +// All endpoints in a pool should have the same MtlsAllowedSources since they are +// instances of the same application route registered with the same authorization rules. +func (p *EndpointPool) MtlsAllowedSources() *MtlsAllowedSources { + p.Lock() + defer p.Unlock() + + if len(p.endpoints) == 0 { + return nil + } + + return p.endpoints[0].endpoint.MtlsAllowedSources +} + +// ApplicationId returns the ApplicationId from the first endpoint in the pool. +// All endpoints in a pool should have the same ApplicationId. +func (p *EndpointPool) ApplicationId() string { + p.Lock() + defer p.Unlock() + + if len(p.endpoints) == 0 { + return "" + } + + return p.endpoints[0].endpoint.ApplicationId +} + func (p *EndpointPool) NextIndex() int { if p.NextIdx == -1 { p.NextIdx = p.random.Intn(len(p.endpoints)) diff --git a/src/code.cloudfoundry.org/gorouter/router/router.go b/src/code.cloudfoundry.org/gorouter/router/router.go index 42b79b65e..ed9d1954f 100644 --- a/src/code.cloudfoundry.org/gorouter/router/router.go +++ b/src/code.cloudfoundry.org/gorouter/router/router.go @@ -291,7 +291,8 @@ func (r *Router) serveHTTPS(server *http.Server, errChan chan error) error { return nil } - tlsConfig := &tls.Config{ + // Base TLS config for non-mTLS domains + baseTlsConfig := &tls.Config{ Certificates: r.config.SSLCertificates, CipherSuites: r.config.CipherSuites, MinVersion: r.config.MinTLSVersion, @@ -301,18 +302,25 @@ func (r *Router) serveHTTPS(server *http.Server, errChan chan error) error { } if r.config.VerifyClientCertificatesBasedOnProvidedMetadata && r.config.VerifyClientCertificateMetadataRules != nil { - tlsConfig.VerifyPeerCertificate = r.verifyMtlsMetadata + baseTlsConfig.VerifyPeerCertificate = r.verifyMtlsMetadata } if r.config.EnableHTTP2 { - tlsConfig.NextProtos = []string{"h2", "http/1.1"} + baseTlsConfig.NextProtos = []string{"h2", "http/1.1"} } // Although this functionality is deprecated there is no intention to remove it from the stdlib // due to the Go 1 compatibility promise. We rely on it to prefer more specific matches (a full // SNI match over wildcard matches) instead of relying on the order of certificates. //lint:ignore SA1019 - see ^^ - tlsConfig.BuildNameToCertificate() + baseTlsConfig.BuildNameToCertificate() + + // Wrap with GetConfigForClient for per-domain mTLS + tlsConfig := &tls.Config{ + GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + return r.getTLSConfigForClient(hello, baseTlsConfig) + }, + } listener, err := net.Listen("tcp", fmt.Sprintf(":%d", r.config.SSLPort)) if err != nil { @@ -353,6 +361,30 @@ func (r *Router) verifyMtlsMetadata(_ [][]byte, chains [][]*x509.Certificate) er return nil } +// getTLSConfigForClient returns appropriate TLS config based on SNI (Server Name Indication) +// For mTLS domains, it requires and verifies client certificates using domain-specific CA pool +// For regular domains, it uses the base TLS configuration +func (r *Router) getTLSConfigForClient(hello *tls.ClientHelloInfo, baseConfig *tls.Config) (*tls.Config, error) { + serverName := hello.ServerName + + mtlsDomainConfig := r.config.GetMtlsDomainConfig(serverName) + if mtlsDomainConfig == nil { + // Not an mTLS domain, use base config + return baseConfig, nil + } + + // mTLS domain - require client certificate + mtlsConfig := baseConfig.Clone() + mtlsConfig.ClientAuth = tls.RequireAndVerifyClientCert + mtlsConfig.ClientCAs = mtlsDomainConfig.CAPool + + r.logger.Debug("mtls-domain-detected", + slog.String("server_name", serverName), + slog.String("domain", mtlsDomainConfig.Domain)) + + return mtlsConfig, nil +} + func (r *Router) serveHTTP(server *http.Server, errChan chan error) error { if r.config.DisableHTTP { r.logger.Info("tcp-listener-disabled") diff --git a/src/code.cloudfoundry.org/gorouter/test_util/helpers.go b/src/code.cloudfoundry.org/gorouter/test_util/helpers.go index c98e341b8..360aa1406 100644 --- a/src/code.cloudfoundry.org/gorouter/test_util/helpers.go +++ b/src/code.cloudfoundry.org/gorouter/test_util/helpers.go @@ -738,3 +738,73 @@ func CreateInvalidCertAndRule(cn string, invalidSubjects []string) ([]*x509.Cert // Return leaf + CA in chain return []*x509.Certificate{x509Leaf, x509CA}, rule, nil } + +// InstanceIdentityCertNames contains identity information for instance identity certificates +type InstanceIdentityCertNames struct { + CommonName string + AppGUID string // Required - will be added as OU "app:" + SpaceGUID string // Optional - will be added as OU "space:" + OrgGUID string // Optional - will be added as OU "organization:" + SANs SubjectAltNames +} + +// CreateInstanceIdentityCert creates a certificate chain with instance identity +// information embedded in OrganizationalUnit fields, matching Diego's format +func CreateInstanceIdentityCert(certNames InstanceIdentityCertNames) CertChain { + rootPrivateKey, rootCADER := CreateCertDER("Diego Instance Identity CA") + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + Expect(err).ToNot(HaveOccurred()) + + // Build OrganizationalUnit slice with instance identity info + organizationalUnits := []string{fmt.Sprintf("app:%s", certNames.AppGUID)} + if certNames.SpaceGUID != "" { + organizationalUnits = append(organizationalUnits, fmt.Sprintf("space:%s", certNames.SpaceGUID)) + } + if certNames.OrgGUID != "" { + organizationalUnits = append(organizationalUnits, fmt.Sprintf("organization:%s", certNames.OrgGUID)) + } + + subject := pkix.Name{ + Organization: []string{"Cloud Foundry"}, + OrganizationalUnit: organizationalUnits, + CommonName: certNames.CommonName, + } + + certTemplate := x509.Certificate{ + SerialNumber: serialNumber, + Subject: subject, + SignatureAlgorithm: x509.SHA256WithRSA, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + BasicConstraintsValid: true, + } + + if certNames.SANs.IP != "" { + certTemplate.IPAddresses = []net.IP{net.ParseIP(certNames.SANs.IP)} + } + if certNames.SANs.DNS != "" { + certTemplate.DNSNames = []string{certNames.SANs.DNS} + } + + rootCert, err := x509.ParseCertificate(rootCADER) + Expect(err).NotTo(HaveOccurred()) + + ownKey, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + + certDER, err := x509.CreateCertificate(rand.Reader, &certTemplate, rootCert, &ownKey.PublicKey, rootPrivateKey) + Expect(err).NotTo(HaveOccurred()) + + ownKeyPEM, ownCertPEM := CreateKeyPairFromDER(certDER, ownKey) + rootKeyPEM, rootCertPEM := CreateKeyPairFromDER(rootCADER, rootPrivateKey) + + return CertChain{ + CertPEM: ownCertPEM, + PrivKeyPEM: ownKeyPEM, + CACertPEM: rootCertPEM, + CAPrivKeyPEM: rootKeyPEM, + CACert: rootCert, + CAPrivKey: rootPrivateKey, + } +} diff --git a/src/code.cloudfoundry.org/route-registrar/config/config.go b/src/code.cloudfoundry.org/route-registrar/config/config.go index e4e571209..1545a5dd7 100644 --- a/src/code.cloudfoundry.org/route-registrar/config/config.go +++ b/src/code.cloudfoundry.org/route-registrar/config/config.go @@ -51,27 +51,39 @@ type ConfigSchema struct { } type RouteSchema struct { - Type string `json:"type" yaml:"type"` - Name string `json:"name" yaml:"name"` - Host string `json:"host" yaml:"host"` - Port *uint16 `json:"port" yaml:"port"` - Protocol string `json:"protocol" yaml:"protocol"` - SniPort *uint16 `json:"sni_port" yaml:"sni_port"` - TLSPort *uint16 `json:"tls_port" yaml:"tls_port"` - Tags map[string]string `json:"tags" yaml:"tags"` - URIs []string `json:"uris" yaml:"uris"` - RouterGroup string `json:"router_group" yaml:"router_group"` - ExternalPort *uint16 `json:"external_port,omitempty" yaml:"external_port,omitempty"` - RouteServiceUrl string `json:"route_service_url" yaml:"route_service_url"` - RegistrationInterval string `json:"registration_interval,omitempty" yaml:"registration_interval,omitempty"` - HealthCheck *HealthCheckSchema `json:"health_check,omitempty" yaml:"health_check,omitempty"` - ServerCertDomainSAN string `json:"server_cert_domain_san,omitempty" yaml:"server_cert_domain_san,omitempty"` - SniRoutableSan string `json:"sni_routable_san,omitempty" yaml:"sni_routable_san,omitempty"` - SniRewriteSan string `json:"sni_rewrite_san,omitempty" yaml:"sni_rewrite_san,omitempty"` - TerminateFrontendTLS bool `json:"terminate_frontend_tls,omitempty" yaml:"terminate_frontend_tls,omitempty"` - EnableBackendTLS bool `json:"enable_backend_tls,omitempty" yaml:"enable_backend_tls,omitempty"` - ALPNs []string `json:"alpns,omitempty" yaml:"alpns,omitempty"` - Options *Options `json:"options,omitempty" yaml:"options,omitempty"` + Type string `json:"type" yaml:"type"` + Name string `json:"name" yaml:"name"` + Host string `json:"host" yaml:"host"` + Port *uint16 `json:"port" yaml:"port"` + Protocol string `json:"protocol" yaml:"protocol"` + SniPort *uint16 `json:"sni_port" yaml:"sni_port"` + TLSPort *uint16 `json:"tls_port" yaml:"tls_port"` + Tags map[string]string `json:"tags" yaml:"tags"` + URIs []string `json:"uris" yaml:"uris"` + RouterGroup string `json:"router_group" yaml:"router_group"` + ExternalPort *uint16 `json:"external_port,omitempty" yaml:"external_port,omitempty"` + RouteServiceUrl string `json:"route_service_url" yaml:"route_service_url"` + RegistrationInterval string `json:"registration_interval,omitempty" yaml:"registration_interval,omitempty"` + HealthCheck *HealthCheckSchema `json:"health_check,omitempty" yaml:"health_check,omitempty"` + ServerCertDomainSAN string `json:"server_cert_domain_san,omitempty" yaml:"server_cert_domain_san,omitempty"` + SniRoutableSan string `json:"sni_routable_san,omitempty" yaml:"sni_routable_san,omitempty"` + SniRewriteSan string `json:"sni_rewrite_san,omitempty" yaml:"sni_rewrite_san,omitempty"` + TerminateFrontendTLS bool `json:"terminate_frontend_tls,omitempty" yaml:"terminate_frontend_tls,omitempty"` + EnableBackendTLS bool `json:"enable_backend_tls,omitempty" yaml:"enable_backend_tls,omitempty"` + ALPNs []string `json:"alpns,omitempty" yaml:"alpns,omitempty"` + Options *Options `json:"options,omitempty" yaml:"options,omitempty"` + MtlsAllowedSources *MtlsAllowedSources `json:"mtls_allowed_sources,omitempty" yaml:"mtls_allowed_sources,omitempty"` +} + +// MtlsAllowedSources contains authorization rules for which sources can communicate +// with this endpoint on mTLS domains. Per RFC specification: +// - If Any is true, any authenticated app is allowed (mutually exclusive with Apps/Spaces/Orgs) +// - If Any is false, at least one of Apps/Spaces/Orgs must be specified (default-deny) +type MtlsAllowedSources struct { + Apps []string `json:"apps,omitempty" yaml:"apps,omitempty"` + Spaces []string `json:"spaces,omitempty" yaml:"spaces,omitempty"` + Orgs []string `json:"orgs,omitempty" yaml:"orgs,omitempty"` + Any bool `json:"any,omitempty" yaml:"any,omitempty"` } type Options struct { @@ -159,6 +171,7 @@ type Route struct { ALPNs []string EnableBackendTLS bool Options *Options + MtlsAllowedSources *MtlsAllowedSources } func NewConfigSchemaFromFile(configFile string) (ConfigSchema, error) { @@ -366,6 +379,7 @@ func RouteFromSchema(r RouteSchema, index int, host string) (*Route, error) { ALPNs: r.ALPNs, EnableBackendTLS: r.EnableBackendTLS, Options: r.Options, + MtlsAllowedSources: r.MtlsAllowedSources, } if r.Type == "sni" { diff --git a/src/code.cloudfoundry.org/route-registrar/messagebus/messagebus.go b/src/code.cloudfoundry.org/route-registrar/messagebus/messagebus.go index a5802de05..144d7d670 100644 --- a/src/code.cloudfoundry.org/route-registrar/messagebus/messagebus.go +++ b/src/code.cloudfoundry.org/route-registrar/messagebus/messagebus.go @@ -30,17 +30,29 @@ type msgBus struct { } type Message struct { - URIs []string `json:"uris"` - Host string `json:"host"` - Protocol string `json:"protocol,omitempty"` - Port *uint16 `json:"port,omitempty"` - TLSPort *uint16 `json:"tls_port,omitempty"` - Tags map[string]string `json:"tags"` - RouteServiceUrl string `json:"route_service_url,omitempty"` - PrivateInstanceId string `json:"private_instance_id"` - ServerCertDomainSAN string `json:"server_cert_domain_san,omitempty"` - AvailabilityZone string `json:"availability_zone,omitempty"` - Options map[string]string `json:"options,omitempty"` + URIs []string `json:"uris"` + Host string `json:"host"` + Protocol string `json:"protocol,omitempty"` + Port *uint16 `json:"port,omitempty"` + TLSPort *uint16 `json:"tls_port,omitempty"` + Tags map[string]string `json:"tags"` + RouteServiceUrl string `json:"route_service_url,omitempty"` + PrivateInstanceId string `json:"private_instance_id"` + ServerCertDomainSAN string `json:"server_cert_domain_san,omitempty"` + AvailabilityZone string `json:"availability_zone,omitempty"` + Options map[string]string `json:"options,omitempty"` + MtlsAllowedSources *MtlsAllowedSources `json:"mtls_allowed_sources,omitempty"` +} + +// MtlsAllowedSources contains authorization rules for which sources can communicate +// with this endpoint on mTLS domains. Per RFC specification: +// - If Any is true, any authenticated app is allowed (mutually exclusive with Apps/Spaces/Orgs) +// - If Any is false, at least one of Apps/Spaces/Orgs must be specified (default-deny) +type MtlsAllowedSources struct { + Apps []string `json:"apps,omitempty"` + Spaces []string `json:"spaces,omitempty"` + Orgs []string `json:"orgs,omitempty"` + Any bool `json:"any,omitempty"` } const LoadBalancingAlgorithm string = "loadbalancing" @@ -109,6 +121,7 @@ func (m msgBus) SendMessage(subject string, route config.Route, privateInstanceI m.logger.Debug("creating-message", lager.Data{"subject": subject, "route": route, "privateInstanceId": privateInstanceId}) routeOptions := m.mapRouteOptions(route) + mtlsAllowedSources := m.mapMtlsAllowedSources(route) msg := &Message{ URIs: route.URIs, @@ -122,6 +135,7 @@ func (m msgBus) SendMessage(subject string, route config.Route, privateInstanceI PrivateInstanceId: privateInstanceId, AvailabilityZone: m.availabilityZone, Options: routeOptions, + MtlsAllowedSources: mtlsAllowedSources, } json, err := json.Marshal(msg) @@ -146,6 +160,18 @@ func (m msgBus) mapRouteOptions(route config.Route) map[string]string { return nil } +func (m msgBus) mapMtlsAllowedSources(route config.Route) *MtlsAllowedSources { + if route.MtlsAllowedSources != nil { + return &MtlsAllowedSources{ + Apps: route.MtlsAllowedSources.Apps, + Spaces: route.MtlsAllowedSources.Spaces, + Orgs: route.MtlsAllowedSources.Orgs, + Any: route.MtlsAllowedSources.Any, + } + } + return nil +} + func (m msgBus) Close() { m.natsConn.Close() }