mirror of
https://github.com/TecharoHQ/anubis.git
synced 2025-08-03 17:59:24 -04:00
feat(expressions): add missingHeader function to bot environment (#870)
Also add tests to the bot expressions custom functions.
This commit is contained in:
parent
6b639cd911
commit
76dcd21582
@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
<!-- This changes the project to: -->
|
<!-- This changes the project to: -->
|
||||||
|
|
||||||
- Expired records are now properly removed from bbolt databases ([#848](https://github.com/TecharoHQ/anubis/pull/848)).
|
- 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))
|
- 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)
|
- [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
|
## v1.21.0: Minfilia Warde
|
||||||
|
|
||||||
> Please, be at ease. You are among friends here.
|
> Please, be at ease. You are among friends here.
|
||||||
|
@ -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.
|
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.
|
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:
|
Anubis expressions can be augmented with the following functions:
|
||||||
|
|
||||||
|
### `missingHeader`
|
||||||
|
|
||||||
|
Available in `bot` expressions.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function missingHeader(headers: Record<string, string>, 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`
|
### `randInt`
|
||||||
|
|
||||||
|
Available in all expressions.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
function randInt(n: int): int;
|
function randInt(n: int): int;
|
||||||
```
|
```
|
||||||
|
@ -6,6 +6,7 @@ import (
|
|||||||
"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"
|
||||||
"github.com/google/cel-go/common/types/ref"
|
"github.com/google/cel-go/common/types/ref"
|
||||||
|
"github.com/google/cel-go/common/types/traits"
|
||||||
"github.com/google/cel-go/ext"
|
"github.com/google/cel-go/ext"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -26,6 +27,33 @@ func BotEnvironment() (*cel.Env, error) {
|
|||||||
cel.Variable("load_1m", cel.DoubleType),
|
cel.Variable("load_1m", cel.DoubleType),
|
||||||
cel.Variable("load_5m", cel.DoubleType),
|
cel.Variable("load_5m", cel.DoubleType),
|
||||||
cel.Variable("load_15m", 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
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
269
lib/policy/expressions/environment_test.go
Normal file
269
lib/policy/expressions/environment_test.go
Normal file
@ -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
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user