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, 01:00 UTC
| 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/stretchr/testify/assert" |
| 26 | |
| 27 | "github.com/caddyserver/caddy/v2/caddytest" |
| 28 | ) |
| 29 | |
| 30 | // TestForwardAuthCopyHeadersStripsClientHeaders is a regression test for the |
| 31 | // header injection vulnerability in forward_auth copy_headers. |
| 32 | // |
| 33 | // When the auth service returns 200 OK without one of the copy_headers headers, |
| 34 | // the MatchNot guard skips the Set operation. Before this fix, the original |
| 35 | // client-supplied header survived unchanged into the backend request, allowing |
| 36 | // privilege escalation with only a valid (non-privileged) bearer token. After |
| 37 | // the fix, an unconditional delete route runs first, so the backend always |
| 38 | // sees an absent header rather than the attacker-supplied value. |
| 39 | func TestForwardAuthCopyHeadersStripsClientHeaders(t *testing.T) { |
| 40 | // Mock auth service: accepts any Bearer token, returns 200 OK with NO |
| 41 | // identity headers. This is the stateless JWT validator pattern that |
| 42 | // triggers the vulnerability. |
| 43 | authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 44 | if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { |
| 45 | w.WriteHeader(http.StatusOK) |
| 46 | return |
| 47 | } |
| 48 | w.WriteHeader(http.StatusUnauthorized) |
| 49 | })) |
| 50 | defer authSrv.Close() |
| 51 | |
| 52 | // Mock backend: records the identity headers it receives. A real application |
| 53 | // would use X-User-Id / X-User-Role to make authorization decisions. |
| 54 | type received struct{ userID, userRole string } |
| 55 | var ( |
| 56 | mu sync.Mutex |
| 57 | last received |
| 58 | ) |
| 59 | backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 60 | mu.Lock() |
| 61 | last = received{ |
| 62 | userID: r.Header.Get("X-User-Id"), |
| 63 | userRole: r.Header.Get("X-User-Role"), |
| 64 | } |
| 65 | mu.Unlock() |
| 66 | w.WriteHeader(http.StatusOK) |
| 67 | fmt.Fprint(w, "ok") |
| 68 | })) |
| 69 | defer backendSrv.Close() |
| 70 | |
| 71 | authAddr := strings.TrimPrefix(authSrv.URL, "http://") |
| 72 | backendAddr := strings.TrimPrefix(backendSrv.URL, "http://") |
| 73 | |
| 74 | tester := caddytest.NewTester(t) |
| 75 | tester.InitServer(fmt.Sprintf(` |
| 76 | { |
| 77 | skip_install_trust |
| 78 | admin localhost:2999 |
| 79 | http_port 9080 |
| 80 | https_port 9443 |
| 81 | grace_period 1ns |
| 82 | } |
| 83 | http://localhost:9080 { |
| 84 | forward_auth %s { |
| 85 | uri / |
| 86 | copy_headers X-User-Id X-User-Role |
| 87 | } |
| 88 | reverse_proxy %s |
| 89 | } |
| 90 | `, authAddr, backendAddr), "caddyfile") |
| 91 | |
| 92 | // Case 1: no token. Auth must still reject the request even when the client |
| 93 | // includes identity headers. This confirms the auth check is not bypassed. |
| 94 | req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil) |
| 95 | req.Header.Set("X-User-Id", "injected") |
| 96 | req.Header.Set("X-User-Role", "injected") |
| 97 | resp := tester.AssertResponseCode(req, http.StatusUnauthorized) |
| 98 | resp.Body.Close() |
| 99 | |
| 100 | // Case 2: valid token, no injected headers. The backend should see absent |
| 101 | // identity headers (the auth service never returns them). |
| 102 | req, _ = http.NewRequest(http.MethodGet, "http://localhost:9080/", nil) |
| 103 | req.Header.Set("Authorization", "Bearer token123") |
| 104 | tester.AssertResponse(req, http.StatusOK, "ok") |
| 105 | mu.Lock() |
| 106 | gotID, gotRole := last.userID, last.userRole |
| 107 | mu.Unlock() |
| 108 | if gotID != "" { |
| 109 | t.Errorf("baseline: X-User-Id should be absent, got %q", gotID) |
| 110 | } |
| 111 | if gotRole != "" { |
| 112 | t.Errorf("baseline: X-User-Role should be absent, got %q", gotRole) |
| 113 | } |
| 114 | |
| 115 | // Case 3 (the security regression): valid token plus forged identity headers. |
| 116 | // The fix must strip those values so the backend never sees them. |
| 117 | req, _ = http.NewRequest(http.MethodGet, "http://localhost:9080/", nil) |
| 118 | req.Header.Set("Authorization", "Bearer token123") |
| 119 | req.Header.Set("X-User-Id", "admin") // forged |
| 120 | req.Header.Set("X-User-Role", "superadmin") // forged |
| 121 | tester.AssertResponse(req, http.StatusOK, "ok") |
| 122 | mu.Lock() |
| 123 | gotID, gotRole = last.userID, last.userRole |
| 124 | mu.Unlock() |
| 125 | if gotID != "" { |
| 126 | t.Errorf("injection: X-User-Id must be stripped, got %q", gotID) |
| 127 | } |
| 128 | if gotRole != "" { |
| 129 | t.Errorf("injection: X-User-Role must be stripped, got %q", gotRole) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | // TestForwardAuthCopyHeadersAuthResponseWins verifies that when the auth |
| 134 | // service does include a copy_headers header in its response, that value |
| 135 | // is forwarded to the backend and takes precedence over any client-supplied |
| 136 | // value for the same header. |
| 137 | func TestForwardAuthCopyHeadersAuthResponseWins(t *testing.T) { |
| 138 | const wantUserID = "service-user-42" |
| 139 | const wantUserRole = "editor" |
| 140 | |
| 141 | // Auth service: accepts bearer token and sets identity headers. |
| 142 | authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 143 | if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { |
| 144 | w.Header().Set("X-User-Id", wantUserID) |
| 145 | w.Header().Set("X-User-Role", wantUserRole) |
| 146 | w.WriteHeader(http.StatusOK) |
| 147 | return |
| 148 | } |
| 149 | w.WriteHeader(http.StatusUnauthorized) |
| 150 | })) |
| 151 | defer authSrv.Close() |
| 152 | |
| 153 | type received struct{ userID, userRole string } |
| 154 | var ( |
| 155 | mu sync.Mutex |
| 156 | last received |
| 157 | ) |
| 158 | backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 159 | mu.Lock() |
| 160 | last = received{ |
| 161 | userID: r.Header.Get("X-User-Id"), |
| 162 | userRole: r.Header.Get("X-User-Role"), |
| 163 | } |
| 164 | mu.Unlock() |
| 165 | w.WriteHeader(http.StatusOK) |
| 166 | fmt.Fprint(w, "ok") |
| 167 | })) |
| 168 | defer backendSrv.Close() |
| 169 | |
| 170 | authAddr := strings.TrimPrefix(authSrv.URL, "http://") |
| 171 | backendAddr := strings.TrimPrefix(backendSrv.URL, "http://") |
| 172 | |
| 173 | tester := caddytest.NewTester(t) |
| 174 | tester.InitServer(fmt.Sprintf(` |
| 175 | { |
| 176 | skip_install_trust |
| 177 | admin localhost:2999 |
| 178 | http_port 9080 |
| 179 | https_port 9443 |
| 180 | grace_period 1ns |
| 181 | } |
| 182 | http://localhost:9080 { |
| 183 | forward_auth %s { |
| 184 | uri / |
| 185 | copy_headers X-User-Id X-User-Role |
| 186 | } |
| 187 | reverse_proxy %s |
| 188 | } |
| 189 | `, authAddr, backendAddr), "caddyfile") |
| 190 | |
| 191 | // The client sends forged headers; the auth service overrides them with |
| 192 | // its own values. The backend must receive the auth service values. |
| 193 | req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil) |
| 194 | req.Header.Set("Authorization", "Bearer token123") |
| 195 | req.Header.Set("X-User-Id", "forged-id") // must be overwritten |
| 196 | req.Header.Set("X-User-Role", "forged-role") // must be overwritten |
| 197 | tester.AssertResponse(req, http.StatusOK, "ok") |
| 198 | |
| 199 | mu.Lock() |
| 200 | gotID, gotRole := last.userID, last.userRole |
| 201 | mu.Unlock() |
| 202 | if gotID != wantUserID { |
| 203 | t.Errorf("X-User-Id: want %q, got %q", wantUserID, gotID) |
| 204 | } |
| 205 | if gotRole != wantUserRole { |
| 206 | t.Errorf("X-User-Role: want %q, got %q", wantUserRole, gotRole) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | // TestForwardAuthCopyHeadersUnderscoreAlias guards GHSA-f59h-q822-g45g and |
| 211 | // GHSA-49wc-4hcv-v58q: client-supplied `Remote_user`/`Remote.user` aliases |
| 212 | // of the copy_headers target `Remote-User` must be stripped before the |
| 213 | // auth route runs, otherwise a downstream CGI/FastCGI/PHP backend would |
| 214 | // fold all three names into the same HTTP_REMOTE_USER variable and the |
| 215 | // attacker would override the trusted identity. |
| 216 | func TestForwardAuthCopyHeadersUnderscoreAlias(t *testing.T) { |
| 217 | const wantRemoteUser = "alice" |
| 218 | |
| 219 | authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 220 | w.Header().Set("Remote-User", wantRemoteUser) |
| 221 | w.WriteHeader(http.StatusOK) |
| 222 | })) |
| 223 | t.Cleanup(authSrv.Close) |
| 224 | |
| 225 | type received struct { |
| 226 | remoteUserHyphen, remoteUserUnderscore, remoteUserDot string |
| 227 | } |
| 228 | var ( |
| 229 | mu sync.Mutex |
| 230 | last received |
| 231 | ) |
| 232 | backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 233 | mu.Lock() |
| 234 | last = received{ |
| 235 | remoteUserHyphen: r.Header.Get("Remote-User"), |
| 236 | remoteUserUnderscore: strings.Join(r.Header["Remote_user"], ","), |
| 237 | remoteUserDot: strings.Join(r.Header["Remote.user"], ","), |
| 238 | } |
| 239 | mu.Unlock() |
| 240 | fmt.Fprint(w, "ok") |
| 241 | })) |
| 242 | t.Cleanup(backendSrv.Close) |
| 243 | |
| 244 | tester := caddytest.NewTester(t) |
| 245 | tester.InitServer(fmt.Sprintf(` |
| 246 | { |
| 247 | skip_install_trust |
| 248 | admin localhost:2999 |
| 249 | http_port 9080 |
| 250 | https_port 9443 |
| 251 | grace_period 1ns |
| 252 | } |
| 253 | http://localhost:9080 { |
| 254 | forward_auth %s { |
| 255 | uri / |
| 256 | copy_headers Remote-User |
| 257 | } |
| 258 | reverse_proxy %s |
| 259 | } |
| 260 | `, strings.TrimPrefix(authSrv.URL, "http://"), strings.TrimPrefix(backendSrv.URL, "http://")), "caddyfile") |
| 261 | |
| 262 | req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil) |
| 263 | // Set the underscore and dot aliases via raw map access to bypass |
| 264 | // http.Header canonicalization, as an attacker would on the wire. |
| 265 | req.Header["Remote_user"] = []string{"attacker"} |
| 266 | req.Header["Remote.user"] = []string{"attacker"} |
| 267 | tester.AssertResponse(req, http.StatusOK, "ok") |
| 268 | |
| 269 | mu.Lock() |
| 270 | defer mu.Unlock() |
| 271 | assert.Equal(t, wantRemoteUser, last.remoteUserHyphen, "trusted Remote-User must reach the backend") |
| 272 | assert.Empty(t, last.remoteUserUnderscore, "underscore alias must be dropped") |
| 273 | assert.Empty(t, last.remoteUserDot, "dot alias must be dropped") |
| 274 | } |