diff --git a/lib/checker/all/all.go b/lib/checker/all/all.go index 89a8d7bd..a5bf6a6f 100644 --- a/lib/checker/all/all.go +++ b/lib/checker/all/all.go @@ -4,5 +4,6 @@ package all import ( _ "github.com/TecharoHQ/anubis/lib/checker/headerexists" _ "github.com/TecharoHQ/anubis/lib/checker/headermatches" + _ "github.com/TecharoHQ/anubis/lib/checker/path" _ "github.com/TecharoHQ/anubis/lib/checker/remoteaddress" ) diff --git a/lib/checker/path/checker.go b/lib/checker/path/checker.go new file mode 100644 index 00000000..2f4339bf --- /dev/null +++ b/lib/checker/path/checker.go @@ -0,0 +1,37 @@ +package path + +import ( + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/TecharoHQ/anubis" + "github.com/TecharoHQ/anubis/internal" + "github.com/TecharoHQ/anubis/lib/checker" +) + +func New(rexStr string) (checker.Interface, error) { + rex, err := regexp.Compile(strings.TrimSpace(rexStr)) + if err != nil { + return nil, fmt.Errorf("%w: regex %s failed parse: %w", anubis.ErrMisconfiguration, rexStr, err) + } + return &Checker{rex, internal.FastHash(rexStr)}, nil +} + +type Checker struct { + regexp *regexp.Regexp + hash string +} + +func (c *Checker) Check(r *http.Request) (bool, error) { + if c.regexp.MatchString(r.URL.Path) { + return true, nil + } + + return false, nil +} + +func (c *Checker) Hash() string { + return c.hash +} diff --git a/lib/checker/path/checker_test.go b/lib/checker/path/checker_test.go new file mode 100644 index 00000000..e482f6bf --- /dev/null +++ b/lib/checker/path/checker_test.go @@ -0,0 +1,90 @@ +package path + +import ( + "encoding/json" + "errors" + "net/http" + "testing" +) + +func TestChecker(t *testing.T) { + fac := Factory{} + + for _, tt := range []struct { + err error + name string + rexStr string + reqPath string + ok bool + }{ + { + name: "match", + rexStr: "^/api/.*", + reqPath: "/api/v1/users", + ok: true, + err: nil, + }, + { + name: "not_match", + rexStr: "^/api/.*", + reqPath: "/static/index.html", + ok: false, + err: nil, + }, + { + name: "wildcard_match", + rexStr: ".*\\.json$", + reqPath: "/data/config.json", + ok: true, + err: nil, + }, + { + name: "wildcard_not_match", + rexStr: ".*\\.json$", + reqPath: "/data/config.yaml", + ok: false, + err: nil, + }, + { + name: "invalid_regex", + rexStr: "a(b", + err: ErrInvalidRegex, + }, + } { + t.Run(tt.name, func(t *testing.T) { + fc := fileConfig{ + Regex: tt.rexStr, + } + data, err := json.Marshal(fc) + if err != nil { + t.Fatal(err) + } + + pc, err := fac.Build(t.Context(), json.RawMessage(data)) + if err != nil && !errors.Is(err, tt.err) { + t.Fatalf("creating PathChecker failed") + } + + if tt.err != nil && pc == nil { + return + } + + t.Log(pc.Hash()) + + r, err := http.NewRequest(http.MethodGet, tt.reqPath, nil) + if err != nil { + t.Fatalf("can't make request: %v", err) + } + + ok, err := pc.Check(r) + + if tt.ok != ok { + t.Errorf("ok: %v, wanted: %v", ok, tt.ok) + } + + if err != nil && tt.err != nil && !errors.Is(err, tt.err) { + t.Errorf("err: %v, wanted: %v", err, tt.err) + } + }) + } +} diff --git a/lib/checker/path/config.go b/lib/checker/path/config.go new file mode 100644 index 00000000..b227943e --- /dev/null +++ b/lib/checker/path/config.go @@ -0,0 +1,38 @@ +package path + +import ( + "errors" + "fmt" + "regexp" +) + +var ( + ErrNoRegex = errors.New("path: no regex is configured") + ErrInvalidRegex = errors.New("path: regex is invalid") +) + +type fileConfig struct { + Regex string `json:"regex" yaml:"regex"` +} + +func (fc fileConfig) String() string { + return fmt.Sprintf("regex=%q", fc.Regex) +} + +func (fc fileConfig) Valid() error { + var errs []error + + if fc.Regex == "" { + errs = append(errs, ErrNoRegex) + } + + if _, err := regexp.Compile(fc.Regex); err != nil { + errs = append(errs, ErrInvalidRegex, err) + } + + if len(errs) != 0 { + return errors.Join(errs...) + } + + return nil +} diff --git a/lib/checker/path/config_test.go b/lib/checker/path/config_test.go new file mode 100644 index 00000000..fd9a335e --- /dev/null +++ b/lib/checker/path/config_test.go @@ -0,0 +1,50 @@ +package path + +import ( + "errors" + "testing" +) + +func TestFileConfigValid(t *testing.T) { + for _, tt := range []struct { + name, description string + in fileConfig + err error + }{ + { + name: "simple happy", + description: "the most common usecase", + in: fileConfig{ + Regex: "^/api/.*", + }, + }, + { + name: "wildcard match", + description: "match files with specific extension", + in: fileConfig{ + Regex: ".*[.]json$", + }, + }, + { + name: "no regex", + description: "Regex must be set, it is not", + in: fileConfig{}, + err: ErrNoRegex, + }, + { + name: "invalid regex", + description: "the user wrote an invalid regular expression", + in: fileConfig{ + Regex: "[a-z", + }, + err: ErrInvalidRegex, + }, + } { + t.Run(tt.name, func(t *testing.T) { + if err := tt.in.Valid(); !errors.Is(err, tt.err) { + t.Log(tt.description) + t.Fatalf("got %v, wanted %v", err, tt.err) + } + }) + } +} diff --git a/lib/checker/path/factory.go b/lib/checker/path/factory.go new file mode 100644 index 00000000..d2c36c2b --- /dev/null +++ b/lib/checker/path/factory.go @@ -0,0 +1,58 @@ +package path + +import ( + "context" + "encoding/json" + "errors" + "regexp" + "strings" + + "github.com/TecharoHQ/anubis/internal" + "github.com/TecharoHQ/anubis/lib/checker" +) + +func init() { + checker.Register("path", Factory{}) +} + +type Factory struct{} + +func (f Factory) Build(ctx context.Context, data json.RawMessage) (checker.Interface, error) { + var fc fileConfig + + if err := json.Unmarshal([]byte(data), &fc); err != nil { + return nil, errors.Join(checker.ErrUnparseableConfig, err) + } + + if err := fc.Valid(); err != nil { + return nil, errors.Join(checker.ErrInvalidConfig, err) + } + + pathRex, err := regexp.Compile(strings.TrimSpace(fc.Regex)) + if err != nil { + return nil, errors.Join(ErrInvalidRegex, err) + } + + return &Checker{ + regexp: pathRex, + hash: internal.FastHash(fc.String()), + }, nil +} + +func (f Factory) Valid(ctx context.Context, data json.RawMessage) error { + var fc fileConfig + + if err := json.Unmarshal([]byte(data), &fc); err != nil { + return errors.Join(checker.ErrUnparseableConfig, err) + } + + return fc.Valid() +} + +func Valid(pathRex string) error { + fc := fileConfig{ + Regex: pathRex, + } + + return fc.Valid() +} diff --git a/lib/checker/path/factory_test.go b/lib/checker/path/factory_test.go new file mode 100644 index 00000000..6f4524a8 --- /dev/null +++ b/lib/checker/path/factory_test.go @@ -0,0 +1,52 @@ +package path + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestFactoryGood(t *testing.T) { + files, err := os.ReadDir("./testdata/good") + if err != nil { + t.Fatal(err) + } + + fac := Factory{} + + for _, fname := range files { + t.Run(fname.Name(), func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "good", fname.Name())) + if err != nil { + t.Fatal(err) + } + + if err := fac.Valid(t.Context(), json.RawMessage(data)); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestFactoryBad(t *testing.T) { + files, err := os.ReadDir("./testdata/bad") + if err != nil { + t.Fatal(err) + } + + fac := Factory{} + + for _, fname := range files { + t.Run(fname.Name(), func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "bad", fname.Name())) + if err != nil { + t.Fatal(err) + } + + if err := fac.Valid(t.Context(), json.RawMessage(data)); err == nil { + t.Fatal("expected validation to fail") + } + }) + } +} diff --git a/lib/checker/path/testdata/bad/invalid_regex.json b/lib/checker/path/testdata/bad/invalid_regex.json new file mode 100644 index 00000000..5230736c --- /dev/null +++ b/lib/checker/path/testdata/bad/invalid_regex.json @@ -0,0 +1,3 @@ +{ + "regex": "a(b" +} \ No newline at end of file diff --git a/lib/checker/path/testdata/bad/nothing.json b/lib/checker/path/testdata/bad/nothing.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/lib/checker/path/testdata/bad/nothing.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/lib/checker/path/testdata/good/simple.json b/lib/checker/path/testdata/good/simple.json new file mode 100644 index 00000000..b2bee6ca --- /dev/null +++ b/lib/checker/path/testdata/good/simple.json @@ -0,0 +1,3 @@ +{ + "regex": "^/api/.*" +} \ No newline at end of file diff --git a/lib/checker/path/testdata/good/wildcard.json b/lib/checker/path/testdata/good/wildcard.json new file mode 100644 index 00000000..17d09266 --- /dev/null +++ b/lib/checker/path/testdata/good/wildcard.json @@ -0,0 +1,3 @@ +{ + "regex": ".*\\.json$" +} \ No newline at end of file