1
+// Copyright 2015 Matthew Holt and The Caddy Authors
2
+//
3
+// Licensed under the Apache License, Version 2.0 (the "License");
4
+// you may not use this file except in compliance with the License.
5
+// You may obtain a copy of the License at
6
+//
7
+// http://www.apache.org/licenses/LICENSE-2.0
8
+//
9
+// Unless required by applicable law or agreed to in writing, software
10
+// distributed under the License is distributed on an "AS IS" BASIS,
11
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+// See the License for the specific language governing permissions and
13
+// limitations under the License.
14
+
15
+package integration
16
+
17
+import (
18
+ "fmt"
19
+ "net/http"
20
+ "net/http/httptest"
21
+ "strings"
22
+ "sync"
23
+ "testing"
24
+
25
+ "github.com/caddyserver/caddy/v2/caddytest"
26
+)
27
+
28
+// TestForwardAuthCopyHeadersStripsClientHeaders is a regression test for the
29
+// header injection vulnerability in forward_auth copy_headers.
30
+//
31
+// When the auth service returns 200 OK without one of the copy_headers headers,
32
+// the MatchNot guard skips the Set operation. Before this fix, the original
33
+// client-supplied header survived unchanged into the backend request, allowing
34
+// privilege escalation with only a valid (non-privileged) bearer token. After
35
+// the fix, an unconditional delete route runs first, so the backend always
36
+// sees an absent header rather than the attacker-supplied value.
37
+func TestForwardAuthCopyHeadersStripsClientHeaders(t *testing.T) {
38
+ // Mock auth service: accepts any Bearer token, returns 200 OK with NO
39
+ // identity headers. This is the stateless JWT validator pattern that
40
+ // triggers the vulnerability.
41
+ authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42
+ if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
43
+ w.WriteHeader(http.StatusOK)
44
+ return
45
+ }
46
+ w.WriteHeader(http.StatusUnauthorized)
47
+ }))
48
+ defer authSrv.Close()
49
+
50
+ // Mock backend: records the identity headers it receives. A real application
51
+ // would use X-User-Id / X-User-Role to make authorization decisions.
52
+ type received struct{ userID, userRole string }
53
+ var (
54
+ mu sync.Mutex
55
+ last received
56
+ )
57
+ backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58
+ mu.Lock()
59
+ last = received{
60
+ userID: r.Header.Get("X-User-Id"),
61
+ userRole: r.Header.Get("X-User-Role"),
62
+ }
63
+ mu.Unlock()
64
+ w.WriteHeader(http.StatusOK)
65
+ fmt.Fprint(w, "ok")
66
+ }))
67
+ defer backendSrv.Close()
68
+
69
+ authAddr := strings.TrimPrefix(authSrv.URL, "http://")
70
+ backendAddr := strings.TrimPrefix(backendSrv.URL, "http://")
71
+
72
+ tester := caddytest.NewTester(t)
73
+ tester.InitServer(fmt.Sprintf(`
74
+ {
75
+ skip_install_trust
76
+ admin localhost:2999
77
+ http_port 9080
78
+ https_port 9443
79
+ grace_period 1ns
80
+ }
81
+ http://localhost:9080 {
82
+ forward_auth %s {
83
+ uri /
84
+ copy_headers X-User-Id X-User-Role
85
+ }
86
+ reverse_proxy %s
87
+ }
88
+ `, authAddr, backendAddr), "caddyfile")
89
+
90
+ // Case 1: no token. Auth must still reject the request even when the client
91
+ // includes identity headers. This confirms the auth check is not bypassed.
92
+ req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
93
+ req.Header.Set("X-User-Id", "injected")
94
+ req.Header.Set("X-User-Role", "injected")
95
+ resp := tester.AssertResponseCode(req, http.StatusUnauthorized)
96
+ resp.Body.Close()
97
+
98
+ // Case 2: valid token, no injected headers. The backend should see absent
99
+ // identity headers (the auth service never returns them).
100
+ req, _ = http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
101
+ req.Header.Set("Authorization", "Bearer token123")
102
+ tester.AssertResponse(req, http.StatusOK, "ok")
103
+ mu.Lock()
104
+ gotID, gotRole := last.userID, last.userRole
105
+ mu.Unlock()
106
+ if gotID != "" {
107
+ t.Errorf("baseline: X-User-Id should be absent, got %q", gotID)
108
+ }
109
+ if gotRole != "" {
110
+ t.Errorf("baseline: X-User-Role should be absent, got %q", gotRole)
111
+ }
112
+
113
+ // Case 3 (the security regression): valid token plus forged identity headers.
114
+ // The fix must strip those values so the backend never sees them.
115
+ req, _ = http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
116
+ req.Header.Set("Authorization", "Bearer token123")
117
+ req.Header.Set("X-User-Id", "admin") // forged
118
+ req.Header.Set("X-User-Role", "superadmin") // forged
119
+ tester.AssertResponse(req, http.StatusOK, "ok")
120
+ mu.Lock()
121
+ gotID, gotRole = last.userID, last.userRole
122
+ mu.Unlock()
123
+ if gotID != "" {
124
+ t.Errorf("injection: X-User-Id must be stripped, got %q", gotID)
125
+ }
126
+ if gotRole != "" {
127
+ t.Errorf("injection: X-User-Role must be stripped, got %q", gotRole)
128
+ }
129
+}
130
+
131
+// TestForwardAuthCopyHeadersAuthResponseWins verifies that when the auth
132
+// service does include a copy_headers header in its response, that value
133
+// is forwarded to the backend and takes precedence over any client-supplied
134
+// value for the same header.
135
+func TestForwardAuthCopyHeadersAuthResponseWins(t *testing.T) {
136
+ const wantUserID = "service-user-42"
137
+ const wantUserRole = "editor"
138
+
139
+ // Auth service: accepts bearer token and sets identity headers.
140
+ authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
141
+ if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
142
+ w.Header().Set("X-User-Id", wantUserID)
143
+ w.Header().Set("X-User-Role", wantUserRole)
144
+ w.WriteHeader(http.StatusOK)
145
+ return
146
+ }
147
+ w.WriteHeader(http.StatusUnauthorized)
148
+ }))
149
+ defer authSrv.Close()
150
+
151
+ type received struct{ userID, userRole string }
152
+ var (
153
+ mu sync.Mutex
154
+ last received
155
+ )
156
+ backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
157
+ mu.Lock()
158
+ last = received{
159
+ userID: r.Header.Get("X-User-Id"),
160
+ userRole: r.Header.Get("X-User-Role"),
161
+ }
162
+ mu.Unlock()
163
+ w.WriteHeader(http.StatusOK)
164
+ fmt.Fprint(w, "ok")
165
+ }))
166
+ defer backendSrv.Close()
167
+
168
+ authAddr := strings.TrimPrefix(authSrv.URL, "http://")
169
+ backendAddr := strings.TrimPrefix(backendSrv.URL, "http://")
170
+
171
+ tester := caddytest.NewTester(t)
172
+ tester.InitServer(fmt.Sprintf(`
173
+ {
174
+ skip_install_trust
175
+ admin localhost:2999
176
+ http_port 9080
177
+ https_port 9443
178
+ grace_period 1ns
179
+ }
180
+ http://localhost:9080 {
181
+ forward_auth %s {
182
+ uri /
183
+ copy_headers X-User-Id X-User-Role
184
+ }
185
+ reverse_proxy %s
186
+ }
187
+ `, authAddr, backendAddr), "caddyfile")
188
+
189
+ // The client sends forged headers; the auth service overrides them with
190
+ // its own values. The backend must receive the auth service values.
191
+ req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
192
+ req.Header.Set("Authorization", "Bearer token123")
193
+ req.Header.Set("X-User-Id", "forged-id") // must be overwritten
194
+ req.Header.Set("X-User-Role", "forged-role") // must be overwritten
195
+ tester.AssertResponse(req, http.StatusOK, "ok")
196
+
197
+ mu.Lock()
198
+ gotID, gotRole := last.userID, last.userRole
199
+ mu.Unlock()
200
+ if gotID != wantUserID {
201
+ t.Errorf("X-User-Id: want %q, got %q", wantUserID, gotID)
202
+ }
203
+ if gotRole != wantUserRole {
204
+ t.Errorf("X-User-Role: want %q, got %q", wantUserRole, gotRole)
205
+ }
206
+}