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 Apr 5, 2024
1 parent 3c70f05 commit 204148c
Show file tree
Hide file tree
Showing 37 changed files with 1,108 additions and 12 deletions.
75 changes: 75 additions & 0 deletions cmd/kube-rbac-proxy/app/options/proxyoptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ limitations under the License.
package options

import (
"context"
"fmt"
"net"
"net/url"
"os"
"path"
"time"

"github.com/ghodss/yaml"
"github.com/spf13/pflag"
Expand All @@ -34,6 +37,8 @@ import (
"github.com/brancz/kube-rbac-proxy/pkg/server"
)

const loopbackLookupTimeout = 5

// ProxyOptions are options specific to the kube-rbac-proxy
type ProxyOptions struct {
Upstream string
Expand Down Expand Up @@ -103,6 +108,11 @@ func (o *ProxyOptions) Validate() []error {
}
}

// Verify secure connection settings, if necessary.
if err := validateSecureConnectionConfig(o); err != nil {
errs = append(errs, err)
}

return errs
}

Expand All @@ -125,13 +135,78 @@ 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 validateSecureConnectionConfig(o *ProxyOptions) error {
if !identityheaders.HasIdentityHeadersEnabled(o.UpstreamHeader) && !o.UpstreamForceH2C {
return nil
}

errLoopback := validateLoopbackAddress(o.Upstream)
if errLoopback == nil {
return nil
}
if o.UpstreamForceH2C {
return fmt.Errorf("loopback address is required for h2c: %w", errLoopback)
}

klog.V(4).Info("Failed to validate loopback address: %v", errLoopback)

u, err := url.Parse(o.Upstream)
if err != nil {
return fmt.Errorf("failed to parse upstream URL: %w", err)
}

// If Identity Headers are configured and it is not a loopback address,
// verify that mTLS is configured.
if len(o.UpstreamClientCertFile) == 0 || len(o.UpstreamClientKeyFile) == 0 || u.Scheme != "https" {
return fmt.Errorf(
"loopback address (currently configured: %q) or client cert/key are required for identity headers",
o.Upstream,
)
}

return nil
}

func validateLoopbackAddress(address string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(loopbackLookupTimeout)*time.Second)
defer cancel()

u, err := url.Parse(address)
if err != nil {
return fmt.Errorf("failed to parse upstream URL: %w", err)
}

ip := net.ParseIP(u.Hostname())
if ip != nil {
if !ip.IsLoopback() {
return fmt.Errorf("not a loopback address: %s", ip.String())
}

return nil
}

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

for _, ip := range ips {
if !ip.IP.IsLoopback() {
return fmt.Errorf("not a loopback address: %s", ip.IP.String())
}
}

return 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
133 changes: 133 additions & 0 deletions test/e2e/identityheaders.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
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-proxy.yaml",
"identityheaders/insecure/service-upstream.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)
}
}
7 changes: 7 additions & 0 deletions test/e2e/identityheaders/default/clusterRole-client.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: metrics
rules:
- nonResourceURLs: ["/metrics"]
verbs: ["get"]
14 changes: 14 additions & 0 deletions test/e2e/identityheaders/default/clusterRole.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kube-rbac-proxy
namespace: default
rules:
- apiGroups: ["authentication.k8s.io"]
resources:
- tokenreviews
verbs: ["create"]
- apiGroups: ["authorization.k8s.io"]
resources:
- subjectaccessreviews
verbs: ["create"]
Loading

0 comments on commit 204148c

Please sign in to comment.