@marcel91200 / caddy Mirror · read-only / commits / 2dbcdefb
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 14:45 UTC

forward_auth: `copy_headers` does not strip client-supplied identity headers (Fixes GHSA-7r4p-vjf4-gxv4) (#7545)

When using copy_headers in a forward_auth block, client-supplied headers with the same names were not being removed before being forwarded to the backend. This happens because PR #6608 added a MatchNot guard that skips the Set operation when the auth service does not return a given header. That guard prevents setting headers to empty strings, which is the correct behavior, but it also means a client can send X-User-Id: admin in their request and if the auth service validates the token without returning X-User-Id, Caddy skips the Set and the client value passes through unchanged to the backend. The fix adds an unconditional delete route for each copy_headers entry, placed just before the existing conditional set route. The delete always runs regardless of what the auth service returns. The conditional set still only runs when the auth service provides that header. The end result is: - Client-supplied headers are always removed - When the auth service returns the header, the backend gets that value - When the auth service does not return the header, the backend sees nothing Existing behavior is unchanged for any deployment where the auth service returns all of the configured copy_headers entries. Fixes GHSA-7r4p-vjf4-gxv4

newklei committed Mar 3, 2026 at 23:30 UTC 2dbcdefbbee68e7b4a31ac66361a0f4e3bcd2eea
5 files changed +480 -2
caddytest/integration/caddyfile_adapt/forward_auth_authelia.caddyfiletest
+49 -1
@@ -46,6 +46,18 @@ app.example.com {
46 }
47 ]
48 },
49 + {
50 + "handle": [
51 + {
52 + "handler": "headers",
53 + "request": {
54 + "delete": [
55 + "Remote-Email"
56 + ]
57 + }
58 + }
59 + ]
60 + },
61 {
62 "handle": [
63 {
@@ -73,6 +85,18 @@ app.example.com {
85 }
86 ]
87 },
88 + {
89 + "handle": [
90 + {
91 + "handler": "headers",
92 + "request": {
93 + "delete": [
94 + "Remote-Groups"
95 + ]
96 + }
97 + }
98 + ]
99 + },
100 {
101 "handle": [
102 {
@@ -100,6 +124,18 @@ app.example.com {
124 }
125 ]
126 },
127 + {
128 + "handle": [
129 + {
130 + "handler": "headers",
131 + "request": {
132 + "delete": [
133 + "Remote-Name"
134 + ]
135 + }
136 + }
137 + ]
138 + },
139 {
140 "handle": [
141 {
@@ -127,6 +163,18 @@ app.example.com {
163 }
164 ]
165 },
166 + {
167 + "handle": [
168 + {
169 + "handler": "headers",
170 + "request": {
171 + "delete": [
172 + "Remote-User"
173 + ]
174 + }
175 + }
176 + ]
177 + },
178 {
179 "handle": [
180 {
@@ -200,4 +248,4 @@ app.example.com {
248 }
249 }
250 }
203 -}
\ No newline at end of file
251 +}
caddytest/integration/caddyfile_adapt/forward_auth_copy_headers_strip.caddyfiletest new
+146
@@ -0,0 +1,146 @@
1 +:8080
2 +
3 +forward_auth 127.0.0.1:9091 {
4 + uri /
5 + copy_headers X-User-Id X-User-Role
6 +}
7 +----------
8 +{
9 + "apps": {
10 + "http": {
11 + "servers": {
12 + "srv0": {
13 + "listen": [
14 + ":8080"
15 + ],
16 + "routes": [
17 + {
18 + "handle": [
19 + {
20 + "handle_response": [
21 + {
22 + "match": {
23 + "status_code": [
24 + 2
25 + ]
26 + },
27 + "routes": [
28 + {
29 + "handle": [
30 + {
31 + "handler": "vars"
32 + }
33 + ]
34 + },
35 + {
36 + "handle": [
37 + {
38 + "handler": "headers",
39 + "request": {
40 + "delete": [
41 + "X-User-Id"
42 + ]
43 + }
44 + }
45 + ]
46 + },
47 + {
48 + "handle": [
49 + {
50 + "handler": "headers",
51 + "request": {
52 + "set": {
53 + "X-User-Id": [
54 + "{http.reverse_proxy.header.X-User-Id}"
55 + ]
56 + }
57 + }
58 + }
59 + ],
60 + "match": [
61 + {
62 + "not": [
63 + {
64 + "vars": {
65 + "{http.reverse_proxy.header.X-User-Id}": [
66 + ""
67 + ]
68 + }
69 + }
70 + ]
71 + }
72 + ]
73 + },
74 + {
75 + "handle": [
76 + {
77 + "handler": "headers",
78 + "request": {
79 + "delete": [
80 + "X-User-Role"
81 + ]
82 + }
83 + }
84 + ]
85 + },
86 + {
87 + "handle": [
88 + {
89 + "handler": "headers",
90 + "request": {
91 + "set": {
92 + "X-User-Role": [
93 + "{http.reverse_proxy.header.X-User-Role}"
94 + ]
95 + }
96 + }
97 + }
98 + ],
99 + "match": [
100 + {
101 + "not": [
102 + {
103 + "vars": {
104 + "{http.reverse_proxy.header.X-User-Role}": [
105 + ""
106 + ]
107 + }
108 + }
109 + ]
110 + }
111 + ]
112 + }
113 + ]
114 + }
115 + ],
116 + "handler": "reverse_proxy",
117 + "headers": {
118 + "request": {
119 + "set": {
120 + "X-Forwarded-Method": [
121 + "{http.request.method}"
122 + ],
123 + "X-Forwarded-Uri": [
124 + "{http.request.uri}"
125 + ]
126 + }
127 + }
128 + },
129 + "rewrite": {
130 + "method": "GET",
131 + "uri": "/"
132 + },
133 + "upstreams": [
134 + {
135 + "dial": "127.0.0.1:9091"
136 + }
137 + ]
138 + }
139 + ]
140 + }
141 + ]
142 + }
143 + }
144 + }
145 + }
146 +}
\ No newline at end of file
caddytest/integration/caddyfile_adapt/forward_auth_rename_headers.caddyfiletest
+61 -1
@@ -35,6 +35,18 @@ forward_auth localhost:9000 {
35 }
36 ]
37 },
38 + {
39 + "handle": [
40 + {
41 + "handler": "headers",
42 + "request": {
43 + "delete": [
44 + "1"
45 + ]
46 + }
47 + }
48 + ]
49 + },
50 {
51 "handle": [
52 {
@@ -62,6 +74,18 @@ forward_auth localhost:9000 {
74 }
75 ]
76 },
77 + {
78 + "handle": [
79 + {
80 + "handler": "headers",
81 + "request": {
82 + "delete": [
83 + "B"
84 + ]
85 + }
86 + }
87 + ]
88 + },
89 {
90 "handle": [
91 {
@@ -89,6 +113,18 @@ forward_auth localhost:9000 {
113 }
114 ]
115 },
116 + {
117 + "handle": [
118 + {
119 + "handler": "headers",
120 + "request": {
121 + "delete": [
122 + "3"
123 + ]
124 + }
125 + }
126 + ]
127 + },
128 {
129 "handle": [
130 {
@@ -116,6 +152,18 @@ forward_auth localhost:9000 {
152 }
153 ]
154 },
155 + {
156 + "handle": [
157 + {
158 + "handler": "headers",
159 + "request": {
160 + "delete": [
161 + "D"
162 + ]
163 + }
164 + }
165 + ]
166 + },
167 {
168 "handle": [
169 {
@@ -143,6 +191,18 @@ forward_auth localhost:9000 {
191 }
192 ]
193 },
194 + {
195 + "handle": [
196 + {
197 + "handler": "headers",
198 + "request": {
199 + "delete": [
200 + "5"
201 + ]
202 + }
203 + }
204 + ]
205 + },
206 {
207 "handle": [
208 {
@@ -203,4 +263,4 @@ forward_auth localhost:9000 {
263 }
264 }
265 }
206 -}
\ No newline at end of file
266 +}
caddytest/integration/forwardauth_test.go new
+206
@@ -0,0 +1,206 @@
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 +}
modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go
+18
@@ -208,6 +208,24 @@ func parseCaddyfile(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
208 for _, from := range sortedHeadersToCopy {
209 to := http.CanonicalHeaderKey(headersToCopy[from])
210 placeholderName := "http.reverse_proxy.header." + http.CanonicalHeaderKey(from)
211 +
212 + // Always delete the client-supplied header before conditionally setting
213 + // it from the auth response. Without this, a client that pre-supplies a
214 + // header listed in copy_headers can inject arbitrary values when the auth
215 + // service does not return that header: the MatchNot guard below would
216 + // skip the Set entirely, leaving the original client-controlled value
217 + // intact and forwarding it to the backend.
218 + copyHeaderRoutes = append(copyHeaderRoutes, caddyhttp.Route{
219 + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(
220 + &headers.Handler{
221 + Request: &headers.HeaderOps{
222 + Delete: []string{to},
223 + },
224 + },
225 + "handler", "headers", nil,
226 + )},
227 + })
228 +
229 handler := &headers.Handler{
230 Request: &headers.HeaderOps{
231 Set: http.Header{