@marcel91200 / caddy Mirror · read-only / commits / 947087ca
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, 07:15 UTC

Merge commit from fork

Caddy's underscore header filter (GHSA-f59h-q822-g45g) only checked for `_`. PHP folds `.` to `_` when registering $_SERVER keys the same way CGI/FastCGI folds `-` to `_`, so a dotted alias (e.g. Remote.User) survived the filter and collided with the legitimate hyphenated header once it reached a PHP/FastCGI backend, bypassing forward_auth copy_headers the same way the underscore alias did. Extends the filter to drop `.` symmetrically, adds an `expected_dot_headers` allowlist mirroring `expected_underscore_headers`, and handles header names containing both separators (only an exact allowlist entry is honored there, since a prefix glob's free-form suffix can't be vetted for an embedded second separator). Root cause identified by @iliaal in the FrankenPHP advisory GHSA-49wc-4hcv-v58q.

Kévin Dunglas committed Aug 11, 2026 at 16:00 UTC 947087cadd596c6e391e0df09e7e975afd2b4cbc
6 files changed +583 -71
caddyconfig/httpcaddyfile/serveroptions.go
+9
@@ -49,6 +49,7 @@ type serverOptions struct {
49 MaxHeaderBytes int
50 EnableFullDuplex bool
51 ExpectedUnderscoreHeaders []string
52 + ExpectedDotHeaders []string
53 Protocols []string
54 StrictSNIHost *bool
55 TrustedProxiesRaw json.RawMessage
@@ -226,6 +227,13 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
227 }
228 serverOpts.ExpectedUnderscoreHeaders = args
229
230 + case "expected_dot_headers":
231 + args := d.RemainingArgs()
232 + if len(args) == 0 {
233 + return nil, d.ArgErr()
234 + }
235 + serverOpts.ExpectedDotHeaders = args
236 +
237 case "log_credentials":
238 if d.NextArg() {
239 return nil, d.ArgErr()
@@ -389,6 +397,7 @@ func applyServerOptions(
397 server.MaxHeaderBytes = opts.MaxHeaderBytes
398 server.EnableFullDuplex = opts.EnableFullDuplex
399 server.ExpectedUnderscoreHeaders = opts.ExpectedUnderscoreHeaders
400 + server.ExpectedDotHeaders = opts.ExpectedDotHeaders
401 server.Protocols = opts.Protocols
402 server.StrictSNIHost = opts.StrictSNIHost
403 server.TrustedProxiesRaw = opts.TrustedProxiesRaw
caddytest/integration/forwardauth_test.go
+12 -9
@@ -207,12 +207,12 @@ func TestForwardAuthCopyHeadersAuthResponseWins(t *testing.T) {
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.
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
@@ -223,7 +223,7 @@ func TestForwardAuthCopyHeadersUnderscoreAlias(t *testing.T) {
223 t.Cleanup(authSrv.Close)
224
225 type received struct {
226 - remoteUserHyphen, remoteUserUnderscore string
226 + remoteUserHyphen, remoteUserUnderscore, remoteUserDot string
227 }
228 var (
229 mu sync.Mutex
@@ -234,6 +234,7 @@ func TestForwardAuthCopyHeadersUnderscoreAlias(t *testing.T) {
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")
@@ -259,13 +260,15 @@ func TestForwardAuthCopyHeadersUnderscoreAlias(t *testing.T) {
260 `, strings.TrimPrefix(authSrv.URL, "http://"), strings.TrimPrefix(backendSrv.URL, "http://")), "caddyfile")
261
262 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.
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 }
modules/caddyhttp/app.go
+7 -4
@@ -49,9 +49,9 @@ func init() {
49 // defined by adding a good, default TLS connection policy.
50 //
51 // Similar to how other popular web servers work, incoming request header fields
52 -// with underscores are ignored/dropped implicitly to mitigate security risks.
53 -// Specific headers to allow can be explicitly configured using
54 -// `expected_underscore_headers`.
52 +// with underscores or dots are ignored/dropped implicitly to mitigate security
53 +// risks. Specific headers to allow can be explicitly configured using
54 +// `expected_underscore_headers` and `expected_dot_headers`.
55 //
56 // ### Placeholders
57 //
@@ -302,10 +302,13 @@ func (app *App) Provision(ctx caddy.Context) error {
302 srv.ClientIPHeaders = []string{"X-Forwarded-For"}
303 }
304
305 - // precompute underscore header allowlist rules
305 + // precompute underscore and dot header allowlist rules
306 if err := srv.provisionUnderscoreHeaders(); err != nil {
307 return fmt.Errorf("server %s: %v", srvName, err)
308 }
309 + if err := srv.provisionDotHeaders(); err != nil {
310 + return fmt.Errorf("server %s: %v", srvName, err)
311 + }
312
313 // process each listener address
314 for i := range srv.Listen {
modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go
+8 -4
@@ -310,10 +310,11 @@ func TestSplitPosUnicodeSecurityRegression(t *testing.T) {
310
311 // TestHeaderNameReplacer asserts the CGI header-to-env normalization rule:
312 // hyphens are mapped to underscores while every other character (including
313 -// spaces) is passed through. Spaces are not RFC 7230 tokens, so they cannot
314 -// reach this function from the wire; the only header names that survive
315 -// untouched at the server layer are sanitized by the underscore filter in
316 -// caddyhttp.Server.serveHTTP (see GHSA-f59h-q822-g45g).
313 +// spaces and dots) is passed through. Spaces are not RFC 7230 tokens, so they
314 +// cannot reach this function from the wire; the only header names that
315 +// survive untouched at the server layer are sanitized by the underscore/dot
316 +// filter in caddyhttp.Server.serveHTTP (see GHSA-f59h-q822-g45g,
317 +// GHSA-49wc-4hcv-v58q).
318 func TestHeaderNameReplacer(t *testing.T) {
319 tests := []struct {
320 in, want string
@@ -323,6 +324,9 @@ func TestHeaderNameReplacer(t *testing.T) {
324 // Underscores are preserved (the server has already dropped any
325 // underscore-named headers when the filter is on).
326 {"Remote_User", "Remote_User"},
327 + // Dots are preserved by this replacer too; the server-layer filter is
328 + // what prevents a dotted alias (e.g. Remote.User) from reaching here.
329 + {"Remote.User", "Remote.User"},
330 // Spaces are not rewritten because Go's HTTP parser rejects whitespace in
331 // header field names.
332 {"Foo Bar", "Foo Bar"},
modules/caddyhttp/server.go
+184 -53
@@ -137,9 +137,52 @@ type Server struct {
137 // multiple values (repeated field), all values are dropped
138 // as a safeguard against header injection.
139 //
140 + // If the same logical header name is also allowlisted (in dot form)
141 + // in ExpectedDotHeaders, both spellings are kept independently. Only
142 + // do this if you know your backend doesn't fold both forms onto the
143 + // same variable name; PHP/CGI-style backends do (see
144 + // ExpectedDotHeaders), so allowlisting both there reintroduces the
145 + // ambiguity this filter exists to remove.
146 + //
147 + // A header name containing both an underscore and a dot (e.g.
148 + // "webhook_user.id") is never matched by a prefix glob here, even
149 + // one whose prefix matches, because the free-form suffix a glob
150 + // allows can't be vetted for an embedded dot; only an exact (non-glob)
151 + // entry for that literal spelling is honored.
152 + //
153 // TODO: This is an EXPERIMENTAL feature. Subject to change or removal.
154 ExpectedUnderscoreHeaders []string `json:"expected_underscore_headers,omitempty"`
155
156 + // A list of header field names containing dots that should be
157 + // preserved instead of being dropped. By default, Caddy drops ALL
158 + // headers with dots to prevent ambiguity with backends that fold
159 + // dots to underscores when registering CGI-style variables, e.g.
160 + // PHP's $_SERVER (GHSA-49wc-4hcv-v58q). When this list is
161 + // configured, only the specified headers are kept; their
162 + // hyphenated variants are actively dropped to prevent confusion.
163 + // Entries are case-insensitive. A trailing "*" acts as a prefix
164 + // glob (e.g., "webhook.*" matches any header starting with
165 + // "webhook."). If an allowlisted header arrives with multiple
166 + // values (repeated field), all values are dropped as a safeguard
167 + // against header injection.
168 + //
169 + // Dotted headers are legal HTTP tokens and some non-CGI backends
170 + // (e.g. Node.js, Go) use them as ordinary, unrelated header names.
171 + // If your backend is one of those, this poses no risk. Only
172 + // PHP/CGI/FastCGI-style backends fold '.', '_', and '-' onto the
173 + // same variable name; avoid allowlisting both the dot and
174 + // underscore form of the same logical name if such a backend is in
175 + // the request path, since Caddy will not stop you and the backend
176 + // may then see either value depending on map iteration order.
177 + //
178 + // A header name containing both a dot and an underscore is never
179 + // matched by a prefix glob here, for the same reason described on
180 + // ExpectedUnderscoreHeaders; only an exact (non-glob) entry for
181 + // that literal spelling is honored.
182 + //
183 + // TODO: This is an EXPERIMENTAL feature. Subject to change or removal.
184 + ExpectedDotHeaders []string `json:"expected_dot_headers,omitempty"`
185 +
186 // Routes describes how this server will handle requests.
187 // Routes are executed sequentially. First a route's matchers
188 // are evaluated, then its grouping. If it matches and has
@@ -312,7 +355,12 @@ type Server struct {
355 // precomputed underscore header allowlist (built during provisioning)
356 underscoreExactAllow map[string]struct{}
357 underscoreExactDrop map[string]struct{}
315 - underscorePrefixRules []underscoreRule
358 + underscorePrefixRules []aliasPrefixRule
359 +
360 + // precomputed dot header allowlist (built during provisioning)
361 + dotExactAllow map[string]struct{}
362 + dotExactDrop map[string]struct{}
363 + dotPrefixRules []aliasPrefixRule
364
365 // registered callback functions
366 connStateFuncs []func(net.Conn, http.ConnState)
@@ -321,30 +369,29 @@ type Server struct {
369 onStopFuncs []func(context.Context) error // TODO: Experimental (Nov. 2023)
370 }
371
324 -// underscoreRule pairs a canonical underscore prefix with its hyphenated
325 -// counterpart. Used for prefix-glob matching in the allowlist.
326 -type underscoreRule struct {
327 - allow string // canonical underscore form, e.g. "Webhook_"
372 +// aliasPrefixRule pairs a canonical allowed prefix (underscore- or
373 +// dot-named) with its hyphenated counterpart. Used for prefix-glob
374 +// matching in the underscore/dot allowlists.
375 +type aliasPrefixRule struct {
376 + allow string // canonical allowed form, e.g. "Webhook_" or "Webhook."
377 drop string // canonical hyphenated form, e.g. "Webhook-"
378 }
379
331 -// provisionUnderscoreHeaders validates the ExpectedUnderscoreHeaders
332 -// entries and builds the precomputed maps and prefix rules used by
333 -// the hot-path filter in serveHTTP.
334 -func (s *Server) provisionUnderscoreHeaders() error {
335 - if len(s.ExpectedUnderscoreHeaders) == 0 {
336 - return nil
337 - }
380 +// provisionHeaderAliasAllowlist validates entries for a header-alias
381 +// allowlist (ExpectedUnderscoreHeaders or ExpectedDotHeaders) and builds
382 +// the precomputed maps and prefix rules used by the hot-path filter in
383 +// serveHTTP. sep is the separator the entries must contain ('_' or '.');
384 +// directive is the Caddyfile/JSON name used in error messages.
385 +func provisionHeaderAliasAllowlist(entries []string, sep rune, directive string) (exactAllow, exactDrop map[string]struct{}, prefixRules []aliasPrefixRule, err error) {
386 + exactAllow = make(map[string]struct{}, len(entries))
387 + exactDrop = make(map[string]struct{}, len(entries))
388
339 - s.underscoreExactAllow = make(map[string]struct{}, len(s.ExpectedUnderscoreHeaders))
340 - s.underscoreExactDrop = make(map[string]struct{}, len(s.ExpectedUnderscoreHeaders))
341 -
342 - for _, entry := range s.ExpectedUnderscoreHeaders {
389 + for _, entry := range entries {
390 // Reject non-ASCII bytes: Go's HTTP parser returns 400 for
391 // non-ASCII header names, so such entries can never match.
392 for i := 0; i < len(entry); i++ {
393 if entry[i] >= 0x80 {
347 - return fmt.Errorf("expected_underscore_headers: entry %q contains non-ASCII characters", entry)
394 + return nil, nil, nil, fmt.Errorf("%s: entry %q contains non-ASCII characters", directive, entry)
395 }
396 }
397
@@ -356,29 +403,55 @@ func (s *Server) provisionUnderscoreHeaders() error {
403
404 // Reject entries with '*' not at the trailing position.
405 if strings.ContainsRune(name, '*') {
359 - return fmt.Errorf("expected_underscore_headers: entry %q has '*' in an invalid position (only a trailing '*' is allowed)", entry)
406 + return nil, nil, nil, fmt.Errorf("%s: entry %q has '*' in an invalid position (only a trailing '*' is allowed)", directive, entry)
407 }
408
362 - // The name (without trailing '*') must contain at least one underscore.
363 - if !strings.ContainsRune(name, '_') {
364 - return fmt.Errorf("expected_underscore_headers: entry %q does not contain an underscore", entry)
409 + // The name (without trailing '*') must contain at least one separator.
410 + if !strings.ContainsRune(name, sep) {
411 + return nil, nil, nil, fmt.Errorf("%s: entry %q does not contain a %q", directive, entry, sep)
412 }
413
414 canonAllow := http.CanonicalHeaderKey(name)
368 - canonDrop := http.CanonicalHeaderKey(strings.ReplaceAll(name, "_", "-"))
415 + canonDrop := http.CanonicalHeaderKey(strings.ReplaceAll(name, string(sep), "-"))
416
417 if isGlob {
371 - s.underscorePrefixRules = append(s.underscorePrefixRules, underscoreRule{
418 + prefixRules = append(prefixRules, aliasPrefixRule{
419 allow: canonAllow,
420 drop: canonDrop,
421 })
422 } else {
376 - s.underscoreExactAllow[canonAllow] = struct{}{}
377 - s.underscoreExactDrop[canonDrop] = struct{}{}
423 + exactAllow[canonAllow] = struct{}{}
424 + exactDrop[canonDrop] = struct{}{}
425 }
426 }
427
381 - return nil
428 + return exactAllow, exactDrop, prefixRules, nil
429 +}
430 +
431 +// provisionUnderscoreHeaders validates the ExpectedUnderscoreHeaders
432 +// entries and builds the precomputed maps and prefix rules used by
433 +// the hot-path filter in serveHTTP.
434 +func (s *Server) provisionUnderscoreHeaders() error {
435 + if len(s.ExpectedUnderscoreHeaders) == 0 {
436 + return nil
437 + }
438 + var err error
439 + s.underscoreExactAllow, s.underscoreExactDrop, s.underscorePrefixRules, err =
440 + provisionHeaderAliasAllowlist(s.ExpectedUnderscoreHeaders, '_', "expected_underscore_headers")
441 + return err
442 +}
443 +
444 +// provisionDotHeaders validates the ExpectedDotHeaders entries and
445 +// builds the precomputed maps and prefix rules used by the hot-path
446 +// filter in serveHTTP.
447 +func (s *Server) provisionDotHeaders() error {
448 + if len(s.ExpectedDotHeaders) == 0 {
449 + return nil
450 + }
451 + var err error
452 + s.dotExactAllow, s.dotExactDrop, s.dotPrefixRules, err =
453 + provisionHeaderAliasAllowlist(s.ExpectedDotHeaders, '.', "expected_dot_headers")
454 + return err
455 }
456
457 // isAllowedUnderscoreHeader reports whether key (a canonical header
@@ -395,18 +468,41 @@ func (s *Server) isAllowedUnderscoreHeader(key string) bool {
468 return false
469 }
470
471 +// isAllowedDotHeader reports whether key (a canonical header name
472 +// containing a dot) is permitted by the allowlist.
473 +func (s *Server) isAllowedDotHeader(key string) bool {
474 + if _, ok := s.dotExactAllow[key]; ok {
475 + return true
476 + }
477 + for _, rule := range s.dotPrefixRules {
478 + if strings.HasPrefix(key, rule.allow) {
479 + return true
480 + }
481 + }
482 + return false
483 +}
484 +
485 // isHyphenatedVariant reports whether key (a canonical header name
399 -// without underscores) is the hyphenated variant of an allowlisted
400 -// underscore header and should therefore be dropped.
486 +// containing neither an underscore nor a dot) is the hyphenated variant
487 +// of an allowlisted underscore or dot header and should therefore be
488 +// dropped.
489 func (s *Server) isHyphenatedVariant(key string) bool {
490 if _, ok := s.underscoreExactDrop[key]; ok {
491 return true
492 }
493 + if _, ok := s.dotExactDrop[key]; ok {
494 + return true
495 + }
496 for _, rule := range s.underscorePrefixRules {
497 if strings.HasPrefix(key, rule.drop) {
498 return true
499 }
500 }
501 + for _, rule := range s.dotPrefixRules {
502 + if strings.HasPrefix(key, rule.drop) {
503 + return true
504 + }
505 + }
506 return false
507 }
508
@@ -604,44 +700,79 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) error {
700 }
701 }
702
607 - // Drop headers whose names contain `_`: once FastCGI/CGI/FrankenPHP etc. rewrites `-` to
608 - // `_`, an underscore alias collides with the legitimate hyphenated header
609 - // and can bypass `forward_auth copy_headers` (GHSA-f59h-q822-g45g).
703 + // Drop headers whose names contain `_` or `.`: once FastCGI/CGI/FrankenPHP etc.
704 + // rewrites `-` to `_` (and PHP additionally folds `.` to `_` when registering
705 + // $_SERVER keys), an underscore or dot alias collides with the legitimate
706 + // hyphenated header and can bypass `forward_auth copy_headers`
707 + // (GHSA-f59h-q822-g45g, GHSA-49wc-4hcv-v58q).
708 //
611 - // When an allowlist is configured, only the listed headers are kept and
612 - // their hyphenated variants are actively dropped to prevent ambiguity.
613 - if len(s.ExpectedUnderscoreHeaders) == 0 {
614 - for k := range r.Header {
615 - if strings.ContainsRune(k, '_') {
709 + // The two allowlists (ExpectedUnderscoreHeaders, ExpectedDotHeaders) are
710 + // otherwise independent: each only keeps headers spelled with its own
711 + // character, and either allowlist actively drops the plain-hyphenated
712 + // variant of its entries.
713 + for k := range r.Header {
714 + hasUnderscore := strings.ContainsRune(k, '_')
715 + hasDot := strings.ContainsRune(k, '.')
716 +
717 + switch {
718 + case hasUnderscore && hasDot:
719 + // A name containing both separators still collides with the
720 + // hyphenated, underscore-only, and dot-only spellings once a
721 + // CGI/FastCGI/PHP backend folds them onto the same variable
722 + // name. A prefix glob can't vet its free-form suffix for an
723 + // embedded "other" separator, so only an exact allowlist entry
724 + // for this literal spelling is honored here.
725 + _, underscoreOK := s.underscoreExactAllow[k]
726 + _, dotOK := s.dotExactAllow[k]
727 + if !underscoreOK && !dotOK {
728 delete(r.Header, k)
729
618 - if c := s.logger.Check(zapcore.DebugLevel, "dropping header containing underscore"); c != nil {
730 + if c := s.logger.Check(zapcore.DebugLevel, "dropping header containing both underscore and dot"); c != nil {
731 c.Write(zap.String("header", k))
732 }
733 + } else if n := len(r.Header[k]); n > 1 {
734 + delete(r.Header, k)
735 +
736 + if c := s.logger.Check(zapcore.WarnLevel, "dropping allowlisted underscore/dot header with repeated values (possible spoofing)"); c != nil {
737 + c.Write(zap.String("header", k), zap.Int("count", n))
738 + }
739 }
622 - }
623 - } else {
624 - for k := range r.Header {
625 - if strings.ContainsRune(k, '_') {
626 - if !s.isAllowedUnderscoreHeader(k) {
627 - delete(r.Header, k)
740
629 - if c := s.logger.Check(zapcore.DebugLevel, "dropping header containing underscore"); c != nil {
630 - c.Write(zap.String("header", k))
631 - }
632 - } else if n := len(r.Header[k]); n > 1 {
633 - delete(r.Header, k)
741 + case hasUnderscore:
742 + if len(s.ExpectedUnderscoreHeaders) == 0 || !s.isAllowedUnderscoreHeader(k) {
743 + delete(r.Header, k)
744
635 - if c := s.logger.Check(zapcore.WarnLevel, "dropping allowlisted underscore header with repeated values (possible spoofing)"); c != nil {
636 - c.Write(zap.String("header", k), zap.Int("count", n))
637 - }
745 + if c := s.logger.Check(zapcore.DebugLevel, "dropping header containing underscore"); c != nil {
746 + c.Write(zap.String("header", k))
747 }
639 - } else if s.isHyphenatedVariant(k) {
748 + } else if n := len(r.Header[k]); n > 1 {
749 delete(r.Header, k)
750
642 - if c := s.logger.Check(zapcore.DebugLevel, "dropping hyphenated variant of expected underscore header"); c != nil {
751 + if c := s.logger.Check(zapcore.WarnLevel, "dropping allowlisted underscore header with repeated values (possible spoofing)"); c != nil {
752 + c.Write(zap.String("header", k), zap.Int("count", n))
753 + }
754 + }
755 +
756 + case hasDot:
757 + if len(s.ExpectedDotHeaders) == 0 || !s.isAllowedDotHeader(k) {
758 + delete(r.Header, k)
759 +
760 + if c := s.logger.Check(zapcore.DebugLevel, "dropping header containing dot"); c != nil {
761 c.Write(zap.String("header", k))
762 }
763 + } else if n := len(r.Header[k]); n > 1 {
764 + delete(r.Header, k)
765 +
766 + if c := s.logger.Check(zapcore.WarnLevel, "dropping allowlisted dot header with repeated values (possible spoofing)"); c != nil {
767 + c.Write(zap.String("header", k), zap.Int("count", n))
768 + }
769 + }
770 +
771 + case s.isHyphenatedVariant(k):
772 + delete(r.Header, k)
773 +
774 + if c := s.logger.Check(zapcore.DebugLevel, "dropping hyphenated variant of expected underscore/dot header"); c != nil {
775 + c.Write(zap.String("header", k))
776 }
777 }
778 }
modules/caddyhttp/server_test.go
+363 -1
@@ -527,6 +527,51 @@ func TestServer_serveHTTP_LogsDroppedUnderscoreHeader(t *testing.T) {
527 assert.Contains(t, buf.String(), `"header":"Remote_user"`)
528 }
529
530 +// TestServer_serveHTTP_DropsDotHeader guards GHSA-49wc-4hcv-v58q: a
531 +// dot-named alias (e.g. `Remote.user`) of a hyphenated header must be
532 +// dropped too, since PHP's $_SERVER registration folds `.` to `_` just
533 +// like CGI/FastCGI folds `-` to `_`.
534 +func TestServer_serveHTTP_DropsDotHeader(t *testing.T) {
535 + got := &http.Header{}
536 + s := &Server{
537 + logger: zap.NewNop(),
538 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
539 + *got = r.Header.Clone()
540 + return nil
541 + }),
542 + }
543 +
544 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
545 + req.Header["X-Real-Header"] = []string{"ok"}
546 + req.Header["Remote.user"] = []string{"attacker"}
547 + req.Header["Remote.groups"] = []string{"admin"}
548 +
549 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
550 + assert.NotContains(t, *got, "Remote.user")
551 + assert.NotContains(t, *got, "Remote.groups")
552 + assert.Equal(t, "ok", got.Get("X-Real-Header"))
553 +}
554 +
555 +// TestServer_serveHTTP_LogsDroppedDotHeader verifies each dropped dotted
556 +// header is emitted at debug level, same as the underscore case.
557 +func TestServer_serveHTTP_LogsDroppedDotHeader(t *testing.T) {
558 + var buf bytes.Buffer
559 + s := &Server{
560 + logger: testLogger(buf.Write),
561 + primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
562 + return nil
563 + }),
564 + }
565 +
566 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
567 + req.Header["Remote.user"] = []string{"attacker"}
568 +
569 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
570 + assert.Contains(t, buf.String(), `"level":"debug"`)
571 + assert.Contains(t, buf.String(), `"msg":"dropping header containing dot"`)
572 + assert.Contains(t, buf.String(), `"header":"Remote.user"`)
573 +}
574 +
575 // --- Allowlist: exact match ---
576
577 func TestServer_serveHTTP_AllowlistKeepsExactMatch(t *testing.T) {
@@ -777,6 +822,277 @@ func TestServer_serveHTTP_LiteralAsteriskInHeader(t *testing.T) {
822 assert.Equal(t, "val", got.Get("Webhook_*"))
823 }
824
825 +// --- Allowlist: dot headers (ExpectedDotHeaders), independent of underscore ---
826 +
827 +// TestServer_serveHTTP_DotAllowlistKeepsExactMatch verifies a dot-named
828 +// header explicitly allowlisted via ExpectedDotHeaders survives, mirroring
829 +// the underscore allowlist behavior.
830 +func TestServer_serveHTTP_DotAllowlistKeepsExactMatch(t *testing.T) {
831 + got := &http.Header{}
832 + s := &Server{
833 + logger: zap.NewNop(),
834 + ExpectedDotHeaders: []string{"user.id"},
835 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
836 + *got = r.Header.Clone()
837 + return nil
838 + }),
839 + }
840 + require.NoError(t, s.provisionDotHeaders())
841 +
842 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
843 + req.Header["User.id"] = []string{"zeus"}
844 +
845 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
846 + assert.Equal(t, "zeus", got.Get("User.id"))
847 +}
848 +
849 +// TestServer_serveHTTP_DotAllowlistDropsHyphenatedVariant verifies the
850 +// hyphenated variant of an allowlisted dot header is dropped, same as for
851 +// the underscore allowlist.
852 +func TestServer_serveHTTP_DotAllowlistDropsHyphenatedVariant(t *testing.T) {
853 + got := &http.Header{}
854 + s := &Server{
855 + logger: zap.NewNop(),
856 + ExpectedDotHeaders: []string{"user.id"},
857 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
858 + *got = r.Header.Clone()
859 + return nil
860 + }),
861 + }
862 + require.NoError(t, s.provisionDotHeaders())
863 +
864 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
865 + req.Header["User.id"] = []string{"zeus"}
866 + req.Header.Set("User-Id", "attacker")
867 +
868 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
869 + assert.Equal(t, "zeus", got.Get("User.id"))
870 + assert.NotContains(t, *got, "User-Id")
871 +}
872 +
873 +// TestServer_serveHTTP_DotAllowlistDropsUnderscoreVariant verifies that when
874 +// only the dot form is allowlisted, an underscore-form header of the same
875 +// logical name is NOT independently kept: ExpectedDotHeaders does not grant
876 +// any allowance to underscore-named headers, so it falls through to the
877 +// default underscore behavior (dropped, since ExpectedUnderscoreHeaders is
878 +// unset).
879 +func TestServer_serveHTTP_DotAllowlistDropsUnderscoreVariant(t *testing.T) {
880 + got := &http.Header{}
881 + s := &Server{
882 + logger: zap.NewNop(),
883 + ExpectedDotHeaders: []string{"user.id"},
884 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
885 + *got = r.Header.Clone()
886 + return nil
887 + }),
888 + }
889 + require.NoError(t, s.provisionDotHeaders())
890 +
891 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
892 + req.Header["User.id"] = []string{"zeus"}
893 + req.Header["User_id"] = []string{"attacker"}
894 +
895 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
896 + assert.Equal(t, "zeus", got.Get("User.id"))
897 + assert.NotContains(t, *got, "User_id")
898 +}
899 +
900 +// TestServer_serveHTTP_DotAllowlistDropsUnlisted verifies an unlisted
901 +// dot-form header is dropped just like its underscore-form counterpart.
902 +func TestServer_serveHTTP_DotAllowlistDropsUnlisted(t *testing.T) {
903 + got := &http.Header{}
904 + s := &Server{
905 + logger: zap.NewNop(),
906 + ExpectedDotHeaders: []string{"user.id"},
907 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
908 + *got = r.Header.Clone()
909 + return nil
910 + }),
911 + }
912 + require.NoError(t, s.provisionDotHeaders())
913 +
914 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
915 + req.Header["User.id"] = []string{"zeus"}
916 + req.Header["Remote.user"] = []string{"attacker"}
917 +
918 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
919 + assert.Equal(t, "zeus", got.Get("User.id"))
920 + assert.NotContains(t, *got, "Remote.user")
921 +}
922 +
923 +// TestServer_serveHTTP_DotPrefixGlobKeepsMatch verifies prefix-glob
924 +// matching works for dot headers the same way it does for underscore
925 +// headers.
926 +func TestServer_serveHTTP_DotPrefixGlobKeepsMatch(t *testing.T) {
927 + got := &http.Header{}
928 + s := &Server{
929 + logger: zap.NewNop(),
930 + ExpectedDotHeaders: []string{"webhook.*"},
931 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
932 + *got = r.Header.Clone()
933 + return nil
934 + }),
935 + }
936 + require.NoError(t, s.provisionDotHeaders())
937 +
938 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
939 + req.Header["Webhook.event"] = []string{"push"}
940 +
941 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
942 + assert.Equal(t, "push", got.Get("Webhook.event"))
943 +}
944 +
945 +// --- Both allowlists configured together ---
946 +
947 +// TestServer_serveHTTP_BothAllowlistsCoexistForDifferentNames verifies
948 +// unrelated underscore and dot allowlist entries can coexist without
949 +// interfering with each other.
950 +func TestServer_serveHTTP_BothAllowlistsCoexistForDifferentNames(t *testing.T) {
951 + got := &http.Header{}
952 + s := &Server{
953 + logger: zap.NewNop(),
954 + ExpectedUnderscoreHeaders: []string{"user_id"},
955 + ExpectedDotHeaders: []string{"webhook.event"},
956 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
957 + *got = r.Header.Clone()
958 + return nil
959 + }),
960 + }
961 + require.NoError(t, s.provisionUnderscoreHeaders())
962 + require.NoError(t, s.provisionDotHeaders())
963 +
964 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
965 + req.Header["User_id"] = []string{"zeus"}
966 + req.Header["Webhook.event"] = []string{"push"}
967 +
968 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
969 + assert.Equal(t, "zeus", got.Get("User_id"))
970 + assert.Equal(t, "push", got.Get("Webhook.event"))
971 +}
972 +
973 +// TestServer_serveHTTP_BothAllowlistsPermitSameLogicalName verifies Caddy
974 +// does not enforce mutual exclusion between the two allowlists: an
975 +// operator whose backend doesn't fold '.' and '_' onto the same variable
976 +// (e.g. a Node.js or Go app behind reverse_proxy, not a CGI/FastCGI/PHP
977 +// backend) may legitimately want both spellings of the same name kept as
978 +// distinct headers. It's their responsibility to know whether that's safe
979 +// for their backend; see the doc comments on ExpectedUnderscoreHeaders and
980 +// ExpectedDotHeaders.
981 +func TestServer_serveHTTP_BothAllowlistsPermitSameLogicalName(t *testing.T) {
982 + got := &http.Header{}
983 + s := &Server{
984 + logger: zap.NewNop(),
985 + ExpectedUnderscoreHeaders: []string{"user_id"},
986 + ExpectedDotHeaders: []string{"user.id"},
987 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
988 + *got = r.Header.Clone()
989 + return nil
990 + }),
991 + }
992 + require.NoError(t, s.provisionUnderscoreHeaders())
993 + require.NoError(t, s.provisionDotHeaders())
994 +
995 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
996 + req.Header["User_id"] = []string{"alice"}
997 + req.Header["User.id"] = []string{"bob"}
998 +
999 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1000 + assert.Equal(t, "alice", got.Get("User_id"))
1001 + assert.Equal(t, "bob", got.Get("User.id"))
1002 +}
1003 +
1004 +// --- Headers with both underscore and dot in the same name ---
1005 +
1006 +// TestServer_serveHTTP_DropsMixedUnderscoreDotHeaderByDefault verifies a
1007 +// header spelled with both an underscore and a dot (e.g. "Webhook_user.id")
1008 +// is dropped by default, same as either separator alone.
1009 +func TestServer_serveHTTP_DropsMixedUnderscoreDotHeaderByDefault(t *testing.T) {
1010 + got := &http.Header{}
1011 + s := &Server{
1012 + logger: zap.NewNop(),
1013 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1014 + *got = r.Header.Clone()
1015 + return nil
1016 + }),
1017 + }
1018 +
1019 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1020 + req.Header["Webhook_user.id"] = []string{"attacker"}
1021 +
1022 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1023 + assert.NotContains(t, *got, "Webhook_user.id")
1024 +}
1025 +
1026 +// TestServer_serveHTTP_PrefixGlobDoesNotLeakMixedHeader guards against a
1027 +// prefix glob unintentionally admitting a mixed-separator header: an
1028 +// underscore prefix rule like "webhook_*" matches "Webhook_user.id" via
1029 +// plain string prefix, but the embedded dot still collides at a
1030 +// CGI/FastCGI/PHP backend the same way a bare dot-named header would, so
1031 +// it must not slip through just because it also happens to start with an
1032 +// allowlisted underscore prefix.
1033 +func TestServer_serveHTTP_PrefixGlobDoesNotLeakMixedHeader(t *testing.T) {
1034 + got := &http.Header{}
1035 + s := &Server{
1036 + logger: zap.NewNop(),
1037 + ExpectedUnderscoreHeaders: []string{"webhook_*"},
1038 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1039 + *got = r.Header.Clone()
1040 + return nil
1041 + }),
1042 + }
1043 + require.NoError(t, s.provisionUnderscoreHeaders())
1044 +
1045 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1046 + req.Header["Webhook_user.id"] = []string{"attacker"}
1047 +
1048 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1049 + assert.NotContains(t, *got, "Webhook_user.id")
1050 +}
1051 +
1052 +// TestServer_serveHTTP_ExactAllowlistPermitsMixedHeader verifies that an
1053 +// operator who explicitly vetted the exact mixed-separator spelling (not a
1054 +// glob) can still allowlist it.
1055 +func TestServer_serveHTTP_ExactAllowlistPermitsMixedHeader(t *testing.T) {
1056 + got := &http.Header{}
1057 + s := &Server{
1058 + logger: zap.NewNop(),
1059 + ExpectedUnderscoreHeaders: []string{"webhook_user.id"},
1060 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1061 + *got = r.Header.Clone()
1062 + return nil
1063 + }),
1064 + }
1065 + require.NoError(t, s.provisionUnderscoreHeaders())
1066 +
1067 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1068 + req.Header["Webhook_user.id"] = []string{"ok"}
1069 +
1070 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1071 + assert.Equal(t, "ok", got.Get("Webhook_user.id"))
1072 +}
1073 +
1074 +// TestServer_serveHTTP_ExactAllowlistDropsMixedHeaderRepeatedValues verifies
1075 +// the repeated-value guard also applies to an exactly-allowlisted mixed
1076 +// header.
1077 +func TestServer_serveHTTP_ExactAllowlistDropsMixedHeaderRepeatedValues(t *testing.T) {
1078 + got := &http.Header{}
1079 + s := &Server{
1080 + logger: zap.NewNop(),
1081 + ExpectedUnderscoreHeaders: []string{"webhook_user.id"},
1082 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1083 + *got = r.Header.Clone()
1084 + return nil
1085 + }),
1086 + }
1087 + require.NoError(t, s.provisionUnderscoreHeaders())
1088 +
1089 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1090 + req.Header["Webhook_user.id"] = []string{"ok", "injected"}
1091 +
1092 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1093 + assert.NotContains(t, *got, "Webhook_user.id")
1094 +}
1095 +
1096 // --- Combined allowlist ---
1097
1098 func TestServer_serveHTTP_ExactAndPrefixCoexist(t *testing.T) {
@@ -895,7 +1211,7 @@ func TestServer_serveHTTP_LogsHyphenatedVariantDrop(t *testing.T) {
1211
1212 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1213 assert.Contains(t, buf.String(), `"level":"debug"`)
898 - assert.Contains(t, buf.String(), `"msg":"dropping hyphenated variant of expected underscore header"`)
1214 + assert.Contains(t, buf.String(), `"msg":"dropping hyphenated variant of expected underscore/dot header"`)
1215 assert.Contains(t, buf.String(), `"header":"User-Id"`)
1216 }
1217
@@ -954,6 +1270,52 @@ func TestServer_provisionUnderscoreHeaders_DeduplicatesSilently(t *testing.T) {
1270 assert.Len(t, s.underscoreExactAllow, 1)
1271 }
1272
1273 +func TestServer_provisionDotHeaders_EmptyListIsNoOp(t *testing.T) {
1274 + s := &Server{ExpectedDotHeaders: []string{}}
1275 + assert.NoError(t, s.provisionDotHeaders())
1276 +}
1277 +
1278 +func TestServer_provisionDotHeaders_RejectsNoDot(t *testing.T) {
1279 + s := &Server{ExpectedDotHeaders: []string{"content-type"}}
1280 + assert.Error(t, s.provisionDotHeaders())
1281 +}
1282 +
1283 +func TestServer_provisionDotHeaders_RejectsBareWildcard(t *testing.T) {
1284 + s := &Server{ExpectedDotHeaders: []string{"*"}}
1285 + assert.Error(t, s.provisionDotHeaders())
1286 +}
1287 +
1288 +func TestServer_provisionDotHeaders_RejectsMidGlob(t *testing.T) {
1289 + s := &Server{ExpectedDotHeaders: []string{"f*oo.bar"}}
1290 + assert.Error(t, s.provisionDotHeaders())
1291 +}
1292 +
1293 +func TestServer_provisionDotHeaders_RejectsLeadingGlob(t *testing.T) {
1294 + s := &Server{ExpectedDotHeaders: []string{"*.foo"}}
1295 + assert.Error(t, s.provisionDotHeaders())
1296 +}
1297 +
1298 +func TestServer_provisionDotHeaders_RejectsNonASCII(t *testing.T) {
1299 + s := &Server{ExpectedDotHeaders: []string{"uşer.id"}}
1300 + assert.Error(t, s.provisionDotHeaders())
1301 +}
1302 +
1303 +func TestServer_provisionDotHeaders_ValidExact(t *testing.T) {
1304 + s := &Server{ExpectedDotHeaders: []string{"user.id"}}
1305 + assert.NoError(t, s.provisionDotHeaders())
1306 +}
1307 +
1308 +func TestServer_provisionDotHeaders_ValidGlob(t *testing.T) {
1309 + s := &Server{ExpectedDotHeaders: []string{"webhook.*"}}
1310 + assert.NoError(t, s.provisionDotHeaders())
1311 +}
1312 +
1313 +func TestServer_provisionDotHeaders_DeduplicatesSilently(t *testing.T) {
1314 + s := &Server{ExpectedDotHeaders: []string{"user.id", "user.id"}}
1315 + require.NoError(t, s.provisionDotHeaders())
1316 + assert.Len(t, s.dotExactAllow, 1)
1317 +}
1318 +
1319 // TestServer_SpaceInHeaderNameReturnsBadRequest documents why the underscore
1320 // filter does not also strip space-named headers: Go's HTTP parser rejects a
1321 // space in a field name with 400 before any handler runs, so such a request