From 76dcd21582236adc21cadaea7e87b080e33d63ec Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Sun, 20 Jul 2025 19:09:29 -0400 Subject: [PATCH] feat(expressions): add missingHeader function to bot environment (#870) Also add tests to the bot expressions custom functions. --- docs/docs/CHANGELOG.md | 3 + docs/docs/admin/configuration/expressions.mdx | 26 +- lib/policy/expressions/environment.go | 28 ++ lib/policy/expressions/environment_test.go | 269 ++++++++++++++++++ 4 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 lib/policy/expressions/environment_test.go diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index 7dd723e..a89b13f 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] + - Expired records are now properly removed from bbolt databases ([#848](https://github.com/TecharoHQ/anubis/pull/848)). - Fix hanging on service restart ([#853](https://github.com/TecharoHQ/anubis/issues/853)) @@ -22,6 +23,8 @@ Anubis now supports these new languages: - [Czech](https://github.com/TecharoHQ/anubis/pull/849) +Anubis now supports the [`missingHeader`](./admin/configuration/expressions.mdx#missingHeader) to assert the absence of headers in requests. + ## v1.21.0: Minfilia Warde > Please, be at ease. You are among friends here. diff --git a/docs/docs/admin/configuration/expressions.mdx b/docs/docs/admin/configuration/expressions.mdx index 43dd274..49d0e05 100644 --- a/docs/docs/admin/configuration/expressions.mdx +++ b/docs/docs/admin/configuration/expressions.mdx @@ -77,7 +77,7 @@ For example, consider this rule: For this rule, if a request comes in from `8.8.8.8` or `1.1.1.1`, Anubis will deny the request and return an error page. -#### `all` blocks +### `all` blocks An `all` block that contains a list of expressions. If all expressions in the list return `true`, then the action specified in the rule will be taken. If any of the expressions in the list returns `false`, Anubis will move on to the next rule. @@ -186,8 +186,32 @@ Also keep in mind that this does not account for other kinds of latency like I/O Anubis expressions can be augmented with the following functions: +### `missingHeader` + +Available in `bot` expressions. + +```ts +function missingHeader(headers: Record, key: string) bool +``` + +`missingHeader` returns `true` if the request does not contain a header. This is useful when you are trying to assert behavior such as: + +```yaml +# Adds weight to old versions of Chrome +- name: old-chrome + action: WEIGH + weight: + adjust: 10 + expression: + all: + - userAgent.matches("Chrome/[1-9][0-9]?\\.0\\.0\\.0") + - missingHeader(headers, "Sec-Ch-Ua") +``` + ### `randInt` +Available in all expressions. + ```ts function randInt(n: int): int; ``` diff --git a/lib/policy/expressions/environment.go b/lib/policy/expressions/environment.go index 431c02b..14b57be 100644 --- a/lib/policy/expressions/environment.go +++ b/lib/policy/expressions/environment.go @@ -6,6 +6,7 @@ import ( "github.com/google/cel-go/cel" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" "github.com/google/cel-go/ext" ) @@ -26,6 +27,33 @@ func BotEnvironment() (*cel.Env, error) { cel.Variable("load_1m", cel.DoubleType), cel.Variable("load_5m", cel.DoubleType), cel.Variable("load_15m", cel.DoubleType), + + // Bot-specific functions: + cel.Function("missingHeader", + cel.Overload("missingHeader_map_string_string_string", + []*cel.Type{cel.MapType(cel.StringType, cel.StringType), cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(headers, key ref.Val) ref.Val { + // Convert headers to a trait that supports Find + headersMap, ok := headers.(traits.Indexer) + if !ok { + return types.ValOrErr(headers, "headers is not a map, but is %T", headers) + } + + keyStr, ok := key.(types.String) + if !ok { + return types.ValOrErr(key, "key is not a string, but is %T", key) + } + + val := headersMap.Get(keyStr) + // Check if the key is missing by testing for an error + if types.IsError(val) { + return types.Bool(true) // header is missing + } + return types.Bool(false) // header is present + }), + ), + ), ) } diff --git a/lib/policy/expressions/environment_test.go b/lib/policy/expressions/environment_test.go new file mode 100644 index 0000000..9878e1c --- /dev/null +++ b/lib/policy/expressions/environment_test.go @@ -0,0 +1,269 @@ +package expressions + +import ( + "testing" + + "github.com/google/cel-go/common/types" +) + +func TestBotEnvironment(t *testing.T) { + env, err := BotEnvironment() + if err != nil { + t.Fatalf("failed to create bot environment: %v", err) + } + + tests := []struct { + name string + expression string + headers map[string]string + expected types.Bool + description string + }{ + { + name: "missing-header", + expression: `missingHeader(headers, "Missing-Header")`, + headers: map[string]string{ + "User-Agent": "test-agent", + "Content-Type": "application/json", + }, + expected: types.Bool(true), + description: "should return true when header is missing", + }, + { + name: "existing-header", + expression: `missingHeader(headers, "User-Agent")`, + headers: map[string]string{ + "User-Agent": "test-agent", + "Content-Type": "application/json", + }, + expected: types.Bool(false), + description: "should return false when header exists", + }, + { + name: "case-sensitive", + expression: `missingHeader(headers, "user-agent")`, + headers: map[string]string{ + "User-Agent": "test-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{}, + 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: "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 { + 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{}{ + "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) + } + }) + } + + t.Run("function-compilation", func(t *testing.T) { + src := `missingHeader(headers, "Test-Header")` + _, err := Compile(env, src) + if err != nil { + t.Fatalf("failed to compile missingHeader expression: %v", err) + } + }) +} + +func TestThresholdEnvironment(t *testing.T) { + env, err := ThresholdEnvironment() + if err != nil { + t.Fatalf("failed to create threshold environment: %v", err) + } + + tests := []struct { + name string + expression string + variables map[string]interface{} + expected types.Bool + description string + shouldCompile bool + }{ + { + name: "weight-variable-available", + expression: `weight > 100`, + variables: map[string]interface{}{"weight": 150}, + expected: types.Bool(true), + description: "should support weight variable in expressions", + shouldCompile: true, + }, + { + name: "weight-variable-false-case", + expression: `weight > 100`, + variables: map[string]interface{}{"weight": 50}, + expected: types.Bool(false), + description: "should correctly evaluate weight comparisons", + shouldCompile: true, + }, + { + name: "missingHeader-not-available", + expression: `missingHeader(headers, "Test")`, + variables: map[string]interface{}{}, + expected: types.Bool(false), // not used + description: "should not have missingHeader function available", + shouldCompile: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prog, err := Compile(env, tt.expression) + + if !tt.shouldCompile { + if err == nil { + t.Fatalf("%s: expected compilation to fail but it succeeded", tt.description) + } + return // Test passed - compilation failed as expected + } + + if err != nil { + t.Fatalf("failed to compile expression %q: %v", tt.expression, err) + } + + result, _, err := prog.Eval(tt.variables) + 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) + } + }) + } +} + +func TestNewEnvironment(t *testing.T) { + env, err := New() + if err != nil { + t.Fatalf("failed to create new environment: %v", err) + } + + tests := []struct { + name string + expression string + variables map[string]interface{} + expectBool *bool // nil if we just want to test compilation or non-bool result + description string + shouldCompile bool + }{ + { + name: "randInt-function-compilation", + expression: `randInt(10)`, + variables: map[string]interface{}{}, + expectBool: nil, // Don't check result, just compilation + description: "should compile randInt function", + shouldCompile: true, + }, + { + name: "randInt-range-validation", + expression: `randInt(10) >= 0 && randInt(10) < 10`, + variables: map[string]interface{}{}, + expectBool: boolPtr(true), + description: "should return values in correct range", + shouldCompile: true, + }, + { + name: "strings-extension-size", + expression: `"hello".size() == 5`, + variables: map[string]interface{}{}, + expectBool: boolPtr(true), + description: "should support string extension functions", + shouldCompile: true, + }, + { + name: "strings-extension-contains", + expression: `"hello world".contains("world")`, + variables: map[string]interface{}{}, + expectBool: boolPtr(true), + description: "should support string contains function", + shouldCompile: true, + }, + { + name: "strings-extension-startsWith", + expression: `"hello world".startsWith("hello")`, + variables: map[string]interface{}{}, + expectBool: boolPtr(true), + description: "should support string startsWith function", + shouldCompile: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prog, err := Compile(env, tt.expression) + + if !tt.shouldCompile { + if err == nil { + t.Fatalf("%s: expected compilation to fail but it succeeded", tt.description) + } + return // Test passed - compilation failed as expected + } + + if err != nil { + t.Fatalf("failed to compile expression %q: %v", tt.expression, err) + } + + // If we only want to test compilation, skip evaluation + if tt.expectBool == nil { + return + } + + result, _, err := prog.Eval(tt.variables) + if err != nil { + t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err) + } + + if result != types.Bool(*tt.expectBool) { + t.Errorf("%s: expected %v, got %v", tt.description, *tt.expectBool, result) + } + }) + } +} + +// Helper function to create bool pointers +func boolPtr(b bool) *bool { + return &b +}