From bcac2beee7e419f8cdab2ed16f388d1af282a46b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 23 Nov 2021 10:01:43 +0100 Subject: [PATCH] logging: add a filter for query parameters (#4424) Co-authored-by: Matt Holt Co-authored-by: Francis Lavoie --- .../caddyfile_adapt/log_filters.txt | 18 +++ modules/logging/filters.go | 130 ++++++++++++++++++ modules/logging/filters_test.go | 41 ++++++ 3 files changed, 189 insertions(+) create mode 100644 modules/logging/filters_test.go diff --git a/caddytest/integration/caddyfile_adapt/log_filters.txt b/caddytest/integration/caddyfile_adapt/log_filters.txt index 0949c1d4..7873b1c9 100644 --- a/caddytest/integration/caddyfile_adapt/log_filters.txt +++ b/caddytest/integration/caddyfile_adapt/log_filters.txt @@ -5,6 +5,10 @@ log { format filter { wrap console fields { + uri query { + replace foo REDACTED + delete bar + } request>headers>Authorization replace REDACTED request>headers>Server delete request>remote_addr ip_mask { @@ -40,6 +44,20 @@ log { "filter": "ip_mask", "ipv4_cidr": 24, "ipv6_cidr": 32 + }, + "uri": { + "actions": [ + { + "parameter": "foo", + "type": "replace", + "value": "REDACTED" + }, + { + "parameter": "bar", + "type": "delete" + } + ], + "filter": "query" } }, "format": "filter", diff --git a/modules/logging/filters.go b/modules/logging/filters.go index ef5a4cb9..ceb0d8ac 100644 --- a/modules/logging/filters.go +++ b/modules/logging/filters.go @@ -15,7 +15,9 @@ package logging import ( + "errors" "net" + "net/url" "strconv" "github.com/caddyserver/caddy/v2" @@ -27,6 +29,7 @@ func init() { caddy.RegisterModule(DeleteFilter{}) caddy.RegisterModule(ReplaceFilter{}) caddy.RegisterModule(IPMaskFilter{}) + caddy.RegisterModule(QueryFilter{}) } // LogFieldFilter can filter (or manipulate) @@ -185,15 +188,142 @@ func (m IPMaskFilter) Filter(in zapcore.Field) zapcore.Field { return in } +type filterAction string + +const ( + // Replace value(s) of query parameter(s). + replaceAction filterAction = "replace" + // Delete query parameter(s). + deleteAction filterAction = "delete" +) + +func (a filterAction) IsValid() error { + switch a { + case replaceAction, deleteAction: + return nil + } + + return errors.New("invalid action type") +} + +type queryFilterAction struct { + // `replace` to replace the value(s) associated with the parameter(s) or `delete` to remove them entirely. + Type filterAction `json:"type"` + + // The name of the query parameter. + Parameter string `json:"parameter"` + + // The value to use as replacement if the action is `replace`. + Value string `json:"value,omitempty"` +} + +// QueryFilter is a Caddy log field filter that filters +// query parameters from a URL. +// +// This filter updates the logged URL string to remove or replace query +// parameters containing sensitive data. For instance, it can be used +// to redact any kind of secrets which were passed as query parameters, +// such as OAuth access tokens, session IDs, magic link tokens, etc. +type QueryFilter struct { + // A list of actions to apply to the query parameters of the URL. + Actions []queryFilterAction `json:"actions"` +} + +// Validate checks that action types are correct. +func (f *QueryFilter) 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 (QueryFilter) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "caddy.logging.encoders.filter.query", + New: func() caddy.Module { return new(QueryFilter) }, + } +} + +// UnmarshalCaddyfile sets up the module from Caddyfile tokens. +func (m *QueryFilter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + for d.Next() { + for d.NextBlock(0) { + qfa := queryFilterAction{} + switch d.Val() { + case "replace": + if !d.NextArg() { + return d.ArgErr() + } + + qfa.Type = replaceAction + qfa.Parameter = d.Val() + + if !d.NextArg() { + return d.ArgErr() + } + qfa.Value = d.Val() + + case "delete": + if !d.NextArg() { + return d.ArgErr() + } + + qfa.Type = deleteAction + qfa.Parameter = d.Val() + + default: + return d.Errf("unrecognized subdirective %s", d.Val()) + } + + m.Actions = append(m.Actions, qfa) + } + } + return nil +} + +// Filter filters the input field. +func (m QueryFilter) Filter(in zapcore.Field) zapcore.Field { + u, err := url.Parse(in.String) + if err != nil { + return in + } + + q := u.Query() + for _, a := range m.Actions { + switch a.Type { + case replaceAction: + for i := range q[a.Parameter] { + q[a.Parameter][i] = a.Value + } + + case deleteAction: + q.Del(a.Parameter) + } + } + + u.RawQuery = q.Encode() + in.String = u.String() + + return in +} + // Interface guards var ( _ LogFieldFilter = (*DeleteFilter)(nil) _ LogFieldFilter = (*ReplaceFilter)(nil) _ LogFieldFilter = (*IPMaskFilter)(nil) + _ LogFieldFilter = (*QueryFilter)(nil) _ caddyfile.Unmarshaler = (*DeleteFilter)(nil) _ caddyfile.Unmarshaler = (*ReplaceFilter)(nil) _ caddyfile.Unmarshaler = (*IPMaskFilter)(nil) + _ caddyfile.Unmarshaler = (*QueryFilter)(nil) _ caddy.Provisioner = (*IPMaskFilter)(nil) + + _ caddy.Validator = (*QueryFilter)(nil) ) diff --git a/modules/logging/filters_test.go b/modules/logging/filters_test.go new file mode 100644 index 00000000..883a1384 --- /dev/null +++ b/modules/logging/filters_test.go @@ -0,0 +1,41 @@ +package logging + +import ( + "testing" + + "go.uber.org/zap/zapcore" +) + +func TestQueryFilter(t *testing.T) { + f := QueryFilter{[]queryFilterAction{ + {replaceAction, "foo", "REDACTED"}, + {replaceAction, "notexist", "REDACTED"}, + {deleteAction, "bar", ""}, + {deleteAction, "notexist", ""}, + }} + + if f.Validate() != nil { + t.Fatalf("the filter must be valid") + } + + out := f.Filter(zapcore.Field{String: "/path?foo=a&foo=b&bar=c&bar=d&baz=e"}) + if out.String != "/path?baz=e&foo=REDACTED&foo=REDACTED" { + t.Fatalf("query parameters have not been filtered: %s", out.String) + } +} + +func TestValidateQueryFilter(t *testing.T) { + f := QueryFilter{[]queryFilterAction{ + {}, + }} + if f.Validate() == nil { + t.Fatalf("empty action type must be invalid") + } + + f = QueryFilter{[]queryFilterAction{ + {Type: "foo"}, + }} + if f.Validate() == nil { + t.Fatalf("unknown action type must be invalid") + } +}