Files
anubis-mirror/lib/policy/expressions/environment_test.go
T
Xe Iaso 926f3d1d0e fix: small security fixes (#1651)
This is based on private evaluation of a prerelease security product.
I cannot comment further other than I am impressed by its output.

This commit is a squash of several commits. The impactful commits
have details underneath markdown heading twos.

## fix(metrics): don't expose pprof by default

pprof[1] is the Go standard library profiling toolkit. It is invaluable
for diagnosing how Go programs perform in the wild. However it also is
able to expose secret data set with command line flags. This is not
ideal and should be mitigated by correctly configured firewall rules. We
don't live in a world where people correctly configure firewall rules,
so we have to fix things for people. Welcome to 2026.

[1]: https://pkg.go.dev/runtime/pprof

Ref: AWOO-001

## fix(honeypot/naive): cap r9k delay to one second

Otherwise this can get unbounded, which can cause problems with lesser
HTTP proxies such as Apache.

Ref: AWOO-002

## fix(policy): mend an edge case with subrequest auth and query strings

This fixes an unlikely edge case where using subrequest auth and query
strings with path based filtering can cause reality to differ from
administrator intent. This effectively strips the query string from
subrequest auth checks. This deficiency should be fixed in the future.

Ref: AWOO-004

## fix(expressions): mend possible nil pointer deref edge case

If Anubis just started up, load averages may not be set in memory. This
can cause a nil pointer dereference which could fail requests with weird
errors until the async thread sets the load averages.

Ref: AWOO-005

## fix(lib): mend case where domainless redirects could allow cross-domain redirects

Ref: AWOO-009

## fix(expressions): validate randInt bounds before rand.IntN

Non-positive or platform-overflowing arguments to the CEL randInt
helper used to reach rand.IntN unchecked, surfacing a CEL evaluator
error during request processing when policies passed
attacker-influenced values (e.g. contentLength). Reject non-positive
bounds and detect int narrowing explicitly, returning a typed CEL
error in both cases.

Ref: AWOO-010

Signed-off-by: Xe Iaso <xe.iaso@techaro.lol>
2026-05-30 00:48:43 -04:00

824 lines
24 KiB
Go

package expressions
import (
"context"
"errors"
"net"
"strings"
"testing"
"github.com/TecharoHQ/anubis/internal/dns"
"github.com/TecharoHQ/anubis/lib/store/memory"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
)
// newTestDNS is a helper function to create a new Dns object with an in-memory cache for testing.
func newTestDNS(forwardTTL int, reverseTTL int) *dns.Dns {
ctx := context.Background()
memStore := memory.New(ctx)
cache := dns.NewDNSCache(forwardTTL, reverseTTL, memStore)
return dns.New(ctx, cache)
}
func TestBotEnvironment(t *testing.T) {
dnsObj := newTestDNS(300, 300)
env, err := BotEnvironment(dnsObj)
if err != nil {
t.Fatalf("failed to create bot environment: %v", err)
}
t.Run("missingHeader", func(t *testing.T) {
tests := []struct {
headers map[string]string
name string
expression string
description string
expected types.Bool
}{
{
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]any{
"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)
}
})
})
t.Run("segments", func(t *testing.T) {
for _, tt := range []struct {
name string
description string
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]any{
"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 {
env any
name string
description string
expression string
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)
}
})
})
t.Run("regexSafe", func(t *testing.T) {
tests := []struct {
name string
expression string
expected types.String
description string
}{
{
name: "complex-test",
expression: `regexSafe("^(test1|test2|)[a-z]+$")`,
expected: types.String("\\^\\(test1\\|test2\\|\\)\\[a\\-z\\]\\+\\$"),
description: "should escape all reserved regex characters",
},
{
name: "backslash-test",
expression: `regexSafe("use \\\\ for special characters escaping\t, one/\"\\\"/for/cel and one/for/regex")`,
expected: types.String("use \\\\\\\\ for special characters escaping\t, one/\"\\\\\"/for/cel and one/for/regex"),
description: "should escape double-backslashes as double-double-backslashes and ignore cel escaping and forward slashes",
},
}
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]any{})
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 := `regexSafe(".*")`
_, err := Compile(env, src)
if err != nil {
t.Fatalf("failed to compile regexSafe expression: %v", err)
}
})
})
t.Run("dnsFunctions", func(t *testing.T) {
originalDNSLookupAddr := dns.DNSLookupAddr
originalDNSLookupHost := dns.DNSLookupHost
defer func() {
dns.DNSLookupAddr = originalDNSLookupAddr
dns.DNSLookupHost = originalDNSLookupHost
}()
t.Run("reverseDNS", func(t *testing.T) {
tests := []struct {
name string
addr string
mockReturn []string
mockError error
expression string
expected ref.Val
description string
}{
{
name: "success",
addr: "8.8.8.8",
mockReturn: []string{"dns.google."},
expression: `reverseDNS("8.8.8.8")`,
expected: types.NewStringList(types.DefaultTypeAdapter, []string{"dns.google"}),
description: "should return domain names for an IP",
},
{
name: "not-found",
addr: "127.0.0.1",
mockReturn: []string{},
mockError: &net.DNSError{IsNotFound: true},
expression: `reverseDNS("127.0.0.1")`,
expected: types.NewStringList(types.DefaultTypeAdapter, []string{}),
description: "should return an empty list when not found",
},
{
name: "error",
addr: "error-addr",
mockError: errors.New("some dns error"),
expression: `reverseDNS("error-addr")`,
expected: types.NewStringList(types.DefaultTypeAdapter, []string{}),
description: "should return empty list on error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dns.DNSLookupAddr = func(addr string) ([]string, error) {
if addr == tt.addr {
return tt.mockReturn, tt.mockError
}
return nil, errors.New("unexpected address for reverse lookup")
}
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]any{})
if err != nil {
t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
}
if result.Equal(tt.expected) != types.True {
t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
}
})
}
})
t.Run("lookupHost", func(t *testing.T) {
tests := []struct {
name string
host string
mockReturn []string
mockError error
expression string
expected ref.Val
description string
}{
{
name: "success",
host: "dns.google",
mockReturn: []string{"8.8.8.8", "8.8.4.4"},
expression: `lookupHost("dns.google")`,
expected: types.NewStringList(types.DefaultTypeAdapter, []string{"8.8.8.8", "8.8.4.4"}),
description: "should return IPs for a domain name",
},
{
name: "not-found",
host: "nonexistent.domain.example.com",
mockReturn: []string{},
mockError: &net.DNSError{IsNotFound: true},
expression: `lookupHost("nonexistent.domain.example.com")`,
expected: types.NewStringList(types.DefaultTypeAdapter, []string{}),
description: "should return an empty list when not found",
},
{
name: "error",
host: "error-host",
mockError: errors.New("some dns error"),
expression: `lookupHost("error-host")`,
expected: types.NewStringList(types.DefaultTypeAdapter, []string{}),
description: "should return empty list on error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dns.DNSLookupHost = func(host string) ([]string, error) {
if host == tt.host {
return tt.mockReturn, tt.mockError
}
return nil, errors.New("unexpected host for forward lookup")
}
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]any{})
if err != nil {
t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
}
if result.Equal(tt.expected) != types.True {
t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
}
})
}
})
t.Run("verifyFCrDNS", func(t *testing.T) {
tests := []struct {
name string
addr string
reverseMockReturn []string
reverseMockError error
forwardMockReturn map[string][]string // name -> ips
forwardMockError map[string]error
expression string
expected types.Bool
description string
}{
{
name: "success",
addr: "8.8.8.8",
reverseMockReturn: []string{"dns.google."},
forwardMockReturn: map[string][]string{"dns.google": {"8.8.8.8", "8.8.4.4"}},
expression: `verifyFCrDNS("8.8.8.8")`,
expected: types.Bool(true),
description: "should return true for valid FCrDNS",
},
{
name: "failure",
addr: "1.2.3.4",
reverseMockReturn: []string{"spoofed.example.com."},
forwardMockReturn: map[string][]string{"spoofed.example.com": {"5.6.7.8"}},
expression: `verifyFCrDNS("1.2.3.4")`,
expected: types.Bool(false),
description: "should return false for invalid FCrDNS",
},
{
name: "reverse-lookup-fails",
addr: "1.1.1.1",
reverseMockError: errors.New("reverse lookup failed"),
expression: `verifyFCrDNS("1.1.1.1")`,
expected: types.Bool(false),
description: "should return false if reverse lookup fails",
},
{
name: "success-with-pattern",
addr: "8.8.8.8",
reverseMockReturn: []string{"dns.google."},
forwardMockReturn: map[string][]string{"dns.google": {"8.8.8.8"}},
expression: `verifyFCrDNS("8.8.8.8", "dns.google")`,
expected: types.Bool(true),
description: "should return true for valid FCrDNS with matching pattern",
},
{
name: "failure-with-pattern",
addr: "8.8.8.8",
reverseMockReturn: []string{"dns.google."},
forwardMockReturn: map[string][]string{"dns.google": {"8.8.8.8"}},
expression: `verifyFCrDNS("8.8.8.8", "wrong.pattern")`,
expected: types.Bool(false),
description: "should return false for FCrDNS with non-matching pattern",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dns.DNSLookupAddr = func(addr string) ([]string, error) {
if addr == tt.addr {
return tt.reverseMockReturn, tt.reverseMockError
}
return nil, errors.New("unexpected address for reverse lookup")
}
dns.DNSLookupHost = func(host string) ([]string, error) {
host = strings.TrimSuffix(host, ".")
if ips, ok := tt.forwardMockReturn[host]; ok {
return ips, nil
}
if err, ok := tt.forwardMockError[host]; ok {
return nil, err
}
return nil, &net.DNSError{IsNotFound: true}
}
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]any{})
if err != nil {
t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
}
if result.Equal(tt.expected) != types.True {
t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
}
})
}
})
t.Run("arpaReverseIP", func(t *testing.T) {
tests := []struct {
name string
expression string
expected types.String
description string
evalError bool
}{
{
name: "ipv4",
expression: `arpaReverseIP("1.2.3.4")`,
expected: types.String("4.3.2.1"),
description: "should correctly reverse an IPv4 address",
},
{
name: "ipv6",
expression: `arpaReverseIP("2001:db8::1")`,
expected: types.String("1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"),
description: "should correctly reverse an IPv6 address",
},
{
name: "ipv6-full",
expression: `arpaReverseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")`,
expected: types.String("4.3.3.7.0.7.3.0.e.2.a.8.0.0.0.0.0.0.0.0.3.a.5.8.8.b.d.0.1.0.0.2"),
description: "should correctly reverse a fully expanded IPv6 address",
},
{
name: "ipv6-loopback",
expression: `arpaReverseIP("::1")`,
expected: types.String("1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0"),
description: "should correctly reverse the IPv6 loopback address",
},
{
name: "invalid-ip",
expression: `arpaReverseIP("not-an-ip")`,
evalError: true,
description: "should error on an invalid IP",
},
}
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]any{})
if tt.evalError {
if err == nil {
t.Errorf("%s: expected an evaluation error, but got none", tt.description)
}
return
}
if err != nil {
t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
}
if result.Equal(tt.expected) != types.True {
t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
}
})
}
})
})
}
func TestThresholdEnvironment(t *testing.T) {
env, err := ThresholdEnvironment()
if err != nil {
t.Fatalf("failed to create threshold environment: %v", err)
}
tests := []struct {
variables map[string]any
name string
expression string
description string
expected types.Bool
shouldCompile bool
}{
{
name: "weight-variable-available",
expression: `weight > 100`,
variables: map[string]any{"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]any{"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]any{},
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]any
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]any{},
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]any{},
expectBool: boolPtr(true),
description: "should return values in correct range",
shouldCompile: true,
},
{
name: "randInt-large-bound",
expression: `randInt(2147483647) >= 0`,
variables: map[string]any{},
expectBool: boolPtr(true),
description: "should accept int32-max bounds without overflow",
shouldCompile: true,
},
{
name: "strings-extension-size",
expression: `"hello".size() == 5`,
variables: map[string]any{},
expectBool: boolPtr(true),
description: "should support string extension functions",
shouldCompile: true,
},
{
name: "strings-extension-contains",
expression: `"hello world".contains("world")`,
variables: map[string]any{},
expectBool: boolPtr(true),
description: "should support string contains function",
shouldCompile: true,
},
{
name: "strings-extension-startsWith",
expression: `"hello world".startsWith("hello")`,
variables: map[string]any{},
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
}
func TestRandIntInvalidBounds(t *testing.T) {
env, err := New(cel.Variable("contentLength", cel.IntType))
if err != nil {
t.Fatalf("failed to create environment: %v", err)
}
tests := []struct {
name string
expression string
variables map[string]any
wantErrText string
description string
}{
{
name: "zero-bound-literal",
expression: `randInt(0)`,
variables: map[string]any{},
wantErrText: "randInt bound must be positive",
description: "randInt(0) should return a CEL error, not panic",
},
{
name: "negative-bound-literal",
expression: `randInt(-5)`,
variables: map[string]any{},
wantErrText: "randInt bound must be positive",
description: "randInt(-5) should return a CEL error, not panic",
},
{
name: "zero-bound-from-variable",
expression: `randInt(contentLength)`,
variables: map[string]any{"contentLength": 0},
wantErrText: "randInt bound must be positive",
description: "attacker-controlled zero contentLength should error gracefully",
},
{
name: "negative-bound-from-variable",
expression: `randInt(contentLength)`,
variables: map[string]any{"contentLength": -1},
wantErrText: "randInt bound must be positive",
description: "attacker-controlled negative contentLength should error gracefully",
},
}
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(tt.variables)
if err == nil {
t.Fatalf("%s: expected an evaluation error, got result %v", tt.description, result)
}
if !strings.Contains(err.Error(), tt.wantErrText) {
t.Errorf("%s: expected error containing %q, got %q", tt.description, tt.wantErrText, err.Error())
}
})
}
}