Skip to content

Commit 887cc77

Browse files
authored
Merge commit from fork
1 parent 857068a commit 887cc77

7 files changed

Lines changed: 741 additions & 39 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
Copyright 2026 The kcp Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package authheaders contains helpers for stamping the request-header
18+
// identity headers (X-Remote-User / X-Remote-Group / X-Remote-Extra-*) that
19+
// kcp proxies use to forward an authenticated identity to a backend shard or
20+
// virtual workspace. The backend trusts these headers verbatim over the
21+
// proxy's mutually-authenticated connection, so the stamping must always strip
22+
// any client-supplied copies first.
23+
package authheaders
24+
25+
import (
26+
"net/http"
27+
"net/url"
28+
"strings"
29+
30+
userinfo "k8s.io/apiserver/pkg/authentication/user"
31+
)
32+
33+
// SetAuthHeaders stamps the given authenticated user's identity onto the request
34+
// headers, after deleting any inbound copies of the identity headers.
35+
//
36+
// This mirrors k8s.io/client-go/transport.SetAuthProxyHeaders and the upstream
37+
// requestheader authenticator's ClearAuthenticationHeaders.
38+
func SetAuthHeaders(header http.Header, user userinfo.Info, userHeader, groupHeader, extraHeaderPrefix string) {
39+
header.Del(userHeader)
40+
header.Del(groupHeader)
41+
for key := range header {
42+
if strings.HasPrefix(strings.ToLower(key), strings.ToLower(extraHeaderPrefix)) {
43+
header.Del(key)
44+
}
45+
}
46+
47+
header.Set(userHeader, user.GetName())
48+
49+
for _, group := range user.GetGroups() {
50+
header.Add(groupHeader, group)
51+
}
52+
53+
for k, values := range user.GetExtra() {
54+
// Key must be encoded to enable e.g authentication.kcp.io/cluster-name
55+
// This is decoded in the RequestHeader auth handler.
56+
encodedKey := url.PathEscape(k)
57+
for _, v := range values {
58+
header.Add(extraHeaderPrefix+encodedKey, v)
59+
}
60+
}
61+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
Copyright 2026 The kcp Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package authheaders
18+
19+
import (
20+
"net/http"
21+
"reflect"
22+
"testing"
23+
24+
userinfo "k8s.io/apiserver/pkg/authentication/user"
25+
)
26+
27+
const (
28+
userHeader = "X-Remote-User"
29+
groupHeader = "X-Remote-Group"
30+
extraPrefix = "X-Remote-Extra-"
31+
32+
warrantHeader = "X-Remote-Extra-Authorization.kcp.io%2fwarrant"
33+
scopesHeader = "X-Remote-Extra-Authentication.kcp.io%2fscopes"
34+
)
35+
36+
// TestSetAuthHeaders_StripsForgedIdentityHeaders is the regression test for the
37+
// identity-header injection vulnerability: client-supplied X-Remote-* headers
38+
// must be removed before the authenticated identity is stamped, so a forged
39+
// value can never be forwarded to a backend that trusts request-header auth.
40+
func TestSetAuthHeaders_StripsForgedIdentityHeaders(t *testing.T) {
41+
t.Parallel()
42+
forgedWarrant := `{"user":"attacker","groups":["system:masters"]}`
43+
44+
tests := []struct {
45+
name string
46+
user userinfo.Info
47+
inbound http.Header
48+
wantUser []string
49+
wantGroups []string
50+
forbiddenHeaders []string
51+
}{
52+
{
53+
name: "forged group dropped, real identity stamped",
54+
user: &userinfo.DefaultInfo{Name: "alice", Groups: []string{"system:authenticated"}},
55+
inbound: http.Header{
56+
groupHeader: {"system:masters"},
57+
},
58+
wantUser: []string{"alice"},
59+
wantGroups: []string{"system:authenticated"},
60+
},
61+
{
62+
name: "forged user overwritten, forged warrant/scopes dropped (no user header sent)",
63+
user: &userinfo.DefaultInfo{Name: "alice", Groups: []string{"system:authenticated"}},
64+
inbound: http.Header{
65+
groupHeader: {"system:masters", "org:required"},
66+
warrantHeader: {forgedWarrant},
67+
scopesHeader: {"cluster:root"},
68+
},
69+
wantUser: []string{"alice"},
70+
wantGroups: []string{"system:authenticated"},
71+
forbiddenHeaders: []string{warrantHeader, scopesHeader},
72+
},
73+
{
74+
name: "forged user header is overwritten with the real identity",
75+
user: &userinfo.DefaultInfo{Name: "alice", Groups: []string{"team:a"}},
76+
inbound: http.Header{
77+
userHeader: {"system:admin"},
78+
groupHeader: {"system:masters"},
79+
},
80+
wantUser: []string{"alice"},
81+
wantGroups: []string{"team:a"},
82+
},
83+
{
84+
name: "real extras are stamped (encoded), forged extras dropped",
85+
user: &userinfo.DefaultInfo{
86+
Name: "alice",
87+
Groups: []string{"system:authenticated"},
88+
Extra: map[string][]string{"authentication.kcp.io/cluster-name": {"root:org:ws"}},
89+
},
90+
inbound: http.Header{
91+
warrantHeader: {forgedWarrant},
92+
},
93+
wantUser: []string{"alice"},
94+
wantGroups: []string{"system:authenticated"},
95+
forbiddenHeaders: []string{warrantHeader},
96+
},
97+
}
98+
99+
for _, tc := range tests {
100+
t.Run(tc.name, func(t *testing.T) {
101+
t.Parallel()
102+
h := tc.inbound.Clone()
103+
if h == nil {
104+
h = http.Header{}
105+
}
106+
107+
SetAuthHeaders(h, tc.user, userHeader, groupHeader, extraPrefix)
108+
109+
if got := h.Values(userHeader); !reflect.DeepEqual(got, tc.wantUser) {
110+
t.Errorf("user header = %v, want %v", got, tc.wantUser)
111+
}
112+
if got := h.Values(groupHeader); !reflect.DeepEqual(got, tc.wantGroups) {
113+
t.Errorf("group header = %v, want %v", got, tc.wantGroups)
114+
}
115+
for _, g := range h.Values(groupHeader) {
116+
if g == "system:masters" {
117+
t.Errorf("forged group system:masters leaked: %v", h.Values(groupHeader))
118+
}
119+
}
120+
for _, name := range tc.forbiddenHeaders {
121+
if v := h.Values(name); len(v) != 0 {
122+
t.Errorf("forged header %q leaked: %v", name, v)
123+
}
124+
}
125+
})
126+
}
127+
}
128+
129+
// TestSetAuthHeaders_RealExtraIsEncodedAndForwarded confirms the legitimate
130+
// extras round-trip through the PathEscape encoding the request-header
131+
// authenticator expects.
132+
func TestSetAuthHeaders_RealExtraIsEncodedAndForwarded(t *testing.T) {
133+
t.Parallel()
134+
h := http.Header{}
135+
SetAuthHeaders(h, &userinfo.DefaultInfo{
136+
Name: "alice",
137+
Extra: map[string][]string{"authentication.kcp.io/cluster-name": {"root:org:ws"}},
138+
}, userHeader, groupHeader, extraPrefix)
139+
140+
const encoded = "X-Remote-Extra-Authentication.kcp.io%2Fcluster-name"
141+
if got := h.Values(encoded); !reflect.DeepEqual(got, []string{"root:org:ws"}) {
142+
t.Errorf("encoded extra header %q = %v, want [root:org:ws]; full headers=%v", encoded, got, h)
143+
}
144+
}

pkg/proxy/proxy.go

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,12 @@ import (
2222
"fmt"
2323
"net/http"
2424
"net/http/httputil"
25-
"net/url"
2625
"os"
2726

2827
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
29-
userinfo "k8s.io/apiserver/pkg/authentication/user"
3028
"k8s.io/apiserver/pkg/endpoints/request"
3129

30+
"github.com/kcp-dev/kcp/pkg/proxy/authheaders"
3231
"github.com/kcp-dev/kcp/pkg/proxy/lookup"
3332
)
3433

@@ -60,30 +59,13 @@ func newTransport(clientCert, clientKeyFile, caFile string) (*http.Transport, er
6059
func WithProxyAuthHeaders(delegate http.Handler, userHeader, groupHeader string, extraHeaderPrefix string) http.HandlerFunc {
6160
return func(w http.ResponseWriter, r *http.Request) {
6261
if u, ok := request.UserFrom(r.Context()); ok {
63-
appendClientCertAuthHeaders(r.Header, u, userHeader, groupHeader, extraHeaderPrefix)
62+
authheaders.SetAuthHeaders(r.Header, u, userHeader, groupHeader, extraHeaderPrefix)
6463
}
6564

6665
delegate.ServeHTTP(w, r)
6766
}
6867
}
6968

70-
func appendClientCertAuthHeaders(header http.Header, user userinfo.Info, userHeader, groupHeader, extraHeaderPrefix string) {
71-
header.Set(userHeader, user.GetName())
72-
73-
for _, group := range user.GetGroups() {
74-
header.Add(groupHeader, group)
75-
}
76-
77-
for k, values := range user.GetExtra() {
78-
// Key must be encoded to enable e.g authentication.kcp.io/cluster-name
79-
// This is decoded in the RequestHeader auth handler
80-
encodedKey := url.PathEscape(k)
81-
for _, v := range values {
82-
header.Add(extraHeaderPrefix+encodedKey, v)
83-
}
84-
}
85-
}
86-
8769
func newShardReverseProxy() *httputil.ReverseProxy {
8870
director := func(req *http.Request) {
8971
shardURL := lookup.ShardURLFrom(req.Context())

0 commit comments

Comments
 (0)