@marcel91200 / caddy Mirror · read-only / commits / fcba554d
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

caddyhttp: New expected_underscore_headers server option (#7809)

* caddyhttp: restore allow_underscore_in_headers server option (#7808) * caddyhttp: mark insecure_allow_underscore_in_headers as EXPERIMENTAL * caddyhttp: replace underscore bool with expected_underscore_headers allowlist * fix gofmt alignment in serveroptions.go * caddyhttp: drop repeated allowlisted underscore headers * caddyhttp: add tests for repeated-value drop and variant-drop logging

Bluegate Studio committed Jun 12, 2026 at 07:26 UTC fcba554d658b0c5fa36f715b77094bb9f02ce799
4 files changed +606 -26
caddyconfig/httpcaddyfile/serveroptions.go
+30 -21
@@ -36,27 +36,28 @@ type serverOptions struct {
36 ListenerAddress string
37
38 // These will all map 1:1 to the caddyhttp.Server struct
39 - Name string
40 - ListenerWrappersRaw []json.RawMessage
41 - PacketConnWrappersRaw []json.RawMessage
42 - ReadTimeout caddy.Duration
43 - ReadHeaderTimeout caddy.Duration
44 - WriteTimeout caddy.Duration
45 - IdleTimeout caddy.Duration
46 - KeepAliveInterval caddy.Duration
47 - KeepAliveIdle caddy.Duration
48 - KeepAliveCount int
49 - MaxHeaderBytes int
50 - EnableFullDuplex bool
51 - Protocols []string
52 - StrictSNIHost *bool
53 - TrustedProxiesRaw json.RawMessage
54 - TrustedProxiesStrict int
55 - TrustedProxiesUnix bool
56 - ClientIPHeaders []string
57 - ShouldLogCredentials bool
58 - Metrics *caddyhttp.Metrics
59 - Trace bool // TODO: EXPERIMENTAL
39 + Name string
40 + ListenerWrappersRaw []json.RawMessage
41 + PacketConnWrappersRaw []json.RawMessage
42 + ReadTimeout caddy.Duration
43 + ReadHeaderTimeout caddy.Duration
44 + WriteTimeout caddy.Duration
45 + IdleTimeout caddy.Duration
46 + KeepAliveInterval caddy.Duration
47 + KeepAliveIdle caddy.Duration
48 + KeepAliveCount int
49 + MaxHeaderBytes int
50 + EnableFullDuplex bool
51 + ExpectedUnderscoreHeaders []string
52 + Protocols []string
53 + StrictSNIHost *bool
54 + TrustedProxiesRaw json.RawMessage
55 + TrustedProxiesStrict int
56 + TrustedProxiesUnix bool
57 + ClientIPHeaders []string
58 + ShouldLogCredentials bool
59 + Metrics *caddyhttp.Metrics
60 + Trace bool // TODO: EXPERIMENTAL
61 // If set, overrides whether QUIC listeners allow 0-RTT (early data).
62 // If nil, the default behavior is used (currently allowed).
63 Allow0RTT *bool
@@ -218,6 +219,13 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
219 }
220 serverOpts.EnableFullDuplex = true
221
222 + case "expected_underscore_headers":
223 + args := d.RemainingArgs()
224 + if len(args) == 0 {
225 + return nil, d.ArgErr()
226 + }
227 + serverOpts.ExpectedUnderscoreHeaders = args
228 +
229 case "log_credentials":
230 if d.NextArg() {
231 return nil, d.ArgErr()
@@ -380,6 +388,7 @@ func applyServerOptions(
388 server.KeepAliveCount = opts.KeepAliveCount
389 server.MaxHeaderBytes = opts.MaxHeaderBytes
390 server.EnableFullDuplex = opts.EnableFullDuplex
391 + server.ExpectedUnderscoreHeaders = opts.ExpectedUnderscoreHeaders
392 server.Protocols = opts.Protocols
393 server.StrictSNIHost = opts.StrictSNIHost
394 server.TrustedProxiesRaw = opts.TrustedProxiesRaw
modules/caddyhttp/app.go
+5
@@ -293,6 +293,11 @@ func (app *App) Provision(ctx caddy.Context) error {
293 srv.ClientIPHeaders = []string{"X-Forwarded-For"}
294 }
295
296 + // precompute underscore header allowlist rules
297 + if err := srv.provisionUnderscoreHeaders(); err != nil {
298 + return fmt.Errorf("server %s: %v", srvName, err)
299 + }
300 +
301 // process each listener address
302 for i := range srv.Listen {
303 lnOut, err := repl.ReplaceOrErr(srv.Listen[i], true, true)
modules/caddyhttp/server.go
+144 -5
@@ -124,6 +124,22 @@ type Server struct {
124 // TODO: This is an EXPERIMENTAL feature. Subject to change or removal.
125 EnableFullDuplex bool `json:"enable_full_duplex,omitempty"`
126
127 + // A list of header field names containing underscores that should
128 + // be preserved instead of being dropped. By default, Caddy drops
129 + // ALL headers with underscores to prevent ambiguity with
130 + // CGI/FastCGI backends that map hyphens to underscores
131 + // (GHSA-f59h-q822-g45g). When this list is configured, only the
132 + // specified headers are kept; their hyphenated variants are
133 + // actively dropped to prevent confusion. Entries are
134 + // case-insensitive. A trailing "*" acts as a prefix glob
135 + // (e.g., "webhook_*" matches any header starting with
136 + // "webhook_"). If an allowlisted header arrives with
137 + // multiple values (repeated field), all values are dropped
138 + // as a safeguard against header injection.
139 + //
140 + // TODO: This is an EXPERIMENTAL feature. Subject to change or removal.
141 + ExpectedUnderscoreHeaders []string `json:"expected_underscore_headers,omitempty"`
142 +
143 // Routes describes how this server will handle requests.
144 // Routes are executed sequentially. First a route's matchers
145 // are evaluated, then its grouping. If it matches and has
@@ -293,6 +309,11 @@ type Server struct {
309
310 shutdownAt atomic.Pointer[time.Time]
311
312 + // precomputed underscore header allowlist (built during provisioning)
313 + underscoreExactAllow map[string]struct{}
314 + underscoreExactDrop map[string]struct{}
315 + underscorePrefixRules []underscoreRule
316 +
317 // registered callback functions
318 connStateFuncs []func(net.Conn, http.ConnState)
319 connContextFuncs []func(ctx context.Context, c net.Conn) context.Context
@@ -300,6 +321,95 @@ type Server struct {
321 onStopFuncs []func(context.Context) error // TODO: Experimental (Nov. 2023)
322 }
323
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_"
328 + drop string // canonical hyphenated form, e.g. "Webhook-"
329 +}
330 +
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 + }
338 +
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 {
343 + // Reject non-ASCII bytes: Go's HTTP parser returns 400 for
344 + // non-ASCII header names, so such entries can never match.
345 + for i := 0; i < len(entry); i++ {
346 + if entry[i] >= 0x80 {
347 + return fmt.Errorf("expected_underscore_headers: entry %q contains non-ASCII characters", entry)
348 + }
349 + }
350 +
351 + isGlob := strings.HasSuffix(entry, "*")
352 + name := entry
353 + if isGlob {
354 + name = strings.TrimSuffix(entry, "*")
355 + }
356 +
357 + // Reject entries with '*' not at the trailing position.
358 + if strings.ContainsRune(name, '*') {
359 + return fmt.Errorf("expected_underscore_headers: entry %q has '*' in an invalid position (only a trailing '*' is allowed)", entry)
360 + }
361 +
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)
365 + }
366 +
367 + canonAllow := http.CanonicalHeaderKey(name)
368 + canonDrop := http.CanonicalHeaderKey(strings.ReplaceAll(name, "_", "-"))
369 +
370 + if isGlob {
371 + s.underscorePrefixRules = append(s.underscorePrefixRules, underscoreRule{
372 + allow: canonAllow,
373 + drop: canonDrop,
374 + })
375 + } else {
376 + s.underscoreExactAllow[canonAllow] = struct{}{}
377 + s.underscoreExactDrop[canonDrop] = struct{}{}
378 + }
379 + }
380 +
381 + return nil
382 +}
383 +
384 +// isAllowedUnderscoreHeader reports whether key (a canonical header
385 +// name containing an underscore) is permitted by the allowlist.
386 +func (s *Server) isAllowedUnderscoreHeader(key string) bool {
387 + if _, ok := s.underscoreExactAllow[key]; ok {
388 + return true
389 + }
390 + for _, rule := range s.underscorePrefixRules {
391 + if strings.HasPrefix(key, rule.allow) {
392 + return true
393 + }
394 + }
395 + return false
396 +}
397 +
398 +// 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.
401 +func (s *Server) isHyphenatedVariant(key string) bool {
402 + if _, ok := s.underscoreExactDrop[key]; ok {
403 + return true
404 + }
405 + for _, rule := range s.underscorePrefixRules {
406 + if strings.HasPrefix(key, rule.drop) {
407 + return true
408 + }
409 + }
410 + return false
411 +}
412 +
413 var defaultProtocols = []string{"h1", "h2", "h3"}
414
415 var (
@@ -497,12 +607,41 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) error {
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).
500 - for k := range r.Header {
501 - if strings.ContainsRune(k, '_') {
502 - delete(r.Header, k)
610 + //
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, '_') {
616 + delete(r.Header, k)
617 +
618 + if c := s.logger.Check(zapcore.DebugLevel, "dropping header containing underscore"); c != nil {
619 + c.Write(zap.String("header", k))
620 + }
621 + }
622 + }
623 + } else {
624 + for k := range r.Header {
625 + if strings.ContainsRune(k, '_') {
626 + if !s.isAllowedUnderscoreHeader(k) {
627 + delete(r.Header, k)
628
504 - if c := s.logger.Check(zapcore.DebugLevel, "dropping header containing underscore"); c != nil {
505 - c.Write(zap.String("header", k))
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)
634 +
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 + }
638 + }
639 + } else if s.isHyphenatedVariant(k) {
640 + delete(r.Header, k)
641 +
642 + if c := s.logger.Check(zapcore.DebugLevel, "dropping hyphenated variant of expected underscore header"); c != nil {
643 + c.Write(zap.String("header", k))
644 + }
645 }
646 }
647 }
modules/caddyhttp/server_test.go
+427
@@ -527,6 +527,433 @@ func TestServer_serveHTTP_LogsDroppedUnderscoreHeader(t *testing.T) {
527 assert.Contains(t, buf.String(), `"header":"Remote_user"`)
528 }
529
530 +// --- Allowlist: exact match ---
531 +
532 +func TestServer_serveHTTP_AllowlistKeepsExactMatch(t *testing.T) {
533 + got := &http.Header{}
534 + s := &Server{
535 + logger: zap.NewNop(),
536 + ExpectedUnderscoreHeaders: []string{"user_id"},
537 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
538 + *got = r.Header.Clone()
539 + return nil
540 + }),
541 + }
542 + require.NoError(t, s.provisionUnderscoreHeaders())
543 +
544 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
545 + req.Header["User_id"] = []string{"zeus"}
546 +
547 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
548 + assert.Equal(t, "zeus", got.Get("User_id"))
549 +}
550 +
551 +func TestServer_serveHTTP_AllowlistDropsHyphenatedVariant(t *testing.T) {
552 + got := &http.Header{}
553 + s := &Server{
554 + logger: zap.NewNop(),
555 + ExpectedUnderscoreHeaders: []string{"user_id"},
556 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
557 + *got = r.Header.Clone()
558 + return nil
559 + }),
560 + }
561 + require.NoError(t, s.provisionUnderscoreHeaders())
562 +
563 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
564 + req.Header["User_id"] = []string{"zeus"}
565 + req.Header.Set("User-Id", "attacker")
566 +
567 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
568 + assert.Equal(t, "zeus", got.Get("User_id"))
569 + assert.NotContains(t, *got, "User-Id")
570 +}
571 +
572 +func TestServer_serveHTTP_AllowlistDropsUnlisted(t *testing.T) {
573 + got := &http.Header{}
574 + s := &Server{
575 + logger: zap.NewNop(),
576 + ExpectedUnderscoreHeaders: []string{"user_id"},
577 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
578 + *got = r.Header.Clone()
579 + return nil
580 + }),
581 + }
582 + require.NoError(t, s.provisionUnderscoreHeaders())
583 +
584 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
585 + req.Header["User_id"] = []string{"zeus"}
586 + req.Header["Remote_user"] = []string{"attacker"}
587 +
588 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
589 + assert.Equal(t, "zeus", got.Get("User_id"))
590 + assert.NotContains(t, *got, "Remote_user")
591 +}
592 +
593 +func TestServer_serveHTTP_AllowlistPassesThroughNormalHeaders(t *testing.T) {
594 + got := &http.Header{}
595 + s := &Server{
596 + logger: zap.NewNop(),
597 + ExpectedUnderscoreHeaders: []string{"user_id"},
598 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
599 + *got = r.Header.Clone()
600 + return nil
601 + }),
602 + }
603 + require.NoError(t, s.provisionUnderscoreHeaders())
604 +
605 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
606 + req.Header.Set("X-Real-Header", "ok")
607 + req.Header.Set("Content-Type", "text/plain")
608 +
609 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
610 + assert.Equal(t, "ok", got.Get("X-Real-Header"))
611 + assert.Equal(t, "text/plain", got.Get("Content-Type"))
612 +}
613 +
614 +// --- Allowlist: mixed underscore/hyphen entry ---
615 +
616 +func TestServer_serveHTTP_MixedEntryKeepsOriginal(t *testing.T) {
617 + got := &http.Header{}
618 + s := &Server{
619 + logger: zap.NewNop(),
620 + ExpectedUnderscoreHeaders: []string{"__user-id"},
621 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
622 + *got = r.Header.Clone()
623 + return nil
624 + }),
625 + }
626 + require.NoError(t, s.provisionUnderscoreHeaders())
627 +
628 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
629 + req.Header["__user-Id"] = []string{"zeus"} // canonical form of __user-id
630 +
631 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
632 + assert.Equal(t, "zeus", got.Get("__user-Id"))
633 +}
634 +
635 +func TestServer_serveHTTP_MixedEntryDropsFullyHyphenated(t *testing.T) {
636 + got := &http.Header{}
637 + s := &Server{
638 + logger: zap.NewNop(),
639 + ExpectedUnderscoreHeaders: []string{"__user-id"},
640 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
641 + *got = r.Header.Clone()
642 + return nil
643 + }),
644 + }
645 + require.NoError(t, s.provisionUnderscoreHeaders())
646 +
647 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
648 + req.Header["--User-Id"] = []string{"attacker"} // fully hyphenated variant
649 +
650 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
651 + assert.NotContains(t, *got, "--User-Id")
652 +}
653 +
654 +func TestServer_serveHTTP_MixedEntryDropsPartialVariants(t *testing.T) {
655 + got := &http.Header{}
656 + s := &Server{
657 + logger: zap.NewNop(),
658 + ExpectedUnderscoreHeaders: []string{"__user-id"},
659 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
660 + *got = r.Header.Clone()
661 + return nil
662 + }),
663 + }
664 + require.NoError(t, s.provisionUnderscoreHeaders())
665 +
666 + // All partial variants still contain underscores and don't match
667 + // the allowlist, so they are dropped by the normal underscore filter.
668 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
669 + req.Header["-_user-Id"] = []string{"attacker1"} // canonical of -_user-id
670 + req.Header["_-User-Id"] = []string{"attacker2"} // canonical of _-user-id
671 + req.Header["__user_id"] = []string{"attacker3"} // all underscores variant
672 +
673 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
674 + assert.NotContains(t, *got, "-_user-Id")
675 + assert.NotContains(t, *got, "_-User-Id")
676 + assert.NotContains(t, *got, "__user_id")
677 +}
678 +
679 +// --- Allowlist: prefix glob ---
680 +
681 +func TestServer_serveHTTP_PrefixGlobKeepsMatch(t *testing.T) {
682 + got := &http.Header{}
683 + s := &Server{
684 + logger: zap.NewNop(),
685 + ExpectedUnderscoreHeaders: []string{"webhook_*"},
686 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
687 + *got = r.Header.Clone()
688 + return nil
689 + }),
690 + }
691 + require.NoError(t, s.provisionUnderscoreHeaders())
692 +
693 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
694 + req.Header["Webhook_event"] = []string{"push"}
695 +
696 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
697 + assert.Equal(t, "push", got.Get("Webhook_event"))
698 +}
699 +
700 +func TestServer_serveHTTP_PrefixGlobDropsHyphenatedVariant(t *testing.T) {
701 + got := &http.Header{}
702 + s := &Server{
703 + logger: zap.NewNop(),
704 + ExpectedUnderscoreHeaders: []string{"webhook_*"},
705 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
706 + *got = r.Header.Clone()
707 + return nil
708 + }),
709 + }
710 + require.NoError(t, s.provisionUnderscoreHeaders())
711 +
712 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
713 + req.Header.Set("Webhook-Event", "push")
714 +
715 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
716 + assert.NotContains(t, *got, "Webhook-Event")
717 +}
718 +
719 +func TestServer_serveHTTP_PrefixGlobDropsNonMatching(t *testing.T) {
720 + got := &http.Header{}
721 + s := &Server{
722 + logger: zap.NewNop(),
723 + ExpectedUnderscoreHeaders: []string{"webhook_*"},
724 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
725 + *got = r.Header.Clone()
726 + return nil
727 + }),
728 + }
729 + require.NoError(t, s.provisionUnderscoreHeaders())
730 +
731 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
732 + req.Header["Other_header"] = []string{"val"}
733 +
734 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
735 + assert.NotContains(t, *got, "Other_header")
736 +}
737 +
738 +func TestServer_serveHTTP_PrefixGlobDropsMixedVariant(t *testing.T) {
739 + got := &http.Header{}
740 + s := &Server{
741 + logger: zap.NewNop(),
742 + ExpectedUnderscoreHeaders: []string{"webhook_*"},
743 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
744 + *got = r.Header.Clone()
745 + return nil
746 + }),
747 + }
748 + require.NoError(t, s.provisionUnderscoreHeaders())
749 +
750 + // "Webhook-Event_type" has underscores but starts with "Webhook-Event_",
751 + // which does NOT match the allow prefix "Webhook_", so it is dropped.
752 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
753 + req.Header["Webhook-Event_type"] = []string{"push"}
754 +
755 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
756 + assert.NotContains(t, *got, "Webhook-Event_type")
757 +}
758 +
759 +func TestServer_serveHTTP_LiteralAsteriskInHeader(t *testing.T) {
760 + got := &http.Header{}
761 + s := &Server{
762 + logger: zap.NewNop(),
763 + ExpectedUnderscoreHeaders: []string{"webhook_*"},
764 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
765 + *got = r.Header.Clone()
766 + return nil
767 + }),
768 + }
769 + require.NoError(t, s.provisionUnderscoreHeaders())
770 +
771 + // A header literally named "Webhook_*" matches the prefix rule
772 + // because "Webhook_" is a prefix of "Webhook_*".
773 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
774 + req.Header["Webhook_*"] = []string{"val"}
775 +
776 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
777 + assert.Equal(t, "val", got.Get("Webhook_*"))
778 +}
779 +
780 +// --- Combined allowlist ---
781 +
782 +func TestServer_serveHTTP_ExactAndPrefixCoexist(t *testing.T) {
783 + got := &http.Header{}
784 + s := &Server{
785 + logger: zap.NewNop(),
786 + ExpectedUnderscoreHeaders: []string{"user_id", "webhook_*"},
787 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
788 + *got = r.Header.Clone()
789 + return nil
790 + }),
791 + }
792 + require.NoError(t, s.provisionUnderscoreHeaders())
793 +
794 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
795 + req.Header["User_id"] = []string{"zeus"} // exact match → keep
796 + req.Header["Webhook_event"] = []string{"push"} // prefix match → keep
797 + req.Header["Other_field"] = []string{"bad"} // unlisted → drop
798 + req.Header.Set("User-Id", "attacker") // hyphenated exact → drop
799 + req.Header.Set("Webhook-Event", "attacker") // hyphenated prefix → drop
800 + req.Header.Set("X-Normal-Header", "ok") // no underscore, not a variant → pass through
801 +
802 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
803 + assert.Equal(t, "zeus", got.Get("User_id"))
804 + assert.Equal(t, "push", got.Get("Webhook_event"))
805 + assert.Equal(t, "ok", got.Get("X-Normal-Header"))
806 + assert.NotContains(t, *got, "Other_field")
807 + assert.NotContains(t, *got, "User-Id")
808 + assert.NotContains(t, *got, "Webhook-Event")
809 +}
810 +
811 +// --- Allowlist: repeated values ---
812 +
813 +// TestServer_serveHTTP_AllowlistDropsRepeatedExact verifies that an
814 +// allowlisted header arriving with multiple values (repeated field)
815 +// is dropped entirely as a safeguard against header injection.
816 +func TestServer_serveHTTP_AllowlistDropsRepeatedExact(t *testing.T) {
817 + got := &http.Header{}
818 + s := &Server{
819 + logger: zap.NewNop(),
820 + ExpectedUnderscoreHeaders: []string{"user_id"},
821 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
822 + *got = r.Header.Clone()
823 + return nil
824 + }),
825 + }
826 + require.NoError(t, s.provisionUnderscoreHeaders())
827 +
828 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
829 + req.Header["User_id"] = []string{"zeus", "injected"}
830 +
831 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
832 + assert.NotContains(t, *got, "User_id")
833 +}
834 +
835 +// TestServer_serveHTTP_AllowlistDropsRepeatedPrefixGlob verifies that a
836 +// glob-matched header arriving with multiple values is dropped entirely.
837 +func TestServer_serveHTTP_AllowlistDropsRepeatedPrefixGlob(t *testing.T) {
838 + got := &http.Header{}
839 + s := &Server{
840 + logger: zap.NewNop(),
841 + ExpectedUnderscoreHeaders: []string{"webhook_*"},
842 + primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
843 + *got = r.Header.Clone()
844 + return nil
845 + }),
846 + }
847 + require.NoError(t, s.provisionUnderscoreHeaders())
848 +
849 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
850 + req.Header["Webhook_event"] = []string{"push", "injected"}
851 +
852 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
853 + assert.NotContains(t, *got, "Webhook_event")
854 +}
855 +
856 +// TestServer_serveHTTP_LogsRepeatedValueDrop verifies that dropping an
857 +// allowlisted header with repeated values emits a warn-level log with
858 +// the header name and value count.
859 +func TestServer_serveHTTP_LogsRepeatedValueDrop(t *testing.T) {
860 + var buf bytes.Buffer
861 + s := &Server{
862 + logger: testLogger(buf.Write),
863 + ExpectedUnderscoreHeaders: []string{"user_id"},
864 + primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
865 + return nil
866 + }),
867 + }
868 + require.NoError(t, s.provisionUnderscoreHeaders())
869 +
870 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
871 + req.Header["User_id"] = []string{"zeus", "injected"}
872 +
873 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
874 + assert.Contains(t, buf.String(), `"level":"warn"`)
875 + assert.Contains(t, buf.String(), `"msg":"dropping allowlisted underscore header with repeated values (possible spoofing)"`)
876 + assert.Contains(t, buf.String(), `"header":"User_id"`)
877 + assert.Contains(t, buf.String(), `"count":2`)
878 +}
879 +
880 +// TestServer_serveHTTP_LogsHyphenatedVariantDrop verifies that dropping a
881 +// hyphenated variant of an allowlisted header emits a debug-level log.
882 +func TestServer_serveHTTP_LogsHyphenatedVariantDrop(t *testing.T) {
883 + var buf bytes.Buffer
884 + s := &Server{
885 + logger: testLogger(buf.Write),
886 + ExpectedUnderscoreHeaders: []string{"user_id"},
887 + primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
888 + return nil
889 + }),
890 + }
891 + require.NoError(t, s.provisionUnderscoreHeaders())
892 +
893 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
894 + req.Header.Set("User-Id", "attacker")
895 +
896 + require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
897 + assert.Contains(t, buf.String(), `"level":"debug"`)
898 + assert.Contains(t, buf.String(), `"msg":"dropping hyphenated variant of expected underscore header"`)
899 + assert.Contains(t, buf.String(), `"header":"User-Id"`)
900 +}
901 +
902 +// --- Validation ---
903 +
904 +func TestServer_provisionUnderscoreHeaders_EmptyListIsNoOp(t *testing.T) {
905 + s := &Server{ExpectedUnderscoreHeaders: []string{}}
906 + // Empty slice is treated as "no allowlist" — provisionUnderscoreHeaders
907 + // returns nil (no error) because len == 0 is a no-op.
908 + assert.NoError(t, s.provisionUnderscoreHeaders())
909 +}
910 +
911 +func TestServer_provisionUnderscoreHeaders_RejectsNoUnderscore(t *testing.T) {
912 + s := &Server{ExpectedUnderscoreHeaders: []string{"content-type"}}
913 + assert.Error(t, s.provisionUnderscoreHeaders())
914 +}
915 +
916 +func TestServer_provisionUnderscoreHeaders_RejectsBareWildcard(t *testing.T) {
917 + s := &Server{ExpectedUnderscoreHeaders: []string{"*"}}
918 + assert.Error(t, s.provisionUnderscoreHeaders())
919 +}
920 +
921 +func TestServer_provisionUnderscoreHeaders_RejectsMidGlob(t *testing.T) {
922 + s := &Server{ExpectedUnderscoreHeaders: []string{"f*oo_bar"}}
923 + assert.Error(t, s.provisionUnderscoreHeaders())
924 +}
925 +
926 +func TestServer_provisionUnderscoreHeaders_RejectsLeadingGlob(t *testing.T) {
927 + s := &Server{ExpectedUnderscoreHeaders: []string{"*_foo"}}
928 + assert.Error(t, s.provisionUnderscoreHeaders())
929 +}
930 +
931 +func TestServer_provisionUnderscoreHeaders_RejectsNonASCII(t *testing.T) {
932 + s := &Server{ExpectedUnderscoreHeaders: []string{"uşer_id"}}
933 + assert.Error(t, s.provisionUnderscoreHeaders())
934 +}
935 +
936 +func TestServer_provisionUnderscoreHeaders_ValidExact(t *testing.T) {
937 + s := &Server{ExpectedUnderscoreHeaders: []string{"user_id"}}
938 + assert.NoError(t, s.provisionUnderscoreHeaders())
939 +}
940 +
941 +func TestServer_provisionUnderscoreHeaders_ValidGlob(t *testing.T) {
942 + s := &Server{ExpectedUnderscoreHeaders: []string{"webhook_*"}}
943 + assert.NoError(t, s.provisionUnderscoreHeaders())
944 +}
945 +
946 +func TestServer_provisionUnderscoreHeaders_ValidMixed(t *testing.T) {
947 + s := &Server{ExpectedUnderscoreHeaders: []string{"__user-id"}}
948 + assert.NoError(t, s.provisionUnderscoreHeaders())
949 +}
950 +
951 +func TestServer_provisionUnderscoreHeaders_DeduplicatesSilently(t *testing.T) {
952 + s := &Server{ExpectedUnderscoreHeaders: []string{"user_id", "user_id"}}
953 + require.NoError(t, s.provisionUnderscoreHeaders())
954 + assert.Len(t, s.underscoreExactAllow, 1)
955 +}
956 +
957 // TestServer_SpaceInHeaderNameReturnsBadRequest documents why the underscore
958 // filter does not also strip space-named headers: Go's HTTP parser rejects a
959 // space in a field name with 400 before any handler runs, so such a request