Skip to content

Commit

Permalink
logging: add a filter for query parameters (#4424)
Browse files Browse the repository at this point in the history
Co-authored-by: Matt Holt <[email protected]>
Co-authored-by: Francis Lavoie <[email protected]>
  • Loading branch information
3 people authored Nov 23, 2021
1 parent 1e10f6f commit bcac2be
Show file tree
Hide file tree
Showing 3 changed files with 189 additions and 0 deletions.
18 changes: 18 additions & 0 deletions caddytest/integration/caddyfile_adapt/log_filters.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
130 changes: 130 additions & 0 deletions modules/logging/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
package logging

import (
"errors"
"net"
"net/url"
"strconv"

"github.com/caddyserver/caddy/v2"
Expand All @@ -27,6 +29,7 @@ func init() {
caddy.RegisterModule(DeleteFilter{})
caddy.RegisterModule(ReplaceFilter{})
caddy.RegisterModule(IPMaskFilter{})
caddy.RegisterModule(QueryFilter{})
}

// LogFieldFilter can filter (or manipulate)
Expand Down Expand Up @@ -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)
)
41 changes: 41 additions & 0 deletions modules/logging/filters_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}

0 comments on commit bcac2be

Please sign in to comment.