Skip to content
11 changes: 11 additions & 0 deletions apis/contour/v1beta1/ingressroute.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ type TLS struct {
// and the encrypted handshake will be passed through to the
// backing cluster.
Passthrough bool `json:"passthrough,omitempty"`
// If specified client-certificate is required and validated (mTLS).
ClientValidation *ClientValidation `json:"clientValidation,omitempty"`
}

type ClientValidation struct {
// The name of a secret in the current namespace used to validate the client certificate
SecretName string `json:"secretName,omitempty"`
// SPKIs used to validate the client certificate
Spkis []string `json:"spkis,omitempty"`
// Hashes used to validate the client certificate
Hashes []string `json:"hashes,omitempty"`
}

// Route contains the set of routes for a virtual host
Expand Down
75 changes: 75 additions & 0 deletions docs/ingressroute.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,81 @@ spec:

In this example, the permission for Contour to reference the Secret `example-com-wildcard` in the `admin` namespace has been delegated to IngressRoute objects in the `example-com` namespace.

#### Mutual TLS (client validation)

Mutual TLS (mTLS) means that not only the server but also the client must present a certificate for validation.

```yaml
apiVersion: contour.heptio.com/v1beta1
kind: IngressRoute
metadata:
name: www
namespace: default
spec:
virtualhost:
fqdn: foo.com
tls:
secretName: server-secret
clientValidation:
secretName: client-secret
routes:
- match: /
services:
- name: s1
port: 80
```

With this configuration clients accessing `foo.com` must provide a certificate that can be validated by the CA stored in the `client-secret`. If the client validation fails the access will be rejected.


##### Other ways of client certificate validation

Envoy allows two other ways for client certificate validation;

* Subject Public Key Information (SPKI)
* Certificate hash

These can also be specified in `clientValidation`;

```yaml
apiVersion: contour.heptio.com/v1beta1
kind: IngressRoute
metadata:
name: www
namespace: default
spec:
virtualhost:
fqdn: foo.com
tls:
secretName: server-secret
clientValidation:
spkis:
- 2IEpPESU/mmC30tPsnOfbGKdwKdQfN/wZw1QWpjGlmk=
hashes:
- c49c6930b9fbfb72a0d9d07504133d26c87588aa2d32b2919475f647a62f6fec
routes:
- match: /
services:
- name: s1
port: 80
```

