diff --git a/changelog/17272.txt b/changelog/17272.txt new file mode 100644 index 000000000000..9d580d80e311 --- /dev/null +++ b/changelog/17272.txt @@ -0,0 +1,3 @@ +```release-note:improvement +auth/cert: Adds support for TLS certificate authenticaion through a reverse proxy that terminates the SSL connection +``` diff --git a/command/server/listener_tcp.go b/command/server/listener_tcp.go index 6c121ec403e2..5ca52faa043c 100644 --- a/command/server/listener_tcp.go +++ b/command/server/listener_tcp.go @@ -64,6 +64,14 @@ func tcpListenerFactory(l *configutil.Listener, _ io.Writer, ui cli.Ui) (net.Lis if len(l.XForwardedForAuthorizedAddrs) > 0 { props["x_forwarded_for_reject_not_authorized"] = strconv.FormatBool(l.XForwardedForRejectNotAuthorized) } + + if len(l.XForwardedForAuthorizedAddrs) > 0 { + props["x_forwarded_for_client_cert_header"] = fmt.Sprintf("%s", l.XForwardedForClientCertHeader) + } + + if len(l.XForwardedForAuthorizedAddrs) > 0 { + props["x_forwarded_for_client_cert_header_decoders"] = fmt.Sprintf("%s", l.XForwardedForClientCertHeaderDecoders) + } } tlsConfig, reloadFunc, err := listenerutil.TLSConfig(l, props, ui) diff --git a/http/forwarded_for_test.go b/http/forwarded_for_test.go index c0409bab30f0..ce6a5144706f 100644 --- a/http/forwarded_for_test.go +++ b/http/forwarded_for_test.go @@ -5,7 +5,9 @@ package http import ( "bytes" + "encoding/base64" "net/http" + "net/url" "strings" "testing" @@ -255,4 +257,80 @@ func TestHandler_XForwardedFor(t *testing.T) { t.Fatalf("bad body: %s", buf.String()) } }) + + // Next: test an invalid certificate being sent + t.Run("reject_bad_cert_in_header", func(t *testing.T) { + t.Parallel() + testHandler := func(props *vault.HandlerProperties) http.Handler { + origHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(r.RemoteAddr)) + }) + listenerConfig := getListenerConfigForMarshalerTest(goodAddr) + listenerConfig.XForwardedForClientCertHeader = "X-Forwarded-Tls-Client-Cert" + listenerConfig.XForwardedForClientCertHeaderDecoders = "URL,BASE64" + return WrapForwardedForHandler(origHandler, listenerConfig) + } + + cluster := vault.NewTestCluster(t, nil, &vault.TestClusterOptions{ + HandlerFunc: HandlerFunc(testHandler), + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + req := client.NewRequest("GET", "/") + req.Headers = make(http.Header) + req.Headers.Set("x-forwarded-for", "5.6.7.8") + req.Headers.Set("x-forwarded-tls-client-cert", `BAD_TEXTMIIDtTCCAp2gAwIBAgIUf%2BjhKTFBnqSs34II0WS1L4QsbbAwDQYJKoZIhvcNAQEL%0ABQAwFjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wHhcNMTYwMjI5MDIyNzQxWhcNMjUw%0AMTA1MTAyODExWjAbMRkwFwYDVQQDExBjZXJ0LmV4YW1wbGUuY29tMIIBIjANBgkq%0AhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsZx0Svr82YJpFpIy4fJNW5fKA6B8mhxS%0ATRAVnygAftetT8puHflY0ss7Y6X2OXjsU0PRn%2B1PswtivhKi%2BeLtgWkUF9cFYFGn%0ASgMld6ZWRhNheZhA6ZfQmeM%2FBF2pa5HK2SDF36ljgjL9T%2BnWrru2Uv0BCoHzLAmi%0AYYMiIWplidMmMO5NTRG3k%2B3AN0TkfakB6JVzjLGhTcXdOcVEMXkeQVqJMAuGouU5%0AdonyqtnaHuIJGuUdy54YDnX86txhOQhAv6r7dHXzZxS4pmLvw8UI1rsSf%2FGLcUVG%0AB%2B5%2BAAGF5iuHC3N2DTl4xz3FcN4Cb4w9pbaQ7%2BmCzz%2BanqiJfyr2nwIDAQABo4H1%0AMIHyMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAdBgNVHQ4EFgQUm%2B%2Be%0AHpyM3p708bgZJuRYEdX1o%2BUwHwYDVR0jBBgwFoAUncSzT%2F6HMexyuiU9%2F7EgHu%2Bo%0Ak5swOwYIKwYBBQUHAQEELzAtMCsGCCsGAQUFBzAChh9odHRwOi8vMTI3LjAuMC4x%0AOjgyMDAvdjEvcGtpL2NhMCEGA1UdEQQaMBiCEGNlcnQuZXhhbXBsZS5jb22HBH8A%0AAAEwMQYDVR0fBCowKDAmoCSgIoYgaHR0cDovLzEyNy4wLjAuMTo4MjAwL3YxL3Br%0AaS9jcmwwDQYJKoZIhvcNAQELBQADggEBABsuvmPSNjjKTVN6itWzdQy%2BSgMIrwfs%0AX1Yb9Lefkkwmp9ovKFNQxa4DucuCuzXcQrbKwWTfHGgR8ct4rf30xCRoA7dbQWq4%0AaYqNKFWrRaBRAaaYZ%2FO1ApRTOrXqRx9Eqr0H1BXLsoAq%2BmWassL8sf6siae%2BCpwA%0AKqBko5G0dNXq5T4i2LQbmoQSVetIrCJEeMrU%2BidkuqfV2h1BQKgSEhFDABjFdTCN%0AQDAHsEHsi2M4%2FjRW9fqEuhHSDfl2n7tkFUI8wTHUUCl7gXwweJ4qtaSXIwKXYzNj%0AxqKHA8Purc1Yfybz4iE1JCROi9fInKlzr5xABq8nb9Qc%2FJ9DIQM%2BXmk%3D`) + resp, err := client.RawRequest(req) + if err == nil { + t.Fatal("expected error") + } + defer resp.Body.Close() + buf := bytes.NewBuffer(nil) + buf.ReadFrom(resp.Body) + if !strings.Contains(buf.String(), "failed to base64 decode the client certificate: ") { + t.Fatalf("bad body: %v", buf.String()) + } + }) + + // Next: test a valid (unverified) certificate being sent + t.Run("pass_cert", func(t *testing.T) { + t.Parallel() + testHandler := func(props *vault.HandlerProperties) http.Handler { + origHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(base64.StdEncoding.EncodeToString(r.TLS.PeerCertificates[0].Raw))) + }) + listenerConfig := getListenerConfigForMarshalerTest(goodAddr) + listenerConfig.XForwardedForClientCertHeader = "X-Forwarded-Tls-Client-Cert" + listenerConfig.XForwardedForClientCertHeaderDecoders = "URL,BASE64" + return WrapForwardedForHandler(origHandler, listenerConfig) + } + + cluster := vault.NewTestCluster(t, nil, &vault.TestClusterOptions{ + HandlerFunc: HandlerFunc(testHandler), + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + req := client.NewRequest("GET", "/") + req.Headers = make(http.Header) + req.Headers.Set("x-forwarded-for", "5.6.7.8") + testcertificate := `MIIDtTCCAp2gAwIBAgIUf%2BjhKTFBnqSs34II0WS1L4QsbbAwDQYJKoZIhvcNAQEL%0ABQAwFjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wHhcNMTYwMjI5MDIyNzQxWhcNMjUw%0AMTA1MTAyODExWjAbMRkwFwYDVQQDExBjZXJ0LmV4YW1wbGUuY29tMIIBIjANBgkq%0AhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsZx0Svr82YJpFpIy4fJNW5fKA6B8mhxS%0ATRAVnygAftetT8puHflY0ss7Y6X2OXjsU0PRn%2B1PswtivhKi%2BeLtgWkUF9cFYFGn%0ASgMld6ZWRhNheZhA6ZfQmeM%2FBF2pa5HK2SDF36ljgjL9T%2BnWrru2Uv0BCoHzLAmi%0AYYMiIWplidMmMO5NTRG3k%2B3AN0TkfakB6JVzjLGhTcXdOcVEMXkeQVqJMAuGouU5%0AdonyqtnaHuIJGuUdy54YDnX86txhOQhAv6r7dHXzZxS4pmLvw8UI1rsSf%2FGLcUVG%0AB%2B5%2BAAGF5iuHC3N2DTl4xz3FcN4Cb4w9pbaQ7%2BmCzz%2BanqiJfyr2nwIDAQABo4H1%0AMIHyMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAdBgNVHQ4EFgQUm%2B%2Be%0AHpyM3p708bgZJuRYEdX1o%2BUwHwYDVR0jBBgwFoAUncSzT%2F6HMexyuiU9%2F7EgHu%2Bo%0Ak5swOwYIKwYBBQUHAQEELzAtMCsGCCsGAQUFBzAChh9odHRwOi8vMTI3LjAuMC4x%0AOjgyMDAvdjEvcGtpL2NhMCEGA1UdEQQaMBiCEGNlcnQuZXhhbXBsZS5jb22HBH8A%0AAAEwMQYDVR0fBCowKDAmoCSgIoYgaHR0cDovLzEyNy4wLjAuMTo4MjAwL3YxL3Br%0AaS9jcmwwDQYJKoZIhvcNAQELBQADggEBABsuvmPSNjjKTVN6itWzdQy%2BSgMIrwfs%0AX1Yb9Lefkkwmp9ovKFNQxa4DucuCuzXcQrbKwWTfHGgR8ct4rf30xCRoA7dbQWq4%0AaYqNKFWrRaBRAaaYZ%2FO1ApRTOrXqRx9Eqr0H1BXLsoAq%2BmWassL8sf6siae%2BCpwA%0AKqBko5G0dNXq5T4i2LQbmoQSVetIrCJEeMrU%2BidkuqfV2h1BQKgSEhFDABjFdTCN%0AQDAHsEHsi2M4%2FjRW9fqEuhHSDfl2n7tkFUI8wTHUUCl7gXwweJ4qtaSXIwKXYzNj%0AxqKHA8Purc1Yfybz4iE1JCROi9fInKlzr5xABq8nb9Qc%2FJ9DIQM%2BXmk%3D` + req.Headers.Set("x-forwarded-tls-client-cert", testcertificate) + resp, err := client.RawRequest(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + buf := bytes.NewBuffer(nil) + buf.ReadFrom(resp.Body) + testcertificate, _ = url.QueryUnescape(testcertificate) + if !strings.Contains(buf.String(), strings.ReplaceAll(testcertificate, "\n", "")) { + t.Fatalf("bad body: %v vs %v", buf.String(), testcertificate) + } + }) } diff --git a/http/handler.go b/http/handler.go index 2ee5f287444e..2f0b31c8ce32 100644 --- a/http/handler.go +++ b/http/handler.go @@ -6,7 +6,10 @@ package http import ( "bytes" "context" + "crypto/x509" + "encoding/base64" "encoding/json" + "encoding/pem" "errors" "fmt" "io" @@ -507,6 +510,8 @@ func WrapForwardedForHandler(h http.Handler, l *configutil.Listener) http.Handle hopSkips := l.XForwardedForHopSkips authorizedAddrs := l.XForwardedForAuthorizedAddrs rejectNotAuthz := l.XForwardedForRejectNotAuthorized + clientCertHeader := l.XForwardedForClientCertHeader + clientCertHeaderDecoders := l.XForwardedForClientCertHeaderDecoders return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { headers, headersOK := r.Header[textproto.CanonicalMIMEHeaderKey("X-Forwarded-For")] if !headersOK || len(headers) == 0 { @@ -589,6 +594,60 @@ func WrapForwardedForHandler(h http.Handler, l *configutil.Listener) http.Handle } r.RemoteAddr = net.JoinHostPort(acc[indexToUse], port) + + // Import the Client Certificate forwarded by the reverse proxy + // There should be only 1 instance of the header, but looping allows for more flexibility + clientCertHeaders, clientCertHeadersOK := r.Header[textproto.CanonicalMIMEHeaderKey(clientCertHeader)] + if clientCertHeadersOK && len(clientCertHeaders) > 0 { + var client_certs []*x509.Certificate + for _, header := range clientCertHeaders { + // Multiple certs should be comma delimetered + vals := strings.Split(header, ",") + for _, v := range vals { + actions := strings.Split(clientCertHeaderDecoders, ",") + for _, action := range actions { + switch action { + case "URL": + decoded, err := url.QueryUnescape(v) + if err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("failed to url unescape the client certificate: %w", err)) + return + } + v = decoded + case "BASE64": + decoded, err := base64.StdEncoding.DecodeString(v) + if err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("failed to base64 decode the client certificate: %w", err)) + return + } + v = string(decoded[:]) + case "DER": + decoded, _ := pem.Decode([]byte(v)) + if decoded == nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("failed to convert the client certificate to DER format: %w", err)) + return + } + v = string(decoded.Bytes[:]) + default: + respondError(w, http.StatusBadRequest, fmt.Errorf("unknown decode option specified: %s", action)) + return + } + } + + cert, err := x509.ParseCertificate([]byte(v)) + if err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("failed to parse the client certificate: %w", err)) + return + } + client_certs = append(client_certs, cert) + } + } + if r.TLS == nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("Server must use TLS for certificate authentication")) + } else { + r.TLS.PeerCertificates = append(client_certs, r.TLS.PeerCertificates...) + } + } h.ServeHTTP(w, r) }) } diff --git a/internalshared/configutil/listener.go b/internalshared/configutil/listener.go index 2c526eca9bcc..050470202887 100644 --- a/internalshared/configutil/listener.go +++ b/internalshared/configutil/listener.go @@ -94,14 +94,16 @@ type Listener struct { ProxyProtocolAuthorizedAddrs []*sockaddr.SockAddrMarshaler `hcl:"-"` ProxyProtocolAuthorizedAddrsRaw interface{} `hcl:"proxy_protocol_authorized_addrs,alias:ProxyProtocolAuthorizedAddrs"` - XForwardedForAuthorizedAddrs []*sockaddr.SockAddrMarshaler `hcl:"-"` - XForwardedForAuthorizedAddrsRaw interface{} `hcl:"x_forwarded_for_authorized_addrs,alias:XForwardedForAuthorizedAddrs"` - XForwardedForHopSkips int64 `hcl:"-"` - XForwardedForHopSkipsRaw interface{} `hcl:"x_forwarded_for_hop_skips,alias:XForwardedForHopSkips"` - XForwardedForRejectNotPresent bool `hcl:"-"` - XForwardedForRejectNotPresentRaw interface{} `hcl:"x_forwarded_for_reject_not_present,alias:XForwardedForRejectNotPresent"` - XForwardedForRejectNotAuthorized bool `hcl:"-"` - XForwardedForRejectNotAuthorizedRaw interface{} `hcl:"x_forwarded_for_reject_not_authorized,alias:XForwardedForRejectNotAuthorized"` + XForwardedForAuthorizedAddrs []*sockaddr.SockAddrMarshaler `hcl:"-"` + XForwardedForAuthorizedAddrsRaw interface{} `hcl:"x_forwarded_for_authorized_addrs,alias:XForwardedForAuthorizedAddrs"` + XForwardedForHopSkips int64 `hcl:"-"` + XForwardedForHopSkipsRaw interface{} `hcl:"x_forwarded_for_hop_skips,alias:XForwardedForHopSkips"` + XForwardedForRejectNotPresent bool `hcl:"-"` + XForwardedForRejectNotPresentRaw interface{} `hcl:"x_forwarded_for_reject_not_present,alias:XForwardedForRejectNotPresent"` + XForwardedForRejectNotAuthorized bool `hcl:"-"` + XForwardedForRejectNotAuthorizedRaw interface{} `hcl:"x_forwarded_for_reject_not_authorized,alias:XForwardedForRejectNotAuthorized"` + XForwardedForClientCertHeader string `hcl:"x_forwarded_for_client_cert_header,alias:XForwardedForClientCertHeader"` + XForwardedForClientCertHeaderDecoders string `hcl:"x_forwarded_for_client_cert_header_decoders,alias:XForwardedForClientCertHeaderDecoders"` SocketMode string `hcl:"socket_mode"` SocketUser string `hcl:"socket_user"` diff --git a/website/content/docs/configuration/listener/tcp.mdx b/website/content/docs/configuration/listener/tcp.mdx index f696e71b65df..5abb41b11ca1 100644 --- a/website/content/docs/configuration/listener/tcp.mdx +++ b/website/content/docs/configuration/listener/tcp.mdx @@ -220,6 +220,26 @@ default value in the `"/sys/config/ui"` [API endpoint](/vault/api-docs/system/co connecting client's IP, for example `3.4.5.6`. Note this requires the load balancer to send the connecting client's IP in the `X-Forwarded-For` header. +- `x_forwarded_for_client_cert_header` `(string: "")` – + Specifies the header that will be used for the client certificate. + This is required if you use the [TLS Certificates Auth Method](/docs/auth/cert) and your + vault server is behind a reverse proxy. + +- `x_forwarded_for_client_cert_header_decoders` `(string: "")` – + Comma delimited list that specifies the decoders that will be used to decode the client certificate. + This is required if you use the [TLS Certificates Auth Method](/docs/auth/cert) and your + vault server is behind a reverse proxy. The resulting certificate should be in DER format. + Available Values: + + - BASE64 - Runs Base64 decode + - DER - Converts a pem certificate to der + - URL - Runs URL decode + + Known Values: + + - Traefik = "BASE64" + - NGINX = "URL,DER" + - `x_forwarded_for_hop_skips` `(string: "0")` – The number of addresses that will be skipped from the _rear_ of the set of hops. For instance, for a header value of `1.2.3.4, 2.3.4.5, 3.4.5.6, 4.5.6.7`, if this value is set to `"1"`, the address that