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