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