diff --git a/authorize/evaluator/evaluator.go b/authorize/evaluator/evaluator.go index 454026f2b..54a544ff8 100644 --- a/authorize/evaluator/evaluator.go +++ b/authorize/evaluator/evaluator.go @@ -32,13 +32,13 @@ type Request struct { // RequestHTTP is the HTTP field in the request. type RequestHTTP struct { - Method string `json:"method"` - Hostname string `json:"hostname"` - Path string `json:"path"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - ClientCertificate string `json:"client_certificate"` - IP string `json:"ip"` + Method string `json:"method"` + Hostname string `json:"hostname"` + Path string `json:"path"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + ClientCertificate ClientCertificateInfo `json:"client_certificate"` + IP string `json:"ip"` } // NewRequestHTTP creates a new RequestHTTP. @@ -46,7 +46,7 @@ func NewRequestHTTP( method string, requestURL url.URL, headers map[string]string, - rawClientCertificate string, + clientCertificate ClientCertificateInfo, ip string, ) RequestHTTP { return RequestHTTP{ @@ -55,11 +55,33 @@ func NewRequestHTTP( Path: requestURL.Path, URL: requestURL.String(), Headers: headers, - ClientCertificate: rawClientCertificate, + ClientCertificate: clientCertificate, IP: ip, } } +// ClientCertificateInfo contains information about the certificate presented +// by the client (if any). +type ClientCertificateInfo struct { + // Presented is true if the client presented any certificate at all. + Presented bool `json:"presented"` + + // Validated is true if the client presented a valid certificate with a + // trust chain rooted at any of the CAs configured within the Envoy + // listener. If any routes define a tls_downstream_client_ca, additional + // validation is required (for all routes). + Validated bool `json:"validated"` + + // Leaf contains the leaf client certificate, provided that the certificate + // validated successfully. + Leaf string `json:"leaf,omitempty"` + + // Intermediates contains the remainder of the client certificate chain as + // it was originally presented by the client, provided that the client + // certificate validated successfully. + Intermediates string `json:"intermediates,omitempty"` +} + // RequestSession is the session field in the request. type RequestSession struct { ID string `json:"id"` @@ -193,7 +215,8 @@ func (e *Evaluator) evaluatePolicy(ctx context.Context, req *Request) (*PolicyRe return nil, err } - isValidClientCertificate, err := isValidClientCertificate(clientCA, req.HTTP.ClientCertificate) + isValidClientCertificate, err := + isValidClientCertificate(clientCA, req.HTTP.ClientCertificate) if err != nil { return nil, fmt.Errorf("authorize: error validating client certificate: %w", err) } diff --git a/authorize/evaluator/evaluator_test.go b/authorize/evaluator/evaluator_test.go index 9fe1ca7dd..4a0b6763e 100644 --- a/authorize/evaluator/evaluator_test.go +++ b/authorize/evaluator/evaluator_test.go @@ -116,6 +116,12 @@ func TestEvaluator(t *testing.T) { WithPolicies(policies), } + validCertInfo := ClientCertificateInfo{ + Presented: true, + Validated: true, + Leaf: testValidCert, + } + t.Run("client certificate", func(t *testing.T) { t.Run("invalid", func(t *testing.T) { res, err := eval(t, options, nil, &Request{ @@ -128,7 +134,7 @@ func TestEvaluator(t *testing.T) { res, err := eval(t, options, nil, &Request{ Policy: &policies[0], HTTP: RequestHTTP{ - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, }, }) require.NoError(t, err) @@ -154,7 +160,7 @@ func TestEvaluator(t *testing.T) { HTTP: RequestHTTP{ Method: http.MethodGet, URL: "https://from.example.com", - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, }, }) require.NoError(t, err) @@ -179,7 +185,7 @@ func TestEvaluator(t *testing.T) { HTTP: RequestHTTP{ Method: http.MethodGet, URL: "https://from.example.com", - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, }, }) require.NoError(t, err) @@ -206,7 +212,7 @@ func TestEvaluator(t *testing.T) { HTTP: RequestHTTP{ Method: http.MethodGet, URL: "https://from.example.com", - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, }, }) require.NoError(t, err) @@ -230,7 +236,7 @@ func TestEvaluator(t *testing.T) { HTTP: RequestHTTP{ Method: http.MethodGet, URL: "https://from.example.com", - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, }, }) require.NoError(t, err) @@ -254,7 +260,7 @@ func TestEvaluator(t *testing.T) { HTTP: RequestHTTP{ Method: http.MethodGet, URL: "https://from.example.com", - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, }, }) require.NoError(t, err) @@ -285,7 +291,7 @@ func TestEvaluator(t *testing.T) { HTTP: RequestHTTP{ Method: http.MethodGet, URL: "https://from.example.com", - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, }, }) require.NoError(t, err) @@ -310,7 +316,7 @@ func TestEvaluator(t *testing.T) { HTTP: RequestHTTP{ Method: http.MethodGet, URL: "https://from.example.com", - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, }, }) require.NoError(t, err) @@ -334,7 +340,7 @@ func TestEvaluator(t *testing.T) { HTTP: RequestHTTP{ Method: http.MethodGet, URL: "https://from.example.com", - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, }, }) require.NoError(t, err) @@ -363,7 +369,7 @@ func TestEvaluator(t *testing.T) { HTTP: RequestHTTP{ Method: http.MethodGet, URL: "https://from.example.com", - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, }, }) require.NoError(t, err) @@ -386,7 +392,7 @@ func TestEvaluator(t *testing.T) { HTTP: RequestHTTP{ Method: http.MethodGet, URL: "https://from.example.com", - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, }, }) require.NoError(t, err) @@ -423,7 +429,7 @@ func TestEvaluator(t *testing.T) { HTTP: RequestHTTP{ Method: http.MethodGet, URL: "https://from.example.com", - ClientCertificate: testValidCert, + ClientCertificate: validCertInfo, Headers: tc.src, }, }) @@ -439,7 +445,7 @@ func TestEvaluator(t *testing.T) { http.MethodGet, *mustParseURL("https://from.example.com/"), nil, - testValidCert, + validCertInfo, "", ), }) @@ -453,7 +459,7 @@ func TestEvaluator(t *testing.T) { "POST", *mustParseURL("https://from.example.com/test"), nil, - testValidCert, + validCertInfo, "", ), }) diff --git a/authorize/evaluator/functions.go b/authorize/evaluator/functions.go index 76c3c4198..d76c43e16 100644 --- a/authorize/evaluator/functions.go +++ b/authorize/evaluator/functions.go @@ -13,14 +13,15 @@ import ( var isValidClientCertificateCache, _ = lru.New2Q[[2]string, bool](100) -func isValidClientCertificate(ca, cert string) (bool, error) { - // when ca is the empty string, client certificates are always accepted +func isValidClientCertificate(ca string, certInfo ClientCertificateInfo) (bool, error) { + // when ca is the empty string, client certificates are not required if ca == "" { return true, nil } - // when cert is the empty string, no client certificate was supplied - if cert == "" { + cert := certInfo.Leaf + + if !certInfo.Validated || cert == "" { return false, nil } diff --git a/authorize/evaluator/functions_test.go b/authorize/evaluator/functions_test.go index f93550315..04e877c1b 100644 --- a/authorize/evaluator/functions_test.go +++ b/authorize/evaluator/functions_test.go @@ -95,27 +95,48 @@ Y+E5W+FKfIBv9yvdNBYZsL6IZ0Yh1ctKwB5gnajO8+swx5BeaCIbBrCtOBSB func Test_isValidClientCertificate(t *testing.T) { t.Run("no ca", func(t *testing.T) { - valid, err := isValidClientCertificate("", "WHATEVER!") + valid, err := isValidClientCertificate("", ClientCertificateInfo{Leaf: "WHATEVER!"}) assert.NoError(t, err, "should not return an error") assert.True(t, valid, "should return true") }) t.Run("no cert", func(t *testing.T) { - valid, err := isValidClientCertificate(testCA, "") + valid, err := isValidClientCertificate(testCA, ClientCertificateInfo{}) assert.NoError(t, err, "should not return an error") assert.False(t, valid, "should return false") }) t.Run("valid cert", func(t *testing.T) { - valid, err := isValidClientCertificate(testCA, testValidCert) + valid, err := isValidClientCertificate(testCA, ClientCertificateInfo{ + Presented: true, + Validated: true, + Leaf: testValidCert, + }) assert.NoError(t, err, "should not return an error") assert.True(t, valid, "should return true") }) + t.Run("cert not externally validated", func(t *testing.T) { + valid, err := isValidClientCertificate(testCA, ClientCertificateInfo{ + Presented: true, + Validated: false, + Leaf: testValidCert, + }) + assert.NoError(t, err, "should not return an error") + assert.False(t, valid, "should return false") + }) t.Run("unsigned cert", func(t *testing.T) { - valid, err := isValidClientCertificate(testCA, testUnsignedCert) + valid, err := isValidClientCertificate(testCA, ClientCertificateInfo{ + Presented: true, + Validated: true, + Leaf: testUnsignedCert, + }) assert.NoError(t, err, "should not return an error") assert.False(t, valid, "should return false") }) t.Run("not a cert", func(t *testing.T) { - valid, err := isValidClientCertificate(testCA, "WHATEVER!") + valid, err := isValidClientCertificate(testCA, ClientCertificateInfo{ + Presented: true, + Validated: true, + Leaf: "WHATEVER!", + }) assert.Error(t, err, "should return an error") assert.False(t, valid, "should return false") }) diff --git a/authorize/grpc.go b/authorize/grpc.go index d18cc3654..99b91d9a6 100644 --- a/authorize/grpc.go +++ b/authorize/grpc.go @@ -2,12 +2,14 @@ package authorize import ( "context" + "encoding/pem" "io" "net/http" "net/url" "strings" envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + "google.golang.org/protobuf/types/known/structpb" "github.com/pomerium/pomerium/authorize/evaluator" "github.com/pomerium/pomerium/config" @@ -60,7 +62,7 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v3.CheckRe u, _ = a.getDataBrokerUser(ctx, s.GetUserId()) // ignore any missing user error } - req, err := a.getEvaluatorRequestFromCheckRequest(in, sessionState) + req, err := a.getEvaluatorRequestFromCheckRequest(ctx, in, sessionState) if err != nil { log.Warn(ctx).Err(err).Msg("error building evaluator request") return nil, err @@ -89,18 +91,22 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v3.CheckRe } func (a *Authorize) getEvaluatorRequestFromCheckRequest( + ctx context.Context, in *envoy_service_auth_v3.CheckRequest, sessionState *sessions.State, ) (*evaluator.Request, error) { requestURL := getCheckRequestURL(in) + attrs := in.GetAttributes() + clientCertMetadata := + attrs.GetMetadataContext().GetFilterMetadata()["com.pomerium.client-certificate-info"] req := &evaluator.Request{ - IsInternal: envoyconfig.ExtAuthzContextExtensionsIsInternal(in.GetAttributes().GetContextExtensions()), + IsInternal: envoyconfig.ExtAuthzContextExtensionsIsInternal(attrs.GetContextExtensions()), HTTP: evaluator.NewRequestHTTP( - in.GetAttributes().GetRequest().GetHttp().GetMethod(), + attrs.GetRequest().GetHttp().GetMethod(), requestURL, getCheckRequestHeaders(in), - getPeerCertificate(in), - in.GetAttributes().GetSource().GetAddress().GetSocketAddress().GetAddress(), + getClientCertificateInfo(ctx, clientCertMetadata), + attrs.GetSource().GetAddress().GetSocketAddress().GetAddress(), ), } if sessionState != nil { @@ -108,7 +114,7 @@ func (a *Authorize) getEvaluatorRequestFromCheckRequest( ID: sessionState.ID, } } - req.Policy = a.getMatchingPolicy(envoyconfig.ExtAuthzContextExtensionsRouteID(in.Attributes.GetContextExtensions())) + req.Policy = a.getMatchingPolicy(envoyconfig.ExtAuthzContextExtensionsRouteID(attrs.GetContextExtensions())) return req, nil } @@ -170,9 +176,38 @@ func getCheckRequestURL(req *envoy_service_auth_v3.CheckRequest) url.URL { return u } -// getPeerCertificate gets the PEM-encoded peer certificate from the check request -func getPeerCertificate(in *envoy_service_auth_v3.CheckRequest) string { - // ignore the error as we will just return the empty string in that case - cert, _ := url.QueryUnescape(in.GetAttributes().GetSource().GetCertificate()) - return cert +// getClientCertificateInfo translates from the client certificate Envoy +// metadata to the ClientCertificateInfo type. +func getClientCertificateInfo( + ctx context.Context, metadata *structpb.Struct, +) evaluator.ClientCertificateInfo { + var c evaluator.ClientCertificateInfo + if metadata == nil { + return c + } + c.Presented = metadata.Fields["presented"].GetBoolValue() + c.Validated = metadata.Fields["validated"].GetBoolValue() + escapedChain := metadata.Fields["chain"].GetStringValue() + if escapedChain == "" { + // No validated client certificate. + return c + } + + chain, err := url.QueryUnescape(escapedChain) + if err != nil { + log.Warn(ctx).Str("chain", escapedChain).Err(err). + Msg(`received unexpected client certificate "chain" value`) + return c + } + + // Split the chain into the leaf and any intermediate certificates. + p, rest := pem.Decode([]byte(chain)) + if p == nil { + log.Warn(ctx).Str("chain", escapedChain). + Msg(`received unexpected client certificate "chain" value (no PEM block found)`) + return c + } + c.Leaf = string(pem.EncodeToMemory(p)) + c.Intermediates = string(rest) + return c } diff --git a/authorize/grpc_test.go b/authorize/grpc_test.go index e1198972a..d265e45b6 100644 --- a/authorize/grpc_test.go +++ b/authorize/grpc_test.go @@ -1,20 +1,25 @@ package authorize import ( + "bytes" "context" "net/http" "net/url" "testing" + envoy_config_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/structpb" "github.com/pomerium/pomerium/authorize/evaluator" "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/atomicutil" "github.com/pomerium/pomerium/internal/sessions" + "github.com/pomerium/pomerium/internal/testutil" "github.com/pomerium/pomerium/pkg/grpc/databroker" ) @@ -53,12 +58,9 @@ func Test_getEvaluatorRequest(t *testing.T) { }}, }) - actual, err := a.getEvaluatorRequestFromCheckRequest( + actual, err := a.getEvaluatorRequestFromCheckRequest(context.Background(), &envoy_service_auth_v3.CheckRequest{ Attributes: &envoy_service_auth_v3.AttributeContext{ - Source: &envoy_service_auth_v3.AttributeContext_Peer{ - Certificate: url.QueryEscape(certPEM), - }, Request: &envoy_service_auth_v3.AttributeContext_Request{ Http: &envoy_service_auth_v3.AttributeContext_HttpRequest{ Id: "id-1234", @@ -73,6 +75,17 @@ func Test_getEvaluatorRequest(t *testing.T) { Body: "BODY", }, }, + MetadataContext: &envoy_config_core_v3.Metadata{ + FilterMetadata: map[string]*structpb.Struct{ + "com.pomerium.client-certificate-info": &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "presented": structpb.NewBoolValue(true), + "validated": structpb.NewBoolValue(true), + "chain": structpb.NewStringValue(url.QueryEscape(certPEM)), + }, + }, + }, + }, }, }, &sessions.State{ @@ -92,7 +105,12 @@ func Test_getEvaluatorRequest(t *testing.T) { "Accept": "text/html", "X-Forwarded-Proto": "https", }, - certPEM, + evaluator.ClientCertificateInfo{ + Presented: true, + Validated: true, + Leaf: certPEM[1:] + "\n", + Intermediates: "", + }, "", ), } @@ -110,27 +128,25 @@ func Test_getEvaluatorRequestWithPortInHostHeader(t *testing.T) { }}, }) - actual, err := a.getEvaluatorRequestFromCheckRequest(&envoy_service_auth_v3.CheckRequest{ - Attributes: &envoy_service_auth_v3.AttributeContext{ - Source: &envoy_service_auth_v3.AttributeContext_Peer{ - Certificate: url.QueryEscape(certPEM), - }, - Request: &envoy_service_auth_v3.AttributeContext_Request{ - Http: &envoy_service_auth_v3.AttributeContext_HttpRequest{ - Id: "id-1234", - Method: http.MethodGet, - Headers: map[string]string{ - "accept": "text/html", - "x-forwarded-proto": "https", + actual, err := a.getEvaluatorRequestFromCheckRequest(context.Background(), + &envoy_service_auth_v3.CheckRequest{ + Attributes: &envoy_service_auth_v3.AttributeContext{ + Request: &envoy_service_auth_v3.AttributeContext_Request{ + Http: &envoy_service_auth_v3.AttributeContext_HttpRequest{ + Id: "id-1234", + Method: http.MethodGet, + Headers: map[string]string{ + "accept": "text/html", + "x-forwarded-proto": "https", + }, + Path: "/some/path?qs=1", + Host: "example.com:80", + Scheme: "http", + Body: "BODY", }, - Path: "/some/path?qs=1", - Host: "example.com:80", - Scheme: "http", - Body: "BODY", }, }, - }, - }, nil) + }, nil) require.NoError(t, err) expect := &evaluator.Request{ Policy: &a.currentOptions.Load().Policies[0], @@ -142,13 +158,144 @@ func Test_getEvaluatorRequestWithPortInHostHeader(t *testing.T) { "Accept": "text/html", "X-Forwarded-Proto": "https", }, - certPEM, + evaluator.ClientCertificateInfo{}, "", ), } assert.Equal(t, expect, actual) } +func Test_getClientCertificateInfo(t *testing.T) { + const leafPEM = `-----BEGIN CERTIFICATE----- +MIIBZTCCAQugAwIBAgICEAEwCgYIKoZIzj0EAwIwGjEYMBYGA1UEAxMPSW50ZXJt +ZWRpYXRlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMB8x +HTAbBgNVBAMTFENsaWVudCBjZXJ0aWZpY2F0ZSAxMFkwEwYHKoZIzj0CAQYIKoZI +zj0DAQcDQgAESly1cwEbcxaJBl6qAhrX1k7vejTFNE2dEbrTMpUYMl86GEWdsDYN +KSa/1wZCowPy82gPGjfAU90odkqJOusCQqM4MDYwEwYDVR0lBAwwCgYIKwYBBQUH +AwIwHwYDVR0jBBgwFoAU6Qb7nEl2XHKpf/QLL6PENsHFqbowCgYIKoZIzj0EAwID +SAAwRQIgXREMUz81pYwJCMLGcV0ApaXIUap1V5n1N4VhyAGxGLYCIQC8p/LwoSgu +71H3/nCi5MxsECsvVtsmHIfwXt0wulQ1TA== +-----END CERTIFICATE----- +` + const intermediatePEM = `-----BEGIN CERTIFICATE----- +MIIBYzCCAQigAwIBAgICEAEwCgYIKoZIzj0EAwIwEjEQMA4GA1UEAxMHUm9vdCBD +QTAiGA8wMDAxMDEwMTAwMDAwMFoYDzAwMDEwMTAxMDAwMDAwWjAaMRgwFgYDVQQD +Ew9JbnRlcm1lZGlhdGUgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATYaTr9 +uH4LpEp541/2SlKrdQZwNns+NHY/ftm++NhMDUn+izzNbPZ5aPT6VBs4Q6vbgfkK +kDaBpaKzb+uOT+o1o0IwQDAdBgNVHQ4EFgQU6Qb7nEl2XHKpf/QLL6PENsHFqbow +HwYDVR0jBBgwFoAUiQ3r61y+vxDn6PMWZrpISr67HiQwCgYIKoZIzj0EAwIDSQAw +RgIhAMvdURs28uib2QwSMnqJjKasMb30yrSJvTiSU+lcg97/AiEA+6GpioM0c221 +n/XNKVYEkPmeXHRoz9ZuVDnSfXKJoHE= +-----END CERTIFICATE----- +` + const rootPEM = `-----BEGIN CERTIFICATE----- +MIIBNzCB36ADAgECAgIQADAKBggqhkjOPQQDAjASMRAwDgYDVQQDEwdSb290IENB +MCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBIxEDAOBgNVBAMT +B1Jvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAS6q0mTvm29xasq7Lwk +aRGb2S/LkQFsAwaCXohSNvonCQHRMCRvA1IrQGk/oyBS5qrDoD9/7xkcVYHuTv5D +CbtuoyEwHzAdBgNVHQ4EFgQUiQ3r61y+vxDn6PMWZrpISr67HiQwCgYIKoZIzj0E +AwIDRwAwRAIgF1ux0ridbN+bo0E3TTcNY8Xfva7yquYRMmEkfbGvSb0CIDqK80B+ +fYCZHo3CID0gRSemaQ/jYMgyeBFrHIr6icZh +-----END CERTIFICATE----- +` + + cases := []struct { + label string + presented bool + validated bool + chain string + expected evaluator.ClientCertificateInfo + expectedLog string + }{ + { + "not presented", + false, + false, + "", + evaluator.ClientCertificateInfo{}, + "", + }, + { + "presented but invalid", + true, + false, + "", + evaluator.ClientCertificateInfo{ + Presented: true, + }, + "", + }, + { + "validated", + true, + true, + url.QueryEscape(leafPEM), + evaluator.ClientCertificateInfo{ + Presented: true, + Validated: true, + Leaf: leafPEM, + }, + "", + }, + { + "validated with intermediates", + true, + true, + url.QueryEscape(leafPEM + intermediatePEM + rootPEM), + evaluator.ClientCertificateInfo{ + Presented: true, + Validated: true, + Leaf: leafPEM, + Intermediates: intermediatePEM + rootPEM, + }, + "", + }, + { + "invalid chain URL encoding", + false, + false, + "invalid%URL%encoding", + evaluator.ClientCertificateInfo{}, + `{"level":"warn","chain":"invalid%URL%encoding","error":"invalid URL escape \"%UR\"","message":"received unexpected client certificate \"chain\" value"} +`, + }, + { + "invalid chain PEM encoding", + true, + true, + "not valid PEM data", + evaluator.ClientCertificateInfo{ + Presented: true, + Validated: true, + }, + `{"level":"warn","chain":"not valid PEM data","message":"received unexpected client certificate \"chain\" value (no PEM block found)"} +`, + }, + } + + var logOutput bytes.Buffer + zl := zerolog.New(&logOutput) + testutil.SetLogger(t, &zl) + + ctx := context.Background() + for i := range cases { + c := &cases[i] + logOutput.Reset() + t.Run(c.label, func(t *testing.T) { + metadata := &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "presented": structpb.NewBoolValue(c.presented), + "validated": structpb.NewBoolValue(c.validated), + "chain": structpb.NewStringValue(c.chain), + }, + } + info := getClientCertificateInfo(ctx, metadata) + assert.Equal(t, c.expected, info) + assert.Equal(t, c.expectedLog, logOutput.String()) + }) + } +} + type mockDataBrokerServiceClient struct { databroker.DataBrokerServiceClient diff --git a/config/envoyconfig/filters.go b/config/envoyconfig/filters.go index 401532ca7..8150d3c4b 100644 --- a/config/envoyconfig/filters.go +++ b/config/envoyconfig/filters.go @@ -35,8 +35,8 @@ func ExtAuthzFilter(grpcClientTimeout *durationpb.Duration) *envoy_extensions_fi }, }, }, - IncludePeerCertificate: true, - TransportApiVersion: envoy_config_core_v3.ApiVersion_V3, + MetadataContextNamespaces: []string{"com.pomerium.client-certificate-info"}, + TransportApiVersion: envoy_config_core_v3.ApiVersion_V3, }), }, } diff --git a/config/envoyconfig/listeners.go b/config/envoyconfig/listeners.go index c3f242f0b..651599771 100644 --- a/config/envoyconfig/listeners.go +++ b/config/envoyconfig/listeners.go @@ -1,6 +1,7 @@ package envoyconfig import ( + "bytes" "context" "crypto/tls" "encoding/base64" @@ -261,6 +262,7 @@ func (b *Builder) buildMainHTTPConnectionManagerFilter( filters := []*envoy_http_connection_manager.HttpFilter{ LuaFilter(luascripts.RemoveImpersonateHeaders), + LuaFilter(luascripts.SetClientCertificateMetadata), ExtAuthzFilter(grpcClientTimeout), LuaFilter(luascripts.ExtAuthzSetCookie), LuaFilter(luascripts.CleanUpstream), @@ -540,23 +542,15 @@ func (b *Builder) buildDownstreamValidationContext( ctx context.Context, cfg *config.Config, ) *envoy_extensions_transport_sockets_tls_v3.CommonTlsContext_ValidationContext { - needsClientCert := false - if ca, _ := cfg.Options.GetClientCA(); len(ca) > 0 { - needsClientCert = true - } - for _, p := range cfg.Options.GetAllPolicies() { - if p.TLSDownstreamClientCA != "" || p.TLSDownstreamClientCAFile != "" { - needsClientCert = true - } - } - if !needsClientCert { + clientCA := clientCABundle(ctx, cfg) + if len(clientCA) == 0 { return nil } - // trusted_ca is left blank because we verify the client certificate in the authorize service vc := &envoy_extensions_transport_sockets_tls_v3.CommonTlsContext_ValidationContext{ ValidationContext: &envoy_extensions_transport_sockets_tls_v3.CertificateValidationContext{ TrustChainVerification: envoy_extensions_transport_sockets_tls_v3.CertificateValidationContext_ACCEPT_UNTRUSTED, + TrustedCa: b.filemgr.BytesDataSource("client-ca.pem", clientCA), }, } @@ -574,6 +568,39 @@ func (b *Builder) buildDownstreamValidationContext( return vc } +// clientCABundle returns a bundle of the globally configured client CA and any +// per-route client CAs. +func clientCABundle(ctx context.Context, cfg *config.Config) []byte { + var bundle bytes.Buffer + ca, _ := cfg.Options.GetClientCA() + addCAToBundle(&bundle, ca) + allPolicies := cfg.Options.GetAllPolicies() + for i := range allPolicies { + p := &allPolicies[i] + if p.TLSDownstreamClientCA == "" { + continue + } + ca, err := base64.StdEncoding.DecodeString(p.TLSDownstreamClientCA) + if err != nil { + log.Error(ctx).Stringer("policy", p).Err(err).Msg("invalid client CA") + continue + } + addCAToBundle(&bundle, ca) + } + return bundle.Bytes() +} + +func addCAToBundle(bundle *bytes.Buffer, ca []byte) { + if len(ca) == 0 { + return + } + bundle.Write(ca) + // Make sure each CA is separated by a newline. + if ca[len(ca)-1] != '\n' { + bundle.WriteByte('\n') + } +} + func getAllRouteableHosts(options *config.Options, addr string) ([]string, error) { allHosts := sets.NewSorted[string]() diff --git a/config/envoyconfig/listeners_test.go b/config/envoyconfig/listeners_test.go index d856ebaa9..346079979 100644 --- a/config/envoyconfig/listeners_test.go +++ b/config/envoyconfig/listeners_test.go @@ -73,6 +73,9 @@ func Test_buildMainHTTPConnectionManagerFilter(t *testing.T) { func Test_buildDownstreamTLSContext(t *testing.T) { b := New("local-grpc", "local-http", "local-metrics", filemgr.NewManager(), nil) + cacheDir, _ := os.UserCacheDir() + clientCAFileName := filepath.Join(cacheDir, "pomerium", "envoy", "files", "client-ca-3533485838304b593757424e3354425157494c4747433534384f474f3631364d5332554c3332485a483834334d50454c344a.pem") + t.Run("no-validation", func(t *testing.T) { downstreamTLSContext, err := b.buildDownstreamTLSContextMulti(context.Background(), &config.Config{Options: &config.Options{}}, nil) require.NoError(t, err) @@ -95,7 +98,7 @@ func Test_buildDownstreamTLSContext(t *testing.T) { }) t.Run("client-ca", func(t *testing.T) { downstreamTLSContext, err := b.buildDownstreamTLSContextMulti(context.Background(), &config.Config{Options: &config.Options{ - ClientCA: "TEST", + ClientCA: "VEVTVAo=", // "TEST\n" (with a trailing newline) }}, nil) require.NoError(t, err) testutil.AssertProtoJSONEqual(t, `{ @@ -113,7 +116,10 @@ func Test_buildDownstreamTLSContext(t *testing.T) { }, "alpnProtocols": ["h2", "http/1.1"], "validationContext": { - "trustChainVerification": "ACCEPT_UNTRUSTED" + "trustChainVerification": "ACCEPT_UNTRUSTED", + "trustedCa": { + "filename": "`+clientCAFileName+`" + } } } }`, downstreamTLSContext) @@ -123,7 +129,7 @@ func Test_buildDownstreamTLSContext(t *testing.T) { Policies: []config.Policy{ { From: "https://a.example.com:1234", - TLSDownstreamClientCA: "TEST", + TLSDownstreamClientCA: "VEVTVA==", // "TEST" (no trailing newline) }, }, }}, nil) @@ -144,7 +150,10 @@ func Test_buildDownstreamTLSContext(t *testing.T) { }, "alpnProtocols": ["h2", "http/1.1"], "validationContext": { - "trustChainVerification": "ACCEPT_UNTRUSTED" + "trustChainVerification": "ACCEPT_UNTRUSTED", + "trustedCa": { + "filename": "`+clientCAFileName+`" + } } } }`, downstreamTLSContext) @@ -201,6 +210,31 @@ func Test_buildDownstreamTLSContext(t *testing.T) { }) } +func Test_clientCABundle(t *testing.T) { + // Make sure multiple bundled CAs are separated by newlines. + clientCA1 := []byte("client CA 1") + clientCA2 := []byte("client CA 2") + clientCA3 := []byte("client CA 3") + + b64 := base64.StdEncoding.EncodeToString + cfg := &config.Config{Options: &config.Options{ + ClientCA: b64(clientCA3), + Policies: []config.Policy{ + { + From: "https://foo.example.com", + TLSDownstreamClientCA: b64(clientCA2), + }, + { + From: "https://bar.example.com", + TLSDownstreamClientCA: b64(clientCA1), + }, + }, + }} + expected := []byte("client CA 3\nclient CA 2\nclient CA 1\n") + actual := clientCABundle(context.Background(), cfg) + assert.Equal(t, expected, actual) +} + func Test_getAllDomains(t *testing.T) { cert, err := cryptutil.GenerateCertificate(nil, "*.unknown.example.com") require.NoError(t, err) diff --git a/config/envoyconfig/testdata/main_http_connection_manager_filter.json b/config/envoyconfig/testdata/main_http_connection_manager_filter.json index 097eb957d..e22ea1cff 100644 --- a/config/envoyconfig/testdata/main_http_connection_manager_filter.json +++ b/config/envoyconfig/testdata/main_http_connection_manager_filter.json @@ -33,6 +33,15 @@ } } }, + { + "name": "envoy.filters.http.lua", + "typedConfig": { + "@type": "type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua", + "defaultSourceCode": { + "inlineString": "function envoy_on_request(request_handle)\n local metadata = request_handle:streamInfo():dynamicMetadata()\n local ssl = request_handle:streamInfo():downstreamSslConnection()\n metadata:set(\"com.pomerium.client-certificate-info\", \"presented\",\n ssl:peerCertificatePresented())\n local validated = ssl:peerCertificateValidated()\n metadata:set(\"com.pomerium.client-certificate-info\", \"validated\", validated)\n if validated then\n metadata:set(\"com.pomerium.client-certificate-info\", \"chain\",\n ssl:urlEncodedPemEncodedPeerCertificateChain())\n end\nend\n\nfunction envoy_on_response(response_handle) end\n" + } + } + }, { "name": "envoy.filters.http.ext_authz", "typedConfig": { @@ -43,7 +52,9 @@ }, "timeout": "10s" }, - "includePeerCertificate": true, + "metadataContextNamespaces": [ + "com.pomerium.client-certificate-info" + ], "statusOnError": { "code": "InternalServerError" }, diff --git a/internal/testutil/log.go b/internal/testutil/log.go new file mode 100644 index 000000000..680137853 --- /dev/null +++ b/internal/testutil/log.go @@ -0,0 +1,18 @@ +package testutil + +import ( + "testing" + + "github.com/rs/zerolog" + + "github.com/pomerium/pomerium/internal/log" +) + +// SetLogger sets the given logger as the global logger for the remainder of +// the current test. Because the logger is global, this must not be called from +// parallel tests. +func SetLogger(t *testing.T, logger *zerolog.Logger) { + originalLogger := log.Logger() + t.Cleanup(func() { log.SetLogger(originalLogger) }) + log.SetLogger(logger) +}