Compare commits

...

7 Commits

Author SHA1 Message Date
Xe Iaso
5d225db493 docs: update changelog
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-26 22:50:50 +00:00
Xe Iaso
d8abecb047 fix(anubis): nuke challengeFor function
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-26 22:45:15 +00:00
Xe Iaso
922f99a61e fix(lib): make challenge validation fully deterministic
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-26 22:43:02 +00:00
Xe Iaso
f3b23a0796 fix(anubis): log when challenges explicitly fail
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-26 22:41:58 +00:00
Xe Iaso
6e75b8d363 fix(internal): add host, method, and path to request logs
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-26 22:39:54 +00:00
Xe Iaso
e014be9575 fix(lib): reduce challenge string size
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-26 22:36:28 +00:00
Xe Iaso
a735770c93 feat(expressions): add segments function to break path into segments (#916)
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-25 16:21:08 -04:00
8 changed files with 312 additions and 112 deletions

View File

@@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- This changes the project to: --> <!-- This changes the project to: -->
- The [Thoth client](https://anubis.techaro.lol/docs/admin/thoth) is now public in the repo instead of being an internal package. - The [Thoth client](https://anubis.techaro.lol/docs/admin/thoth) is now public in the repo instead of being an internal package.
- The [`segments`](./admin/configuration/expressions.mdx#segments) function was added for splitting a path into its slash-separated segments.
- When issuing a challenge, Anubis stores information about that challenge into the store. That stored information is later used to validate challenge responses. This works around nondeterminism in bot rules. ([#917](https://github.com/TecharoHQ/anubis/issues/917))
## v1.21.3: Minfilia Warde - Echo 3 ## v1.21.3: Minfilia Warde - Echo 3

View File

@@ -232,6 +232,39 @@ This is best applied when doing explicit block rules, eg:
It seems counter-intuitive to allow known bad clients through sometimes, but this allows you to confuse attackers by making Anubis' behavior random. Adjust the thresholds and numbers as facts and circumstances demand. It seems counter-intuitive to allow known bad clients through sometimes, but this allows you to confuse attackers by making Anubis' behavior random. Adjust the thresholds and numbers as facts and circumstances demand.
### `segments`
Available in `bot` expressions.
```ts
function segments(path: string): string[];
```
`segments` returns the number of slash-separated path segments, ignoring the leading slash. Here is what it will return with some common paths:
| Input | Output |
| :----------------------- | :--------------------- |
| `segments("/")` | `[""]` |
| `segments("/foo/bar")` | `["foo", "bar"] ` |
| `segments("/users/xe/")` | `["users", "xe", ""] ` |
:::note
If the path ends with a `/`, then the last element of the result will be an empty string. This is because `/users/xe` and `/users/xe/` are semantically different paths.
:::
This is useful if you want to write rules that allow requests that have no query parameters only if they have less than two path segments:
```yaml
- name: two-path-segments-no-query
action: ALLOW
expression:
all:
- size(query) == 0
- size(segments(path)) < 2
```
## Life advice ## Life advice
Expressions are very powerful. This is a benefit and a burden. If you are not careful with your expression targeting, you will be liable to get yourself into trouble. If you are at all in doubt, throw a `CHALLENGE` over a `DENY`. Legitimate users can easily work around a `CHALLENGE` result with a [proof of work challenge](../../design/why-proof-of-work.mdx). Bots are less likely to be able to do this. Expressions are very powerful. This is a benefit and a burden. If you are not careful with your expression targeting, you will be liable to get yourself into trouble. If you are at all in doubt, throw a `CHALLENGE` over a `DENY`. Legitimate users can easily work around a `CHALLENGE` result with a [proof of work challenge](../../design/why-proof-of-work.mdx). Bots are less likely to be able to do this.

View File

@@ -28,6 +28,9 @@ func InitSlog(level string) {
func GetRequestLogger(r *http.Request) *slog.Logger { func GetRequestLogger(r *http.Request) *slog.Logger {
return slog.With( return slog.With(
"host", r.Host,
"method", r.Method,
"path", r.URL.Path,
"user_agent", r.UserAgent(), "user_agent", r.UserAgent(),
"accept_language", r.Header.Get("Accept-Language"), "accept_language", r.Header.Get("Accept-Language"),
"priority", r.Header.Get("Priority"), "priority", r.Header.Get("Priority"),

View File

@@ -90,41 +90,39 @@ func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
} }
} }
func (s *Server) challengeFor(r *http.Request) (*challenge.Challenge, error) { func (s *Server) getChallenge(r *http.Request) (*challenge.Challenge, error) {
ckies := r.CookiesNamed(anubis.TestCookieName) ckies := r.CookiesNamed(anubis.TestCookieName)
if len(ckies) == 0 { if len(ckies) == 0 {
return s.issueChallenge(r.Context(), r) return nil, store.ErrNotFound
} }
j := store.JSON[challenge.Challenge]{Underlying: s.store} j := store.JSON[challenge.Challenge]{Underlying: s.store}
ckie := ckies[0] ckie := ckies[0]
chall, err := j.Get(r.Context(), "challenge:"+ckie.Value) chall, err := j.Get(r.Context(), "challenge:"+ckie.Value)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return s.issueChallenge(r.Context(), r)
}
return nil, err return &chall, err
}
return &chall, nil
} }
func (s *Server) issueChallenge(ctx context.Context, r *http.Request) (*challenge.Challenge, error) { func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.Logger, cr policy.CheckResult, rule *policy.Bot) (*challenge.Challenge, error) {
if cr.Rule != config.RuleChallenge {
slog.Error("this should be impossible, asked to issue a challenge but the rule is not a challenge rule", "cr", cr, "rule", rule)
//return nil, errors.New("[unexpected] this codepath should be impossible, asked to issue a challenge for a non-challenge rule")
}
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var randomData = make([]byte, 256) var randomData = make([]byte, 64)
if _, err := rand.Read(randomData); err != nil { if _, err := rand.Read(randomData); err != nil {
return nil, err return nil, err
} }
chall := challenge.Challenge{ chall := challenge.Challenge{
ID: id.String(), ID: id.String(),
Method: rule.Challenge.Algorithm,
RandomData: fmt.Sprintf("%x", randomData), RandomData: fmt.Sprintf("%x", randomData),
IssuedAt: time.Now(), IssuedAt: time.Now(),
Metadata: map[string]string{ Metadata: map[string]string{
@@ -138,6 +136,8 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request) (*challeng
return nil, err return nil, err
} }
lg.Info("new challenge issued", "challenge", id.String())
return &chall, err return &chall, err
} }
@@ -185,21 +185,21 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
if err != nil { if err != nil {
lg.Debug("cookie not found", "path", r.URL.Path) lg.Debug("cookie not found", "path", r.URL.Path)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
if err := ckie.Valid(); err != nil { if err := ckie.Valid(); err != nil {
lg.Debug("cookie is invalid", "err", err) lg.Debug("cookie is invalid", "err", err)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() { if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
lg.Debug("cookie expired", "path", r.URL.Path) lg.Debug("cookie expired", "path", r.URL.Path)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
@@ -208,7 +208,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
if err != nil || !token.Valid { if err != nil || !token.Valid {
lg.Debug("invalid token", "path", r.URL.Path, "err", err) lg.Debug("invalid token", "path", r.URL.Path, "err", err)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
@@ -216,7 +216,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
if !ok { if !ok {
lg.Debug("invalid token claims type", "path", r.URL.Path) lg.Debug("invalid token claims type", "path", r.URL.Path)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
@@ -224,14 +224,14 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
if !ok { if !ok {
lg.Debug("policyRule claim is not a string") lg.Debug("policyRule claim is not a string")
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
if policyRule != rule.Hash() { if policyRule != rule.Hash() {
lg.Debug("user originally passed with a different rule, issuing new challenge", "old", policyRule, "new", rule.Name) lg.Debug("user originally passed with a different rule, issuing new challenge", "old", policyRule, "new", rule.Name)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
@@ -346,7 +346,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
} }
lg = lg.With("check_result", cr) lg = lg.With("check_result", cr)
chall, err := s.challengeFor(r) chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
if err != nil { if err != nil {
lg.Error("failed to fetch or issue challenge", "err", err) lg.Error("failed to fetch or issue challenge", "err", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@@ -436,19 +436,21 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
} }
lg = lg.With("check_result", cr) lg = lg.With("check_result", cr)
impl, ok := challenge.Get(rule.Challenge.Algorithm) chall, err := s.getChallenge(r)
if err != nil {
lg.Error("getChallenge failed", "err", err)
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
return
}
impl, ok := challenge.Get(chall.Method)
if !ok { if !ok {
lg.Error("check failed", "err", err) lg.Error("check failed", "err", err)
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm)) s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
return return
} }
chall, err := s.challengeFor(r) lg = lg.With("challenge", chall.ID)
if err != nil {
lg.Error("check failed", "err", err)
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
return
}
in := &challenge.ValidateInput{ in := &challenge.ValidateInput{
Challenge: chall, Challenge: chall,
@@ -466,9 +468,13 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
case errors.As(err, &cerr): case errors.As(err, &cerr):
switch { switch {
case errors.Is(err, challenge.ErrFailed): case errors.Is(err, challenge.ErrFailed):
lg.Error("challenge failed", "err", err)
s.respondWithStatus(w, r, cerr.PublicReason, cerr.StatusCode) s.respondWithStatus(w, r, cerr.PublicReason, cerr.StatusCode)
return
case errors.Is(err, challenge.ErrInvalidFormat), errors.Is(err, challenge.ErrMissingField): case errors.Is(err, challenge.ErrInvalidFormat), errors.Is(err, challenge.ErrMissingField):
lg.Error("invalid challenge format", "err", err)
s.respondWithError(w, r, cerr.PublicReason) s.respondWithError(w, r, cerr.PublicReason)
return
} }
} }
} }

View File

@@ -5,6 +5,7 @@ import "time"
// Challenge is the metadata about a single challenge issuance. // Challenge is the metadata about a single challenge issuance.
type Challenge struct { type Challenge struct {
ID string `json:"id"` // UUID identifying the challenge ID string `json:"id"` // UUID identifying the challenge
Method string `json:"method"` // Challenge method
RandomData string `json:"randomData"` // The random data the client processes RandomData string `json:"randomData"` // The random data the client processes
IssuedAt time.Time `json:"issuedAt"` // When the challenge was issued IssuedAt time.Time `json:"issuedAt"` // When the challenge was issued
Metadata map[string]string `json:"metadata"` // Challenge metadata such as IP address and user agent Metadata map[string]string `json:"metadata"` // Challenge metadata such as IP address and user agent

View File

@@ -111,7 +111,7 @@ func randomChance(n int) bool {
return rand.Intn(n) == 0 return rand.Intn(n) == 0
} }
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) { func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, rule *policy.Bot, returnHTTPStatusOnly bool) {
localizer := localization.GetLocalizer(r) localizer := localization.GetLocalizer(r)
if returnHTTPStatusOnly { if returnHTTPStatusOnly {
@@ -125,17 +125,20 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) { if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) {
lg.Error("client was given a challenge but does not in fact support gzip compression") lg.Error("client was given a challenge but does not in fact support gzip compression")
s.respondWithError(w, r, localizer.T("client_error_browser")) s.respondWithError(w, r, localizer.T("client_error_browser"))
return
} }
challengesIssued.WithLabelValues("embedded").Add(1) challengesIssued.WithLabelValues("embedded").Add(1)
chall, err := s.challengeFor(r) chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
if err != nil { if err != nil {
lg.Error("can't get challenge", "err", "err") lg.Error("can't get challenge", "err", err)
s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host}) s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm)) s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
return return
} }
lg = lg.With("challenge", chall.ID)
var ogTags map[string]string = nil var ogTags map[string]string = nil
if s.opts.OpenGraph.Enabled { if s.opts.OpenGraph.Enabled {
var err error var err error
@@ -153,7 +156,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
Expiry: 30 * time.Minute, Expiry: 30 * time.Minute,
}) })
impl, ok := challenge.Get(rule.Challenge.Algorithm) impl, ok := challenge.Get(chall.Method)
if !ok { if !ok {
lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm) lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm)
s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host}) s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})

View File

@@ -2,6 +2,7 @@ package expressions
import ( import (
"math/rand/v2" "math/rand/v2"
"strings"
"github.com/google/cel-go/cel" "github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types"
@@ -54,6 +55,28 @@ func BotEnvironment() (*cel.Env, error) {
}), }),
), ),
), ),
cel.Function("segments",
cel.Overload("segments_string_list_string",
[]*cel.Type{cel.StringType},
cel.ListType(cel.StringType),
cel.UnaryBinding(func(path ref.Val) ref.Val {
pathStrType, ok := path.(types.String)
if !ok {
return types.ValOrErr(path, "path is not a string, but is %T", path)
}
pathStr := string(pathStrType)
if !strings.HasPrefix(pathStr, "/") {
return types.ValOrErr(path, "path does not start with /")
}
pathList := strings.Split(string(pathStr), "/")[1:]
return types.NewStringList(types.DefaultTypeAdapter, pathList)
}),
),
),
) )
} }