Please read the [Envoy documentation](https://www.envoyproxy.io/docs/envoy/latest/api-v2/api/v2/auth/cert.proto.html#auth-certificatevalidationcontext) for details.


##### Forward client certificate details

The backend application may need information of the certificate used (by envoy) to validate the client. Contour forwards client certificate details in the `X-Forwarded-Client-Cert` http header;

```
X-Forwarded-Client-Cert: [Hash=c49c6930b9fbfb72a0d9d07504133d26c87588aa2d32b2919475f647a62f6fec]
```

The hash uniquely identifies the used client certificate.

Contour uses the [SANITIZE_SET](https://www.envoyproxy.io/docs/envoy/latest/api-v2/config/filter/network/http_connection_manager/v2/http_connection_manager.proto#envoy-api-enum-config-filter-network-http-connection-manager-v2-httpconnectionmanager-forwardclientcertdetails) option in `envoy`.


### Routing

Each route entry in an IngressRoute must start with a prefix match.
Expand Down
10 changes: 9 additions & 1 deletion internal/contour/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,15 @@ func (v *listenerVisitor) visit(vertex dag.Vertex) {

// attach certificate data to this listener if provided.
if vh.Secret != nil {
fc.TlsContext = envoy.DownstreamTLSContext(envoy.Secretname(vh.Secret), vh.MinProtoVersion, alpnProtos...)
if vh.ClientValidation != nil {
clientValidation := &envoy.ClientValidation{
Spkis: vh.ClientValidation.Spkis,
Hashes: vh.ClientValidation.Hashes,
}
fc.TlsContext = envoy.DownstreamTLSContext(envoy.Secretname(vh.Secret), clientValidation, vh.MinProtoVersion, alpnProtos...)
} else {
fc.TlsContext = envoy.DownstreamTLSContext(envoy.Secretname(vh.Secret), nil, vh.MinProtoVersion, alpnProtos...)
}
}

v.listeners[ENVOY_HTTPS_LISTENER].FilterChains = append(v.listeners[ENVOY_HTTPS_LISTENER].FilterChains, fc)
Expand Down
2 changes: 1 addition & 1 deletion internal/contour/listener_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ func filterchain(filters ...listener.Filter) []listener.FilterChain {
}

func tlscontext(tlsMinProtoVersion auth.TlsParameters_TlsProtocol, alpnprotos ...string) *auth.DownstreamTlsContext {
return envoy.DownstreamTLSContext("default/secret/735ad571c1", tlsMinProtoVersion, alpnprotos...)
return envoy.DownstreamTLSContext("default/secret/735ad571c1", nil, tlsMinProtoVersion, alpnprotos...)
}

func secretdata(cert, key string) map[string][]byte {
Expand Down
13 changes: 13 additions & 0 deletions internal/dag/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,19 @@ func (b *builder) computeIngressRoutes() {
svhost.Secret = sec
svhost.MinProtoVersion = minProtoVersion(ir.Spec.VirtualHost.TLS.MinimumProtocolVersion)
enforceTLS = true
if tls.ClientValidation != nil {
svhost.ClientValidation = &ClientValidation{
Spkis: tls.ClientValidation.Spkis,
Hashes: tls.ClientValidation.Hashes,
}
if tls.ClientValidation.SecretName != "" {
m := splitSecret(tls.ClientValidation.SecretName, ir.Namespace)
sec := b.lookupSecret(m, validSecret)
if sec != nil {
svhost.ClientValidation.Secret = sec
}
}
}
}
// passthrough is true if tls.secretName is not present, and
// tls.passthrough is set to true.
Expand Down
12 changes: 12 additions & 0 deletions internal/dag/dag.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,18 @@ type SecureVirtualHost struct {

// The cert and key for this host.
*Secret

// The client certificate validation for this host (mTLS)
*ClientValidation
}

type ClientValidation struct {
// The CA for client validation.
*Secret
// SPKIs used to validate the client certificate
Spkis []string
// Hashes used to validate the client certificate
Hashes []string
}

func (s *SecureVirtualHost) Visit(f func(Vertex)) {
Expand Down
2 changes: 1 addition & 1 deletion internal/e2e/lds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1435,7 +1435,7 @@ func filterchaintls(domain string, secret *v1.Secret, filter listener.Filter, al
ServerNames: []string{domain},
}
secretName := envoy.Secretname(&dag.Secret{Object: secret})
fc.TlsContext = envoy.DownstreamTLSContext(secretName, auth.TlsParameters_TLSv1_1, alpn...)
fc.TlsContext = envoy.DownstreamTLSContext(secretName, nil, auth.TlsParameters_TLSv1_1, alpn...)
return []listener.FilterChain{fc}
}

Expand Down
21 changes: 19 additions & 2 deletions internal/envoy/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package envoy
import (
"github.com/envoyproxy/go-control-plane/envoy/api/v2/auth"
"github.com/envoyproxy/go-control-plane/envoy/api/v2/core"
"github.com/gogo/protobuf/types"
)

var (
Expand Down Expand Up @@ -91,9 +92,15 @@ func validationContext(ca []byte, subjectName string) *auth.CommonTlsContext_Val
}
}

type ClientValidation struct {
Secret *auth.Secret
Spkis []string
Hashes []string
}

// DownstreamTLSContext creates a new DownstreamTlsContext.
func DownstreamTLSContext(secretName string, tlsMinProtoVersion auth.TlsParameters_TlsProtocol, alpnProtos ...string) *auth.DownstreamTlsContext {
return &auth.DownstreamTlsContext{
func DownstreamTLSContext(secretName string, clientValidation *ClientValidation, tlsMinProtoVersion auth.TlsParameters_TlsProtocol, alpnProtos ...string) *auth.DownstreamTlsContext {
context := auth.DownstreamTlsContext{
CommonTlsContext: &auth.CommonTlsContext{
TlsParams: &auth.TlsParameters{
TlsMinimumProtocolVersion: tlsMinProtoVersion,
Expand All @@ -107,4 +114,14 @@ func DownstreamTLSContext(secretName string, tlsMinProtoVersion auth.TlsParamete
AlpnProtocols: alpnProtos,
},
}
if clientValidation != nil {
context.RequireClientCertificate = &types.BoolValue{Value: true}
context.CommonTlsContext.ValidationContextType = &auth.CommonTlsContext_ValidationContext{
ValidationContext: &auth.CertificateValidationContext{
VerifyCertificateSpki: clientValidation.Spkis,
VerifyCertificateHash: clientValidation.Hashes,
},
}
}
return &context
}
9 changes: 5 additions & 4 deletions internal/envoy/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,11 @@ func HTTPConnectionManager(routename, accessLogPath string) listener.Filter {
// a Host: header. See #537.
AcceptHttp_10: true,
},
AccessLog: FileAccessLog(accessLogPath),
UseRemoteAddress: &types.BoolValue{Value: true}, // TODO(jbeda) should this ever be false?
NormalizePath: &types.BoolValue{Value: true},
IdleTimeout: idleTimeout(HTTPDefaultIdleTimeout),
AccessLog: FileAccessLog(accessLogPath),
UseRemoteAddress: &types.BoolValue{Value: true}, // TODO(jbeda) should this ever be false?
NormalizePath: &types.BoolValue{Value: true},
IdleTimeout: idleTimeout(HTTPDefaultIdleTimeout),
ForwardClientCertDetails: http.SANITIZE_SET,
}),
},
}
Expand Down
11 changes: 6 additions & 5 deletions internal/envoy/listener_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ func TestSocketAddress(t *testing.T) {
func TestDownstreamTLSContext(t *testing.T) {
const secretName = "default/tls-cert"

got := DownstreamTLSContext(secretName, auth.TlsParameters_TLSv1_1, "h2", "http/1.1")
got := DownstreamTLSContext(secretName, nil, auth.TlsParameters_TLSv1_1, "h2", "http/1.1")
want := &auth.DownstreamTlsContext{
CommonTlsContext: &auth.CommonTlsContext{
TlsParams: &auth.TlsParameters{
Expand Down Expand Up @@ -254,10 +254,11 @@ func TestHTTPConnectionManager(t *testing.T) {
// a Host: header. See #537.
AcceptHttp_10: true,
},
AccessLog: FileAccessLog("/dev/stdout"),
UseRemoteAddress: &types.BoolValue{Value: true},
NormalizePath: &types.BoolValue{Value: true},
IdleTimeout: duration(HTTPDefaultIdleTimeout),
AccessLog: FileAccessLog("/dev/stdout"),
UseRemoteAddress: &types.BoolValue{Value: true},
NormalizePath: &types.BoolValue{Value: true},
IdleTimeout: duration(HTTPDefaultIdleTimeout),
ForwardClientCertDetails: http.SANITIZE_SET,
}),
},
},
Expand Down