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

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