View File

@@ -12,99 +12,228 @@ func TestBotEnvironment(t *testing.T) {
t.Fatalf("failed to create bot environment: %v", err) t.Fatalf("failed to create bot environment: %v", err)
} }
tests := []struct { t.Run("missingHeader", func(t *testing.T) {
name string tests := []struct {
expression string name string
headers map[string]string expression string
expected types.Bool headers map[string]string
description string expected types.Bool
}{ description string
{ }{
name: "missing-header", {
expression: `missingHeader(headers, "Missing-Header")`, name: "missing-header",
headers: map[string]string{ expression: `missingHeader(headers, "Missing-Header")`,
"User-Agent": "test-agent", headers: map[string]string{
"Content-Type": "application/json", "User-Agent": "test-agent",
"Content-Type": "application/json",
},
expected: types.Bool(true),
description: "should return true when header is missing",
}, },
expected: types.Bool(true), {
description: "should return true when header is missing", name: "existing-header",
}, expression: `missingHeader(headers, "User-Agent")`,
{ headers: map[string]string{
name: "existing-header", "User-Agent": "test-agent",
expression: `missingHeader(headers, "User-Agent")`, "Content-Type": "application/json",
headers: map[string]string{ },
"User-Agent": "test-agent", expected: types.Bool(false),
"Content-Type": "application/json", description: "should return false when header exists",
}, },
expected: types.Bool(false), {
description: "should return false when header exists", name: "case-sensitive",
}, expression: `missingHeader(headers, "user-agent")`,
{ headers: map[string]string{
name: "case-sensitive", "User-Agent": "test-agent",
expression: `missingHeader(headers, "user-agent")`, },
headers: map[string]string{ expected: types.Bool(true),
"User-Agent": "test-agent", description: "should be case-sensitive (user-agent != User-Agent)",
}, },
expected: types.Bool(true), {
description: "should be case-sensitive (user-agent != User-Agent)", name: "empty-headers",
}, expression: `missingHeader(headers, "Any-Header")`,
{ headers: map[string]string{},
name: "empty-headers", expected: types.Bool(true),
expression: `missingHeader(headers, "Any-Header")`, description: "should return true for any header when map is empty",
headers: map[string]string{},
expected: types.Bool(true),
description: "should return true for any header when map is empty",
},
{
name: "real-world-sec-ch-ua",
expression: `missingHeader(headers, "Sec-Ch-Ua")`,
headers: map[string]string{
"User-Agent": "curl/7.68.0",
"Accept": "*/*",
"Host": "example.com",
}, },
expected: types.Bool(true), {
description: "should detect missing browser-specific headers from bots", name: "real-world-sec-ch-ua",
}, expression: `missingHeader(headers, "Sec-Ch-Ua")`,
{ headers: map[string]string{
name: "browser-with-sec-ch-ua", "User-Agent": "curl/7.68.0",
expression: `missingHeader(headers, "Sec-Ch-Ua")`, "Accept": "*/*",
headers: map[string]string{ "Host": "example.com",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", },
"Sec-Ch-Ua": `"Chrome"; v="91", "Not A Brand"; v="99"`, expected: types.Bool(true),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", description: "should detect missing browser-specific headers from bots",
}, },
expected: types.Bool(false), {
description: "should return false when browser sends Sec-Ch-Ua header", name: "browser-with-sec-ch-ua",
}, expression: `missingHeader(headers, "Sec-Ch-Ua")`,
} headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Sec-Ch-Ua": `"Chrome"; v="91", "Not A Brand"; v="99"`,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
expected: types.Bool(false),
description: "should return false when browser sends Sec-Ch-Ua header",
},
}
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
prog, err := Compile(env, tt.expression) prog, err := Compile(env, tt.expression)
if err != nil { if err != nil {
t.Fatalf("failed to compile expression %q: %v", tt.expression, err) t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
} }
result, _, err := prog.Eval(map[string]interface{}{ result, _, err := prog.Eval(map[string]interface{}{
"headers": tt.headers, "headers": tt.headers,
})
if err != nil {
t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
}
if result != tt.expected {
t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
}
}) })
if err != nil { }
t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
}
if result != tt.expected { t.Run("function-compilation", func(t *testing.T) {
t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result) src := `missingHeader(headers, "Test-Header")`
_, err := Compile(env, src)
if err != nil {
t.Fatalf("failed to compile missingHeader expression: %v", err)
} }
}) })
} })
t.Run("function-compilation", func(t *testing.T) { t.Run("segments", func(t *testing.T) {
src := `missingHeader(headers, "Test-Header")` for _, tt := range []struct {
_, err := Compile(env, src) name string
if err != nil { description string
t.Fatalf("failed to compile missingHeader expression: %v", err) expression string
path string
expected types.Bool
}{
{
name: "simple",
description: "/ should have one path segment",
expression: `size(segments(path)) == 1`,
path: "/",
expected: types.Bool(true),
},
{
name: "two segments without trailing slash",
description: "/user/foo should have two segments",
expression: `size(segments(path)) == 2`,
path: "/user/foo",
expected: types.Bool(true),
},
{
name: "at least two segments",
description: "/foo/bar/ should have at least two path segments",
expression: `size(segments(path)) >= 2`,
path: "/foo/bar/",
expected: types.Bool(true),
},
{
name: "at most two segments",
description: "/foo/bar/ does not have less than two path segments",
expression: `size(segments(path)) < 2`,
path: "/foo/bar/",
expected: types.Bool(false),
},
} {
t.Run(tt.name, func(t *testing.T) {
prog, err := Compile(env, tt.expression)
if err != nil {
t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
}
result, _, err := prog.Eval(map[string]interface{}{
"path": tt.path,
})
if err != nil {
t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
}
if result != tt.expected {
t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
}
})
} }
t.Run("invalid", func(t *testing.T) {
for _, tt := range []struct {
name string
description string
expression string
env any
wantFailCompile bool
wantFailEval bool
}{
{
name: "segments of headers",
description: "headers are not a path list",
expression: `segments(headers)`,
env: map[string]any{
"headers": map[string]string{
"foo": "bar",
},
},
wantFailCompile: true,
},
{
name: "invalid path type",
description: "a path should be a sting",
expression: `size(segments(path)) != 0`,
env: map[string]any{
"path": 4,
},
wantFailEval: true,
},
{
name: "invalid path",
description: "a path should start with a leading slash",
expression: `size(segments(path)) != 0`,
env: map[string]any{
"path": "foo",
},
wantFailEval: true,
},
} {
t.Run(tt.name, func(t *testing.T) {
prog, err := Compile(env, tt.expression)
if err != nil {
if !tt.wantFailCompile {
t.Log(tt.description)
t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
} else {
return
}
}
_, _, err = prog.Eval(tt.env)
if err == nil {
t.Log(tt.description)
t.Fatal("wanted an error but got none")
}
t.Log(err)
})
}
})
t.Run("function-compilation", func(t *testing.T) {
src := `size(segments(path)) <= 2`
_, err := Compile(env, src)
if err != nil {
t.Fatalf("failed to compile missingHeader expression: %v", err)
}
})
}) })
} }