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) {
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) {
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
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