Skip to content

Commit

Permalink
opts: enforce loopback (or mTLS) for H2C/id header
Browse files Browse the repository at this point in the history
h2c must happen on a loopback connection as the connection is not using TLS at
all.
identity headers must use a loopback connection or a mTLS conection is
required. Trust in both directions is important. kube-rbac-proxy needs
to provide certs, such that upstream can verify the authenticity of the
headers. upstream needs certs, such that we can be sure to not leak
secrets.
  • Loading branch information
ibihim committed Feb 21, 2024
1 parent 3f3aae2 commit 3499d17
Show file tree
Hide file tree
Showing 36 changed files with 1,169 additions and 14 deletions.
97 changes: 95 additions & 2 deletions cmd/kube-rbac-proxy/app/options/proxyoptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ limitations under the License.
package options

import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/url"
"os"
"path"
"time"

"github.com/ghodss/yaml"
"github.com/spf13/pflag"
Expand All @@ -36,8 +42,9 @@ import (

// ProxyOptions are options specific to the kube-rbac-proxy
type ProxyOptions struct {
Upstream string
UpstreamForceH2C bool
Upstream string
UpstreamForceH2C bool
UpstreamDNSTimeout int

UpstreamCAFile string
UpstreamClientCertFile string
Expand All @@ -57,6 +64,7 @@ type ProxyOptions struct {
func (o *ProxyOptions) AddFlags(flagset *pflag.FlagSet) {
flagset.StringVar(&o.Upstream, "upstream", "", "The upstream URL to proxy to once requests have successfully been authenticated and authorized.")
flagset.BoolVar(&o.UpstreamForceH2C, "upstream-force-h2c", false, "Force h2c to communicate with the upstream. This is required when the upstream speaks h2c(http/2 cleartext - insecure variant of http/2) only. For example, go-grpc server in the insecure mode, such as helm's tiller w/o TLS, speaks h2c only")
flagset.IntVar(&o.UpstreamDNSTimeout, "upstream-dns-timeout", 5, "The timeout in seconds for DNS lookups of the upstream. If set to 0, no timeout is set.")

// upstream tls options
flagset.StringVar(&o.UpstreamCAFile, "upstream-ca-file", "", "The CA the upstream uses for TLS connection. This is required when the upstream uses TLS and its own CA certificate")
Expand Down Expand Up @@ -103,6 +111,17 @@ func (o *ProxyOptions) Validate() []error {
}
}

// Verify secure connection settings, if necessary.
if identityheaders.HasIdentityHeadersEnabled(o.UpstreamHeader) || o.UpstreamForceH2C {
isSecure, err := hasSecureConnection(o)
if err != nil {
errs = append(errs, err)
}
if !isSecure {
errs = append(errs, errors.New("configuration requires a secure connection (mTLS or loopback) to the upstream (h2c/identity headers)"))
}
}

return errs
}

Expand All @@ -125,13 +144,87 @@ func (o *ProxyOptions) ApplyTo(c *server.KubeRBACProxyInfo, a *serverconfig.Auth
}
}

c.UpstreamHeaders = o.UpstreamHeader
c.IgnorePaths = o.IgnorePaths
c.AllowPaths = o.AllowPaths
a.APIAudiences = o.TokenAudiences

return nil
}

func hasSecureConnection(o *ProxyOptions) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.UpstreamDNSTimeout)*time.Second)
defer cancel()

isLoopback, err := isLoopbackAddress(ctx, o.Upstream)
if err == nil && isLoopback {
return true, nil
}

if o.UpstreamForceH2C {
return false, fmt.Errorf("loopback address is required for h2c")
}

// isMTLSPossible emphasizes that we can't verify that upstream verifies the
// client's certificate.
isMTLSPossible, err := isMTLSConfigured(o.UpstreamClientCertFile, o.UpstreamClientKeyFile, o.UpstreamCAFile)
if !isMTLSPossible || err != nil {
return false, err
}

return true, nil
}

