@marcel91200 / caddy Mirror · read-only / commits / 3eb8e48f
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, 09:45 UTC

Merge commit from fork

* feat: drop headers with underscore in their names * feat: Caddyfile binding and tests for underscore-in-header drop Add the `allow_underscore_in_headers` global server option, refine the doc comment, and cover the filter end-to-end: server-level unit tests (drop, opt-out, debug log, RFC-7230 space rejection), a fastcgi unit test for the trimmed header name replacer, and forward_auth integration tests for both the default-drop and opt-out paths. * remove allow_underscore_in_headers option for now

Kévin Dunglas committed May 29, 2026 at 19:37 UTC 3eb8e48ff052e1ad16d88c683672c306d2077a11
5 files changed +180 -1
caddytest/integration/forwardauth_test.go
+65
@@ -22,6 +22,8 @@ import (
22 "sync"
23 "testing"
24
25 + "github.com/stretchr/testify/assert"
26 +
27 "github.com/caddyserver/caddy/v2/caddytest"
28 )
29
@@ -204,3 +206,66 @@ func TestForwardAuthCopyHeadersAuthResponseWins(t *testing.T) {
206 t.Errorf("X-User-Role: want %q, got %q", wantUserRole, gotRole)
207 }
208 }
209 +
210 +// TestForwardAuthCopyHeadersUnderscoreAlias guards GHSA-f59h-q822-g45g:
211 +// a client-supplied `Remote_user` alias of the copy_headers target
212 +// `Remote-User` must be stripped before the auth route runs, otherwise
213 +// a downstream CGI/FastCGI backend would fold both names into the same
214 +// HTTP_REMOTE_USER variable and the attacker would override the trusted
215 +// 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 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 + }
238 + mu.Unlock()
239 + fmt.Fprint(w, "ok")
240 + }))
241 + t.Cleanup(backendSrv.Close)
242 +
243 + tester := caddytest.NewTester(t)
244 + tester.InitServer(fmt.Sprintf(`
245 + {
246 + skip_install_trust
247 + admin localhost:2999
248 + http_port 9080
249 + https_port 9443
250 + grace_period 1ns
251 + }
252 + http://localhost:9080 {
253 + forward_auth %s {
254 + uri /
255 + copy_headers Remote-User
256 + }
257 + reverse_proxy %s
258 + }
259 + `, strings.TrimPrefix(authSrv.URL, "http://"), strings.TrimPrefix(backendSrv.URL, "http://")), "caddyfile")
260 +
261 + req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
262 + // Set the underscore alias via raw map access to bypass http.Header
263 + // canonicalization, as an attacker would on the wire.
264 + req.Header["Remote_user"] = []string{"attacker"}
265 + tester.AssertResponse(req, http.StatusOK, "ok")
266 +
267 + mu.Lock()
268 + defer mu.Unlock()
269 + assert.Equal(t, wantRemoteUser, last.remoteUserHyphen, "trusted Remote-User must reach the backend")
270 + assert.Empty(t, last.remoteUserUnderscore, "underscore alias must be dropped")
271 +}
modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
+1 -1
@@ -507,7 +507,7 @@ var tlsProtocolStrings = map[uint16]string{
507 tls.VersionTLS13: "TLSv1.3",
508 }
509
510 -var headerNameReplacer = strings.NewReplacer(" ", "_", "-", "_")
510 +var headerNameReplacer = strings.NewReplacer("-", "_")
511
512 // Interface guards
513 var (
modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go
+24
@@ -304,6 +304,30 @@ func TestSplitPosUnicodeSecurityRegression(t *testing.T) {
304 }
305 }
306
307 +// TestHeaderNameReplacer asserts the CGI header-to-env normalization rule:
308 +// hyphens are mapped to underscores while every other character (including
309 +// spaces) is passed through. Spaces are not RFC 7230 tokens, so they cannot
310 +// reach this function from the wire; the only header names that survive
311 +// untouched at the server layer are sanitized by the underscore filter in
312 +// caddyhttp.Server.serveHTTP (see GHSA-f59h-q822-g45g).
313 +func TestHeaderNameReplacer(t *testing.T) {
314 + tests := []struct {
315 + in, want string
316 + }{
317 + {"X-Forwarded-For", "X_Forwarded_For"},
318 + {"Remote-User", "Remote_User"},
319 + // Underscores are preserved (the server has already dropped any
320 + // underscore-named headers when the filter is on).
321 + {"Remote_User", "Remote_User"},
322 + // Spaces are not rewritten because Go's HTTP parser rejects whitespace in
323 + // header field names.
324 + {"Foo Bar", "Foo Bar"},
325 + }
326 + for _, tt := range tests {
327 + assert.Equal(t, tt.want, headerNameReplacer.Replace(tt.in), "input %q", tt.in)
328 + }
329 +}
330 +
331 // TestSplitPosSecurityRegressionUnicodeBypass guards against the FrankenPHP
332 // advisories GHSA-3g8v-8r37-cgjm (uninitialized match flag on inner non-ASCII
333 // byte) and GHSA-v4h7-cj44-8fc8 (Unicode equivalence via search.IgnoreCase
modules/caddyhttp/server.go
+13
@@ -494,6 +494,19 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) error {
494 }
495 }
496
497 + // Drop headers whose names contain `_`: once FastCGI/CGI/FrankenPHP etc. rewrites `-` to
498 + // `_`, an underscore alias collides with the legitimate hyphenated header
499 + // and can bypass `forward_auth copy_headers` (GHSA-f59h-q822-g45g).
500 + for k := range r.Header {
501 + if strings.ContainsRune(k, '_') {
502 + delete(r.Header, k)
503 +
504 + if c := s.logger.Check(zapcore.DebugLevel, "dropping header containing underscore"); c != nil {
505 + c.Write(zap.String("header", k))
506 + }
507 + }
508 + }
509 +
510 // execute the primary handler chain
511 return s.primaryHandlerChain.ServeHTTP(w, r)
512 }
modules/caddyhttp/server_test.go
+77
@@ -1,16 +1,20 @@
1 package caddyhttp
2
3 import (
4 + "bufio"
5 "bytes"
6 "context"
7 "io"
8 + "net"
9 "net/http"
10 "net/http/httptest"
11 "net/netip"
12 + "strings"
13 "testing"
14 "time"
15
16 "github.com/stretchr/testify/assert"
17 + "github.com/stretchr/testify/require"
18 "go.uber.org/zap"
19 "go.uber.org/zap/zapcore"
20 )
@@ -478,6 +482,79 @@ func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedSkippingTrusted(t *
482 assert.Equal(t, clientIP, "45.54.45.54")
483 }
484
485 +// TestServer_serveHTTP_DropsUnderscoreHeader covers GHSA-f59h-q822-g45g: an
486 +// underscore-named alias (e.g. `Remote_user`) of a hyphenated header must be
487 +// dropped before any handler runs.
488 +func TestServer_serveHTTP_DropsUnderscoreHeader(t *testing.T) {
489 + got := &http.Header{}
490 + s := &Server{
491 + logger: zap.NewNop(),
492 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
493 + *got = r.Header.Clone()
494 + return nil
495 + }),
496 + }
497 +
498 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
499 + req.Header["X-Real-Header"] = []string{"ok"}
500 + req.Header["Remote_user"] = []string{"attacker"}
501 + req.Header["Remote_groups"] = []string{"admin"}
502 +
503 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
504 + assert.NotContains(t, *got, "Remote_user")
505 + assert.NotContains(t, *got, "Remote_groups")
506 + assert.Equal(t, "ok", got.Get("X-Real-Header"))
507 +}
508 +
509 +// TestServer_serveHTTP_LogsDroppedUnderscoreHeader verifies each dropped
510 +// header is emitted at debug level so operators can diagnose unexpectedly
511 +// missing headers without spamming the log on adversarial traffic.
512 +func TestServer_serveHTTP_LogsDroppedUnderscoreHeader(t *testing.T) {
513 + var buf bytes.Buffer
514 + s := &Server{
515 + logger: testLogger(buf.Write),
516 + primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
517 + return nil
518 + }),
519 + }
520 +
521 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
522 + req.Header["Remote_user"] = []string{"attacker"}
523 +
524 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
525 + assert.Contains(t, buf.String(), `"level":"debug"`)
526 + assert.Contains(t, buf.String(), `"msg":"dropping header containing underscore"`)
527 + assert.Contains(t, buf.String(), `"header":"Remote_user"`)
528 +}
529 +
530 +// TestServer_SpaceInHeaderNameReturnsBadRequest documents why the underscore
531 +// filter does not also strip space-named headers: Go's HTTP parser rejects a
532 +// space in a field name with 400 before any handler runs, so such a request
533 +// can never reach Caddy's pipeline.
534 +func TestServer_SpaceInHeaderNameReturnsBadRequest(t *testing.T) {
535 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
536 + t.Errorf("handler must not be reached; got headers %v", r.Header)
537 + }))
538 + t.Cleanup(srv.Close)
539 +
540 + addr := strings.TrimPrefix(srv.URL, "http://")
541 + conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
542 + require.NoError(t, err)
543 + t.Cleanup(func() { _ = conn.Close() })
544 + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
545 +
546 + _, err = conn.Write([]byte("GET / HTTP/1.1\r\n" +
547 + "Host: " + addr + "\r\n" +
548 + "Remote User: attacker\r\n" +
549 + "Connection: close\r\n\r\n"))
550 + require.NoError(t, err)
551 +
552 + resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
553 + require.NoError(t, err)
554 + t.Cleanup(func() { _ = resp.Body.Close() })
555 + assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
556 +}
557 +
558 func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedFirst(t *testing.T) {
559 localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
560