caddy/caddytest/integration/forwardauth_test.go
Marc 62e9c05264
root: introduce down-propagating Helper.BlockState for other directives/plugins to use (#7594)
* add 'root' key to Helper.State for access in frankenphp's `php_server` directive

* clone state before passing it to child directives, but keep sharing it among sibling directives

* propagate named route state from children to parent

* use BlockState to set "root" instead

* gofmt -w .

* go fmt ./...

* here we go
2026-03-28 17:44:42 +00:00

207 lines
6.8 KiB
Go

// Copyright 2015 Matthew Holt and The Caddy Authors
//
// 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 integration
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/caddyserver/caddy/v2/caddytest"
)
// TestForwardAuthCopyHeadersStripsClientHeaders is a regression test for the
// header injection vulnerability in forward_auth copy_headers.
//
// When the auth service returns 200 OK without one of the copy_headers headers,
// the MatchNot guard skips the Set operation. Before this fix, the original
// client-supplied header survived unchanged into the backend request, allowing
// privilege escalation with only a valid (non-privileged) bearer token. After
// the fix, an unconditional delete route runs first, so the backend always
// sees an absent header rather than the attacker-supplied value.
func TestForwardAuthCopyHeadersStripsClientHeaders(t *testing.T) {
// Mock auth service: accepts any Bearer token, returns 200 OK with NO
// identity headers. This is the stateless JWT validator pattern that
// triggers the vulnerability.
authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
defer authSrv.Close()
// Mock backend: records the identity headers it receives. A real application
// would use X-User-Id / X-User-Role to make authorization decisions.
type received struct{ userID, userRole string }
var (
mu sync.Mutex
last received
)
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
last = received{
userID: r.Header.Get("X-User-Id"),
userRole: r.Header.Get("X-User-Role"),
}
mu.Unlock()
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
}))
defer backendSrv.Close()
authAddr := strings.TrimPrefix(authSrv.URL, "http://")
backendAddr := strings.TrimPrefix(backendSrv.URL, "http://")
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
forward_auth %s {
uri /
copy_headers X-User-Id X-User-Role
}
reverse_proxy %s
}
`, authAddr, backendAddr), "caddyfile")
// Case 1: no token. Auth must still reject the request even when the client
// includes identity headers. This confirms the auth check is not bypassed.
req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
req.Header.Set("X-User-Id", "injected")
req.Header.Set("X-User-Role", "injected")
resp := tester.AssertResponseCode(req, http.StatusUnauthorized)
resp.Body.Close()
// Case 2: valid token, no injected headers. The backend should see absent
// identity headers (the auth service never returns them).
req, _ = http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
req.Header.Set("Authorization", "Bearer token123")
tester.AssertResponse(req, http.StatusOK, "ok")
mu.Lock()
gotID, gotRole := last.userID, last.userRole
mu.Unlock()
if gotID != "" {
t.Errorf("baseline: X-User-Id should be absent, got %q", gotID)
}
if gotRole != "" {
t.Errorf("baseline: X-User-Role should be absent, got %q", gotRole)
}
// Case 3 (the security regression): valid token plus forged identity headers.
// The fix must strip those values so the backend never sees them.
req, _ = http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
req.Header.Set("Authorization", "Bearer token123")
req.Header.Set("X-User-Id", "admin") // forged
req.Header.Set("X-User-Role", "superadmin") // forged
tester.AssertResponse(req, http.StatusOK, "ok")
mu.Lock()
gotID, gotRole = last.userID, last.userRole
mu.Unlock()
if gotID != "" {
t.Errorf("injection: X-User-Id must be stripped, got %q", gotID)
}
if gotRole != "" {
t.Errorf("injection: X-User-Role must be stripped, got %q", gotRole)
}
}
// TestForwardAuthCopyHeadersAuthResponseWins verifies that when the auth
// service does include a copy_headers header in its response, that value
// is forwarded to the backend and takes precedence over any client-supplied
// value for the same header.
func TestForwardAuthCopyHeadersAuthResponseWins(t *testing.T) {
const wantUserID = "service-user-42"
const wantUserRole = "editor"
// Auth service: accepts bearer token and sets identity headers.
authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
w.Header().Set("X-User-Id", wantUserID)
w.Header().Set("X-User-Role", wantUserRole)
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
defer authSrv.Close()
type received struct{ userID, userRole string }
var (
mu sync.Mutex
last received
)
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
last = received{
userID: r.Header.Get("X-User-Id"),
userRole: r.Header.Get("X-User-Role"),
}
mu.Unlock()
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
}))
defer backendSrv.Close()
authAddr := strings.TrimPrefix(authSrv.URL, "http://")
backendAddr := strings.TrimPrefix(backendSrv.URL, "http://")
tester := caddytest.NewTester(t)
tester.InitServer(fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
grace_period 1ns
}
http://localhost:9080 {
forward_auth %s {
uri /
copy_headers X-User-Id X-User-Role
}
reverse_proxy %s
}
`, authAddr, backendAddr), "caddyfile")
// The client sends forged headers; the auth service overrides them with
// its own values. The backend must receive the auth service values.
req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
req.Header.Set("Authorization", "Bearer token123")
req.Header.Set("X-User-Id", "forged-id") // must be overwritten
req.Header.Set("X-User-Role", "forged-role") // must be overwritten
tester.AssertResponse(req, http.StatusOK, "ok")
mu.Lock()
gotID, gotRole := last.userID, last.userRole
mu.Unlock()
if gotID != wantUserID {
t.Errorf("X-User-Id: want %q, got %q", wantUserID, gotID)
}
if gotRole != wantUserRole {
t.Errorf("X-User-Role: want %q, got %q", wantUserRole, gotRole)
}
}