func isMTLSConfigured(upstreamClientCertPath, upstreamClientKeyPath, upstreamCAPath string) (bool, error) {
// Check if client is configured to provide a certificiate.
if len(upstreamClientCertPath) > 0 {
return false, nil
}

_, err := tls.LoadX509KeyPair(upstreamClientCertPath, upstreamClientKeyPath)
if err != nil {
return false, fmt.Errorf("failed to read upstream client cert/key: %w", err)
}

// Check if we have a CA to verify upstream.
upstreamCAPEM, err := os.ReadFile(upstreamCAPath)
if err != nil {
return false, fmt.Errorf("failed to read the upstream CA file: %w", err)
}

upstreamCACertPool := x509.NewCertPool()
if ok := upstreamCACertPool.AppendCertsFromPEM(upstreamCAPEM); !ok {
return false, errors.New("error parsing upstream CA certificate")
}

return true, nil
}

func isLoopbackAddress(ctx context.Context, address string) (bool, error) {
u, err := url.Parse(address)
if err != nil {
return false, fmt.Errorf("failed to parse upstream URL: %w", err)
}

ip := net.ParseIP(u.Hostname())
if ip != nil {
return ip.IsLoopback(), nil
}

ips, err := (&net.Resolver{}).LookupIPAddr(ctx, u.Hostname())
if err != nil {
return false, fmt.Errorf("failed to lookup ip: %w", err)
}

for _, ip := range ips {
if !ip.IP.IsLoopback() {
return false, nil
}
}

return true, nil
}

type configfile struct {
AuthorizationConfig *authz.AuthzConfig `json:"authorization,omitempty"`
}
Expand Down
7 changes: 5 additions & 2 deletions pkg/authn/identityheaders/identityheaders.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ type AuthnHeaderConfig struct {
// WithAuthHeaders adds identity information to the headers.
// Must not be used, if connection is not encrypted with TLS.
func WithAuthHeaders(handler http.Handler, cfg *AuthnHeaderConfig) http.Handler {
upstreamHeadersEnabled := len(cfg.GroupsFieldName) > 0 || len(cfg.UserFieldName) > 0
if !upstreamHeadersEnabled {
if !HasIdentityHeadersEnabled(cfg) {
return handler
}

Expand All @@ -55,3 +54,7 @@ func WithAuthHeaders(handler http.Handler, cfg *AuthnHeaderConfig) http.Handler
handler.ServeHTTP(w, req)
})
}

func HasIdentityHeadersEnabled(cfg *AuthnHeaderConfig) bool {
return len(cfg.GroupsFieldName) > 0 || len(cfg.UserFieldName) > 0
}
35 changes: 35 additions & 0 deletions test/e2e/h2c-upstream/deployment-proxy-non-loopback.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: kube-rbac-proxy
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: kube-rbac-proxy
template:
metadata:
labels:
app: kube-rbac-proxy
spec:
serviceAccountName: kube-rbac-proxy
containers:
- name: kube-rbac-proxy
image: quay.io/brancz/kube-rbac-proxy:local
args:
- "--secure-port=8443"
- "--upstream=http://http-echo-service.default.svc.cluster.local:80/"
- "--authentication-skip-lookup"
- "--upstream-force-h2c=true"
- "--logtostderr=true"
- "--v=10"
ports:
- containerPort: 8443
name: https
- name: prometheus-example-app
image: quay.io/brancz/prometheus-example-app:v0.4.0
args:
- "--bind=127.0.0.1:8081"
- "--h2c=true"

36 changes: 36 additions & 0 deletions test/e2e/h2c-upstream/deployment-upstream-non-loopback.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: http-echo
labels:
app: http-echo
spec:
replicas: 1
selector:
matchLabels:
app: http-echo
template:
metadata:
labels:
app: http-echo
spec:
containers:
- name: http-echo
image: mendhak/http-https-echo
env:
- name: HTTP_PORT
value: 8080
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: http-echo-service
spec:
ports:
- port: 80
targetPort: 8080
selector:
app: http-echo

