Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

logging: add a filter for query parameters #4424

Merged
merged 11 commits into from
Nov 23, 2021
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")
}
}