From 8887adb027982e844965b4707b8595cee5845d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 23 Nov 2021 17:40:20 +0100 Subject: [PATCH] logging: add a filter for cookies (#4425) * feat(logging): add a filter for cookies * Improve godoc and add validation --- .../caddyfile_adapt/log_filters.txt | 18 +++ modules/logging/filters.go | 117 ++++++++++++++++++ modules/logging/filters_test.go | 28 +++++ 3 files changed, 163 insertions(+) diff --git a/caddytest/integration/caddyfile_adapt/log_filters.txt b/caddytest/integration/caddyfile_adapt/log_filters.txt index 7873b1c9..2693e9c6 100644 --- a/caddytest/integration/caddyfile_adapt/log_filters.txt +++ b/caddytest/integration/caddyfile_adapt/log_filters.txt @@ -11,6 +11,10 @@ log { } request>headers>Authorization replace REDACTED request>headers>Server delete + request>headers>Cookie cookie { + replace foo REDACTED + delete bar + } request>remote_addr ip_mask { ipv4 24 ipv6 32 @@ -37,6 +41,20 @@ log { "filter": "replace", "value": "REDACTED" }, + "request\u003eheaders\u003eCookie": { + "actions": [ + { + "name": "foo", + "type": "replace", + "value": "REDACTED" + }, + { + "name": "bar", + "type": "delete" + } + ], + "filter": "cookie" + }, "request\u003eheaders\u003eServer": { "filter": "delete" }, diff --git a/modules/logging/filters.go b/modules/logging/filters.go index ceb0d8ac..cf3b5cc1 100644 --- a/modules/logging/filters.go +++ b/modules/logging/filters.go @@ -17,6 +17,7 @@ package logging import ( "errors" "net" + "net/http" "net/url" "strconv" @@ -30,6 +31,7 @@ func init() { caddy.RegisterModule(ReplaceFilter{}) caddy.RegisterModule(IPMaskFilter{}) caddy.RegisterModule(QueryFilter{}) + caddy.RegisterModule(CookieFilter{}) } // LogFieldFilter can filter (or manipulate) @@ -311,17 +313,132 @@ func (m QueryFilter) Filter(in zapcore.Field) zapcore.Field { return in } +type cookieFilterAction struct { + // `replace` to replace the value of the cookie or `delete` to remove it entirely. + Type filterAction `json:"type"` + + // The name of the cookie. + Name string `json:"name"` + + // The value to use as replacement if the action is `replace`. + Value string `json:"value,omitempty"` +} + +// CookieFilter is a Caddy log field filter that filters +// cookies. +// +// This filter updates the logged HTTP header string +// to remove or replace cookies containing sensitive data. For instance, +// it can be used to redact any kind of secrets, such as session IDs. +// +// If several actions are configured for the same cookie name, only the first +// will be applied. +type CookieFilter struct { + // A list of actions to apply to the cookies. + Actions []cookieFilterAction `json:"actions"` +} + +// Validate checks that action types are correct. +func (f *CookieFilter) Validate() error { + for _, a := range f.Actions { + if err := a.Type.IsValid(); err != nil { + return err + } + } + + return nil +} + +// CaddyModule returns the Caddy module information. +func (CookieFilter) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "caddy.logging.encoders.filter.cookie", + New: func() caddy.Module { return new(CookieFilter) }, + } +} + +// UnmarshalCaddyfile sets up the module from Caddyfile tokens. +func (m *CookieFilter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + for d.Next() { + for d.NextBlock(0) { + cfa := cookieFilterAction{} + switch d.Val() { + case "replace": + if !d.NextArg() { + return d.ArgErr() + } + + cfa.Type = replaceAction + cfa.Name = d.Val() + + if !d.NextArg() { + return d.ArgErr() + } + cfa.Value = d.Val() + + case "delete": + if !d.NextArg() { + return d.ArgErr() + } + + cfa.Type = deleteAction + cfa.Name = d.Val() + + default: + return d.Errf("unrecognized subdirective %s", d.Val()) + } + + m.Actions = append(m.Actions, cfa) + } + } + return nil +} + +// Filter filters the input field. +func (m CookieFilter) Filter(in zapcore.Field) zapcore.Field { + originRequest := http.Request{Header: http.Header{"Cookie": []string{in.String}}} + cookies := originRequest.Cookies() + transformedRequest := http.Request{Header: make(http.Header)} + +OUTER: + for _, c := range cookies { + for _, a := range m.Actions { + if c.Name != a.Name { + continue + } + + switch a.Type { + case replaceAction: + c.Value = a.Value + transformedRequest.AddCookie(c) + continue OUTER + + case deleteAction: + continue OUTER + } + } + + transformedRequest.AddCookie(c) + } + + in.String = transformedRequest.Header.Get("Cookie") + + return in +} + // Interface guards var ( _ LogFieldFilter = (*DeleteFilter)(nil) _ LogFieldFilter = (*ReplaceFilter)(nil) _ LogFieldFilter = (*IPMaskFilter)(nil) _ LogFieldFilter = (*QueryFilter)(nil) + _ LogFieldFilter = (*CookieFilter)(nil) _ caddyfile.Unmarshaler = (*DeleteFilter)(nil) _ caddyfile.Unmarshaler = (*ReplaceFilter)(nil) _ caddyfile.Unmarshaler = (*IPMaskFilter)(nil) _ caddyfile.Unmarshaler = (*QueryFilter)(nil) + _ caddyfile.Unmarshaler = (*CookieFilter)(nil) _ caddy.Provisioner = (*IPMaskFilter)(nil) diff --git a/modules/logging/filters_test.go b/modules/logging/filters_test.go index 883a1384..6871bea0 100644 --- a/modules/logging/filters_test.go +++ b/modules/logging/filters_test.go @@ -39,3 +39,31 @@ func TestValidateQueryFilter(t *testing.T) { t.Fatalf("unknown action type must be invalid") } } + +func TestCookieFilter(t *testing.T) { + f := CookieFilter{[]cookieFilterAction{ + {replaceAction, "foo", "REDACTED"}, + {deleteAction, "bar", ""}, + }} + + out := f.Filter(zapcore.Field{String: "foo=a; foo=b; bar=c; bar=d; baz=e"}) + if out.String != "foo=REDACTED; foo=REDACTED; baz=e" { + t.Fatalf("cookies have not been filtered: %s", out.String) + } +} + +func TestValidateCookieFilter(t *testing.T) { + f := CookieFilter{[]cookieFilterAction{ + {}, + }} + if f.Validate() == nil { + t.Fatalf("empty action type must be invalid") + } + + f = CookieFilter{[]cookieFilterAction{ + {Type: "foo"}, + }} + if f.Validate() == nil { + t.Fatalf("unknown action type must be invalid") + } +}