24 changes: 23 additions & 1 deletion test/e2e/h2c_upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,29 @@ func testH2CUpstream(client kubernetes.Interface) kubetest.TestSuite {
command := `curl --connect-timeout 5 -v -s -k --fail -H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" https://kube-rbac-proxy.default.svc.cluster.local:8443/metrics`

kubetest.Scenario{
Name: "With H2C Upstream",
Name: "With H2C non-local upstream",

Given: kubetest.Actions(
kubetest.CreatedManifests(
client,
"h2c-upstream/clusterRole.yaml",
"h2c-upstream/clusterRoleBinding.yaml",
"h2c-upstream/deployment-proxy-non-loopback.yaml",
"h2c-upstream/deployment-upstream-non-loopback.yaml",
"h2c-upstream/service.yaml",
"h2c-upstream/serviceAccount.yaml",
),
),
Then: kubetest.Actions(
kubetest.PodIsCrashLoopBackOff(
client,
"kube-rbac-proxy",
),
),
}.Run(t)

kubetest.Scenario{
Name: "With H2C local upstream",

Given: kubetest.Actions(
kubetest.CreatedManifests(
Expand Down
132 changes: 132 additions & 0 deletions test/e2e/identityheaders.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
Copyright 2024 the kube-rbac-proxy maintainers. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package e2e

import (
"testing"

"github.com/brancz/kube-rbac-proxy/test/kubetest"
"k8s.io/client-go/kubernetes"
)

func testIdentityHeaders(client kubernetes.Interface) kubetest.TestSuite {
return func(t *testing.T) {
command := `curl --connect-timeout 5 -v -s -k --fail -H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" https://kube-rbac-proxy.default.svc.cluster.local:8443/metrics`

kubetest.Scenario{
Name: "With x-remote-user",
Description: `
Verifies that remote user is set to the service account, when
upstreama is listening on loopback through a HTTP connection.
`,

Given: kubetest.Actions(
kubetest.CreatedManifests(
client,
"identityheaders/default/clusterRole-client.yaml",
"identityheaders/default/clusterRole.yaml",
"identityheaders/default/clusterRoleBinding-client.yaml",
"identityheaders/default/clusterRoleBinding.yaml",
"identityheaders/default/configmap-nginx.yaml",
"identityheaders/default/deployment.yaml",
"identityheaders/default/service.yaml",
"identityheaders/default/serviceAccount.yaml",
),
),
When: kubetest.Actions(
kubetest.PodsAreReady(
client,
1,
"app=kube-rbac-proxy",
),
kubetest.ServiceIsReady(
client,
"kube-rbac-proxy",
),
),
Then: kubetest.Actions(
kubetest.ClientLogsContain(
client,
command,
[]string{`< x-remote-user: system:serviceaccount:default:default`},
nil,
),
),
}.Run(t)

kubetest.Scenario{
Name: "With http on no loopback",
Description: `
Verifies that the proxy is not able to connect to the remote upstream service,
if upstream isn't offering TLS, when identity headers are being used.
`,

Given: kubetest.Actions(
kubetest.CreatedManifests(
client,
"identityheaders/insecure/clusterRole-client.yaml",
"identityheaders/insecure/clusterRole.yaml",
"identityheaders/insecure/clusterRoleBinding-client.yaml",
"identityheaders/insecure/clusterRoleBinding.yaml",
"identityheaders/insecure/deployment-proxy.yaml",
"identityheaders/insecure/deployment-upstream.yaml",
"identityheaders/insecure/service.yaml",
"identityheaders/insecure/serviceAccount.yaml",
),
),
Then: kubetest.Actions(
kubetest.PodIsCrashLoopBackOff(
client,
"kube-rbac-proxy",
),
),
}.Run(t)

kubetest.Scenario{
Name: "With https on no loopback",
Description: `
Verifies that the proxy is able to connect to the remote upstream service,
through a mTLS connection, when providing identity headers.
`,

Given: kubetest.Actions(
kubetest.CreateServerCerts(client, "nginx"),
kubetest.CreateClientCerts(client, "kube-rbac-proxy-client"),
kubetest.CreateServerCerts(client, "kube-rbac-proxy"),
kubetest.CreatedManifests(
client,
"identityheaders/secure/clusterRole-client.yaml",
"identityheaders/secure/clusterRole.yaml",
"identityheaders/secure/clusterRoleBinding-client.yaml",
"identityheaders/secure/clusterRoleBinding.yaml",
"identityheaders/secure/configmap-nginx.yaml",
"identityheaders/secure/deployment-proxy.yaml",
"identityheaders/secure/deployment-upstream.yaml",
"identityheaders/secure/service-proxy.yaml",
"identityheaders/secure/service-upstream.yaml",
"identityheaders/secure/serviceAccount.yaml",
),
),
Then: kubetest.Actions(
kubetest.ClientSucceeds(
client,
command,
nil,
),
),
}.Run(t)
}
}
Loading

0 comments on commit 3499d17

Please sign in to comment.