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
logging: add set_cookie log filter for Set-Cookie response headers (#7888)
Steffen Busch committed
Aug 13, 2026 at 06:59 UTC
50e54ee279aa1e504fe218ca49ab6ae16c100410
2 files changed
+295
modules/logging/filters.go
+176
@@ -24,6 +24,7 @@ import (
24
"regexp"
25
"strconv"
26
"strings"
27
+ "unicode"
28
29
"go.uber.org/zap/zapcore"
30
@@ -39,6 +40,7 @@ func init() {
40
caddy.RegisterModule(IPMaskFilter{})
41
caddy.RegisterModule(QueryFilter{})
42
caddy.RegisterModule(CookieFilter{})
43
+ caddy.RegisterModule(SetCookieFilter{})
44
caddy.RegisterModule(RegexpFilter{})
45
caddy.RegisterModule(RenameFilter{})
46
caddy.RegisterModule(MultiRegexpFilter{})
@@ -564,6 +566,177 @@ OUTER:
566
return in
567
}
568
569
+// SetCookieFilter is a Caddy log field filter that filters
570
+// Set-Cookie header values.
571
+//
572
+// This filter updates logged Set-Cookie header values to remove,
573
+// replace or hash cookies containing sensitive data while preserving
574
+// the original attribute suffix verbatim.
575
+//
576
+// It is typically used with access log fields such as
577
+// "resp_headers>Set-Cookie".
578
+//
579
+// If several actions are configured for the same cookie name, only the first
580
+// will be applied.
581
+type SetCookieFilter struct {
582
+ // A list of actions to apply to the Set-Cookie values.
583
+ Actions []cookieFilterAction `json:"actions"`
584
+}
585
+
586
+// Validate checks that action types are correct.
587
+func (f *SetCookieFilter) Validate() error {
588
+ for _, a := range f.Actions {
589
+ if err := a.Type.IsValid(); err != nil {
590
+ return err
591
+ }
592
+ }
593
+
594
+ return nil
595
+}
596
+
597
+// CaddyModule returns the Caddy module information.
598
+func (SetCookieFilter) CaddyModule() caddy.ModuleInfo {
599
+ return caddy.ModuleInfo{
600
+ ID: "caddy.logging.encoders.filter.set_cookie",
601
+ New: func() caddy.Module { return new(SetCookieFilter) },
602
+ }
603
+}
604
+
605
+// UnmarshalCaddyfile sets up the module from Caddyfile tokens.
606
+func (m *SetCookieFilter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
607
+ d.Next() // consume filter name
608
+ for d.NextBlock(0) {
609
+ cfa := cookieFilterAction{}
610
+ switch d.Val() {
611
+ case "replace":
612
+ if !d.NextArg() {
613
+ return d.ArgErr()
614
+ }
615
+
616
+ cfa.Type = replaceAction
617
+ cfa.Name = d.Val()
618
+
619
+ if !d.NextArg() {
620
+ return d.ArgErr()
621
+ }
622
+ cfa.Value = d.Val()
623
+
624
+ case "hash":
625
+ if !d.NextArg() {
626
+ return d.ArgErr()
627
+ }
628
+
629
+ cfa.Type = hashAction
630
+ cfa.Name = d.Val()
631
+
632
+ case "delete":
633
+ if !d.NextArg() {
634
+ return d.ArgErr()
635
+ }
636
+
637
+ cfa.Type = deleteAction
638
+ cfa.Name = d.Val()
639
+
640
+ default:
641
+ return d.Errf("unrecognized subdirective %s", d.Val())
642
+ }
643
+
644
+ m.Actions = append(m.Actions, cfa)
645
+ }
646
+ return nil
647
+}
648
+
649
+// Filter filters the input field.
650
+func (m SetCookieFilter) Filter(in zapcore.Field) zapcore.Field {
651
+ setCookies, ok := in.Interface.(internal.LoggableStringArray)
652
+ if !ok {
653
+ return in
654
+ }
655
+
656
+ transformed := make(internal.LoggableStringArray, 0, len(setCookies))
657
+ for _, line := range setCookies {
658
+ filtered, keep := m.filterLine(line)
659
+ if keep {
660
+ transformed = append(transformed, filtered)
661
+ }
662
+ }
663
+
664
+ in.Interface = transformed
665
+
666
+ return in
667
+}
668
+
669
+func (m SetCookieFilter) filterLine(line string) (string, bool) {
670
+ cookie, err := http.ParseSetCookie(line)
671
+ if err != nil {
672
+ return line, true
673
+ }
674
+
675
+ for _, a := range m.Actions {
676
+ if cookie.Name != a.Name {
677
+ continue
678
+ }
679
+
680
+ switch a.Type {
681
+ case replaceAction:
682
+ return replaceSetCookieValue(line, cookie, a.Value), true
683
+
684
+ case hashAction:
685
+ // A Set-Cookie-based deletion may still have an empty value
686
+ // (for example with Max-Age=0). As with the existing hash filter,
687
+ // hashing an empty value yields the SHA-256 prefix "e3b0c442".
688
+ return replaceSetCookieValue(line, cookie, hash(cookie.Value)), true
689
+
690
+ case deleteAction:
691
+ return "", false
692
+ }
693
+ }
694
+
695
+ return line, true
696
+}
697
+
698
+// replaceSetCookieValue keeps the original Set-Cookie suffix verbatim and only
699
+// swaps out the leading cookie value after ParseSetCookie has identified it.
700
+func replaceSetCookieValue(line string, cookie *http.Cookie, newValue string) string {
701
+ // Only operate on the first Set-Cookie segment: "name=value".
702
+ headEnd := strings.IndexByte(line, ';')
703
+ if headEnd == -1 {
704
+ headEnd = len(line)
705
+ }
706
+
707
+ head := line[:headEnd]
708
+ eq := strings.IndexByte(head, '=')
709
+ if eq == -1 {
710
+ return line
711
+ }
712
+
713
+ name := strings.TrimSpace(head[:eq])
714
+ if name != cookie.Name {
715
+ return line
716
+ }
717
+
718
+ // Preserve the original spacing around the value so the logged header stays
719
+ // as close as possible to the original input.
720
+ rawValue := head[eq+1:]
721
+ trimmedValue := strings.TrimSpace(rawValue)
722
+ leadingWhitespaceLen := len(rawValue) - len(strings.TrimLeftFunc(rawValue, unicode.IsSpace))
723
+ trailingWhitespaceLen := len(rawValue) - len(strings.TrimRightFunc(rawValue, unicode.IsSpace))
724
+
725
+ replacement := newValue
726
+ // If the original cookie value was quoted, keep the replacement quoted too.
727
+ if cookie.Quoted && len(trimmedValue) >= 2 && trimmedValue[0] == '"' && trimmedValue[len(trimmedValue)-1] == '"' {
728
+ replacement = `"` + newValue + `"`
729
+ }
730
+
731
+ // Rebuild only the first "name=value" pair and keep the attribute suffix
732
+ // byte-for-byte unchanged.
733
+ return line[:eq+1] +
734
+ rawValue[:leadingWhitespaceLen] +
735
+ replacement +
736
+ rawValue[len(rawValue)-trailingWhitespaceLen:] +
737
+ line[headEnd:]
738
+}
739
+
740
// RegexpFilter is a Caddy log field filter that
741
// replaces the field matching the provided regexp
742
// with the indicated string. If the field is an
@@ -879,6 +1052,7 @@ var (
1052
_ LogFieldFilter = (*IPMaskFilter)(nil)
1053
_ LogFieldFilter = (*QueryFilter)(nil)
1054
_ LogFieldFilter = (*CookieFilter)(nil)
1055
+ _ LogFieldFilter = (*SetCookieFilter)(nil)
1056
_ LogFieldFilter = (*RegexpFilter)(nil)
1057
_ LogFieldFilter = (*RenameFilter)(nil)
1058
_ LogFieldFilter = (*MultiRegexpFilter)(nil)
@@ -889,6 +1063,7 @@ var (
1063
_ caddyfile.Unmarshaler = (*IPMaskFilter)(nil)
1064
_ caddyfile.Unmarshaler = (*QueryFilter)(nil)
1065
_ caddyfile.Unmarshaler = (*CookieFilter)(nil)
1066
+ _ caddyfile.Unmarshaler = (*SetCookieFilter)(nil)
1067
_ caddyfile.Unmarshaler = (*RegexpFilter)(nil)
1068
_ caddyfile.Unmarshaler = (*RenameFilter)(nil)
1069
_ caddyfile.Unmarshaler = (*MultiRegexpFilter)(nil)
@@ -898,5 +1073,6 @@ var (
1073
_ caddy.Provisioner = (*MultiRegexpFilter)(nil)
1074
1075
_ caddy.Validator = (*QueryFilter)(nil)
1076
+ _ caddy.Validator = (*SetCookieFilter)(nil)
1077
_ caddy.Validator = (*MultiRegexpFilter)(nil)
1078
)
modules/logging/filters_test.go
+119
@@ -190,6 +190,125 @@ func TestValidateCookieFilter(t *testing.T) {
190
}
191
}
192
193
+func TestSetCookieFilter(t *testing.T) {
194
+ f := SetCookieFilter{[]cookieFilterAction{
195
+ {replaceAction, "foo", "REDACTED"},
196
+ {deleteAction, "bar", ""},
197
+ {hashAction, "hash", ""},
198
+ }}
199
+
200
+ out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{
201
+ `foo="a"; Path=/; Priority=High`,
202
+ "bar=c; Path=/; Secure",
203
+ "hash=hashed; Path=/; HttpOnly",
204
+ "baz=e; Path=/; SameSite=Strict",
205
+ }})
206
+ outval := out.Interface.(internal.LoggableStringArray)
207
+ expected := internal.LoggableStringArray{
208
+ `foo="REDACTED"; Path=/; Priority=High`,
209
+ "hash=1a06df82; Path=/; HttpOnly",
210
+ "baz=e; Path=/; SameSite=Strict",
211
+ }
212
+ if len(outval) != len(expected) {
213
+ t.Fatalf("unexpected number of Set-Cookie values: got %d, want %d", len(outval), len(expected))
214
+ }
215
+ for i := range expected {
216
+ if outval[i] != expected[i] {
217
+ t.Fatalf("Set-Cookie entry %d has not been filtered correctly: got %q, want %q", i, outval[i], expected[i])
218
+ }
219
+ }
220
+}
221
+
222
+func TestSetCookieFilterInvalidHeaderPassesThrough(t *testing.T) {
223
+ f := SetCookieFilter{[]cookieFilterAction{
224
+ {hashAction, "session", ""},
225
+ }}
226
+
227
+ out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{
228
+ "not-a-cookie",
229
+ }})
230
+ outval := out.Interface.(internal.LoggableStringArray)
231
+ if outval[0] != "not-a-cookie" {
232
+ t.Fatalf("invalid Set-Cookie line should pass through unchanged: got %q", outval[0])
233
+ }
234
+}
235
+
236
+func TestSetCookieFilterHashWithoutAttributes(t *testing.T) {
237
+ f := SetCookieFilter{[]cookieFilterAction{
238
+ {hashAction, "session", ""},
239
+ }}
240
+
241
+ out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{
242
+ "session=secret",
243
+ }})
244
+ outval := out.Interface.(internal.LoggableStringArray)
245
+ expected := "session=2bb80d53"
246
+ if outval[0] != expected {
247
+ t.Fatalf("cookie value was not hashed as expected: got %q, want %q", outval[0], expected)
248
+ }
249
+}
250
+
251
+func TestSetCookieFilterCookieNameMatchingIsCaseSensitive(t *testing.T) {
252
+ f := SetCookieFilter{[]cookieFilterAction{
253
+ {hashAction, "session", ""},
254
+ }}
255
+
256
+ out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{
257
+ "Session=secret; Path=/",
258
+ }})
259
+ outval := out.Interface.(internal.LoggableStringArray)
260
+ expected := "Session=secret; Path=/"
261
+ if outval[0] != expected {
262
+ t.Fatalf("Set-Cookie value should remain unchanged when the cookie name does not match exactly: got %q, want %q", outval[0], expected)
263
+ }
264
+}
265
+
266
+func TestSetCookieFilterFirstMatchWins(t *testing.T) {
267
+ f := SetCookieFilter{[]cookieFilterAction{
268
+ {deleteAction, "session", ""},
269
+ {hashAction, "session", ""},
270
+ }}
271
+
272
+ out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{
273
+ "session=secret; Path=/",
274
+ }})
275
+ outval := out.Interface.(internal.LoggableStringArray)
276
+ if len(outval) != 0 {
277
+ t.Fatalf("expected cookie to be deleted by first matching action, got %q", outval)
278
+ }
279
+}
280
+
281
+func TestSetCookieFilterHashEmptyValue(t *testing.T) {
282
+ f := SetCookieFilter{[]cookieFilterAction{
283
+ {hashAction, "session", ""},
284
+ }}
285
+
286
+ out := f.Filter(zapcore.Field{Interface: internal.LoggableStringArray{
287
+ "session=; Max-Age=0; Path=/; HttpOnly",
288
+ }})
289
+ outval := out.Interface.(internal.LoggableStringArray)
290
+ expected := "session=e3b0c442; Max-Age=0; Path=/; HttpOnly"
291
+ if outval[0] != expected {
292
+ t.Fatalf("empty cookie value was not hashed as expected: got %q, want %q", outval[0], expected)
293
+ }
294
+}
295
+
296
+func TestValidateSetCookieFilter(t *testing.T) {
297
+ f := SetCookieFilter{[]cookieFilterAction{
298
+ {},
299
+ }}
300
+ if f.Validate() == nil {
301
+ t.Fatalf("empty action type must be invalid")
302
+ }
303
+
304
+ f = SetCookieFilter{[]cookieFilterAction{
305
+ {Type: "foo"},
306
+ }}
307
+ if f.Validate() == nil {
308
+ t.Fatalf("unknown action type must be invalid")
309
+ }
310
+}
311
+
312
func TestRegexpFilterSingleValue(t *testing.T) {
313
f := RegexpFilter{RawRegexp: `secret`, Value: "REDACTED"}
314
f.Provision(caddy.Context{})