Implement FCrDNS and other DNS features (#1308)

* Implement FCrDNS and other DNS features

* Redesign DNS cache and methods

* Fix DNS cache

* Rename regexSafe arg

* Alter verifyFCrDNS(addr) behaviour

* Remove unused dnsCache field from Server struct

* Upd expressions docs

* Update docs/docs/CHANGELOG.md

Signed-off-by: Xe Iaso <me@xeiaso.net>

* refactor(dns): simplify FCrDNS logging

* docs: clarify verifyFCrDNS behavior

Add a note to the documentation for `verifyFCrDNS` to clarify that it returns true when no PTR records are found for the given IP address.

* fix(dns): Improve FCrDNS error handling and tests

The `VerifyFCrDNS` function previously ignored errors returned from reverse DNS lookups. This could lead to incorrect passes when a DNS failure (other than a simple 'not found') occurred. This change ensures that any error from a reverse lookup will cause the FCrDNS check to fail.

The test suite for FCrDNS has been updated to reflect this change. The mock DNS lookups now simulate both 'not found' errors and other generic DNS errors. The test cases have been updated to ensure that the function behaves correctly in both scenarios, resolving a situation where two test cases were effectively duplicates.

* docs: Update FCrDNS documentation and spelling

Corrected a typo in the `verifyFCrDNS` function documentation.

Additionally, updated the spelling exception list to include new terms and remove redundant entries.

* chore: update spelling

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
Co-authored-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
The Ninth 2025-11-27 06:24:45 +03:00 committed by GitHub
parent 4ead3ed16e
commit 00fa939acf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1652 additions and 480 deletions

View file

@ -11,3 +11,4 @@ tencent
maintnotifications maintnotifications
azurediamond azurediamond
cooldown cooldown
verifyfcrdns

View file

@ -1,392 +1,400 @@
acs
Actorified acs
actorifiedstore Actorified
actorify actorifiedstore
Aibrew actorify
alibaba Aibrew
alrest alibaba
amazonbot alrest
anthro amazonbot
anubis anthro
anubistest anubis
apnic anubistest
APNICRANDNETAU apnic
Applebot APNICRANDNETAU
archlinux Applebot
asnc archlinux
asnchecker arpa
asns asnc
aspirational asnchecker
atuin asns
azuretools aspirational
badregexes atuin
bbolt azuretools
bdba badregexes
berr bbolt
bezier bdba
bingbot berr
Bitcoin bezier
bitrate bingbot
Bluesky Bitcoin
blueskybot bitrate
boi Bluesky
Bokm blueskybot
botnet boi
botstopper Bokm
BPort botnet
Brightbot botstopper
broked BPort
buildah Brightbot
byteslice broked
Bytespider buildah
cachebuster byteslice
cachediptoasn Bytespider
Caddyfile cachebuster
caninetools cachediptoasn
Cardyb Caddyfile
celchecker caninetools
celphase Cardyb
cerr celchecker
certresolver celphase
cespare cerr
CGNAT certresolver
cgr cespare
chainguard CGNAT
chall cgr
challengemozilla chainguard
challengetest chall
checkpath challengemozilla
checkresult challengetest
chibi checkpath
cidranger checkresult
ckie chibi
cloudflare cidranger
Codespaces ckie
confd cloudflare
connnection Codespaces
containerbuild confd
containerregistry connnection
coreutils containerbuild
Cotoyogi containerregistry
Cromite coreutils
crt Cotoyogi
Cscript Cromite
daemonizing crt
dayjob Cscript
DDOS daemonizing
Debian dayjob
debrpm DDOS
decaymap Debian
devcontainers debrpm
Diffbot decaymap
discordapp devcontainers
discordbot Diffbot
distros discordapp
dnf discordbot
dnsbl distros
dnserr dnf
domainhere dnsbl
dracula dnserr
dronebl DNSTTL
droneblresponse domainhere
dropin dracula
dsilence dronebl
duckduckbot droneblresponse
eerror dropin
ellenjoe dsilence
emacs duckduckbot
enbyware eerror
etld ellenjoe
everyones emacs
evilbot enbyware
evilsite etld
expressionorlist everyones
externalagent evilbot
externalfetcher evilsite
extldflags expressionorlist
facebookgo externalagent
Factset externalfetcher
fahedouch extldflags
fastcgi facebookgo
fediverse Factset
ffprobe fahedouch
financials fastcgi
finfos FCr
Firecrawl fcrdns
flagenv fediverse
Fordola ffprobe
forgejo financials
forwardauth finfos
fsys Firecrawl
fullchain flagenv
gaissmai Fordola
Galvus forgejo
geoip forwardauth
geoipchecker fsys
gha fullchain
GHSA gaissmai
Ghz Galvus
gipc geoip
gitea geoipchecker
godotenv gha
goland GHSA
gomod Ghz
goodbot gipc
googlebot gitea
gopsutil godotenv
govulncheck goland
goyaml gomod
GPG goodbot
GPT googlebot
gptbot gopsutil
Graphene govulncheck
grpcprom goyaml
grw GPG
gzw GPT
Hashcash gptbot
hashrate Graphene
headermap grpcprom
healthcheck grw
healthz gzw
hec Hashcash
helpdesk hashrate
Hetzner headermap
hmc healthcheck
homelab healthz
hostable hec
htmlc helpdesk
htmx Hetzner
httpdebug hmc
Huawei homelab
huawei hostable
hypertext htmlc
iaskspider htmx
iaso httpdebug
iat huawei
ifm hypertext
Imagesift iaskspider
imgproxy iaso
impressum iat
inbox ifm
ingressed Imagesift
inp imgproxy
internets impressum
IPTo inbox
iptoasn ingressed
isp inp
iss internets
isset IPTo
ivh iptoasn
Jenomis isp
JGit iss
jhjj isset
joho ivh
journalctl Jenomis
jshelter JGit
JWTs jhjj
kagi joho
kagibot journalctl
Keyfunc jshelter
keypair JWTs
KHTML kagi
kinda kagibot
KUBECONFIG Keyfunc
lcj keypair
ldflags KHTML
letsencrypt kinda
Lexentale KUBECONFIG
lfc lcj
lgbt ldflags
licend letsencrypt
licstart Lexentale
lightpanda lfc
limsa lgbt
Linting licend
listor licstart
LLU lightpanda
loadbalancer limsa
lol Linting
lominsa listor
maintainership LLU
malware loadbalancer
mcr lol
memes lominsa
metarefresh maintainership
metrix malware
mimi mcr
Minfilia memes
mistralai metarefresh
mnt metrix
Mojeek mimi
mojeekbot Minfilia
mozilla mistralai
myclient mnt
mymaster Mojeek
mypass mojeekbot
myuser mozilla
nbf myclient
nepeat mymaster
netsurf mypass
nginx myuser
nicksnyder nbf
nobots nepeat
NONINFRINGEMENT netsurf
nosleep nginx
nullglob nicksnyder
oci nobots
OCOB NONINFRINGEMENT
ogtag nosleep
oklch nullglob
omgili oci
omgilibot OCOB
openai ogtag
opengraph oklch
openrc omgili
oswald omgilibot
pag openai
palemoon opendns
Pangu opengraph
parseable openrc
passthrough oswald
Patreon pag
pgrep palemoon
phrik Pangu
pidfile parseable
pids passthrough
pipefail Patreon
pki pgrep
podkova phrik
podman pidfile
Postgre pids
poststart pipefail
prebaked pki
privkey podkova
promauto podman
promhttp Postgre
proofofwork poststart
publicsuffix prebaked
purejs privkey
pwcmd promauto
pwuser promhttp
qualys proofofwork
qwant publicsuffix
qwantbot purejs
rac pwcmd
rawler pwuser
rcvar qualys
redhat qwant
redir qwantbot
redirectscheme rac
refactors rawler
remoteip rcvar
reputational redhat
risc redir
ruleset redirectscheme
runlevels refactors
RUnlock remoteip
runtimedir reputational
runtimedirectory risc
Ryzen ruleset
sas runlevels
sasl RUnlock
screenshots runtimedir
searchbot runtimedirectory
searx Ryzen
sebest sas
secretplans sasl
Semrush screenshots
Seo searchbot
setsebool searx
shellcheck sebest
shirou secretplans
shopt Semrush
Sidetrade Seo
simprint setsebool
sitemap shellcheck
sls shirou
sni shopt
Spambot Sidetrade
sparkline simprint
spyderbot sitemap
srv sls
stackoverflow sni
startprecmd snipster
stoppostcmd Spambot
storetest sparkline
subgrid spyderbot
subr srv
subrequest stackoverflow
SVCNAME startprecmd
tagline stoppostcmd
tarballs storetest
tarrif subgrid
taviso subr
tbn subrequest
tbr SVCNAME
techaro tagline
techarohq tarballs
templ tarrif
templruntime taviso
testarea tbn
Thancred tbr
thoth techaro
thothmock techarohq
Tik telegrambot
Timpibot templ
TLog templruntime
traefik testarea
trunc Thancred
uberspace thoth
Unbreak thothmock
unbreakdocker Tik
unifiedjs Timpibot
unmarshal TLog
unparseable traefik
uvx trunc
UXP uberspace
valkey Unbreak
Varis unbreakdocker
Velen unifiedjs
vendored unmarshal
vhosts unparseable
VKE uvx
vnd UXP
VPS valkey
Vultr Varis
weblate Velen
webmaster vendored
webpage verify
websecure vhosts
websites vkbot
Webzio VKE
whois vnd
wildbase VPS
withthothmock Vultr
wolfbeast weblate
wordpress webmaster
Workaround webpage
workaround websecure
workdir websites
wpbot Webzio
XCircle whois
xeiaso wildbase
xeserv withthothmock
xesite wolfbeast
xess wordpress
xff workaround
XForwarded workdir
XNG wpbot
XOB XCircle
XOriginal xeiaso
XReal xeserv
yae xesite
YAMLTo xess
Yda xff
yeet XForwarded
yeetfile XNG
yourdomain XOB
yyz XOriginal
Zenos XReal
zizmor yae
zombocom YAMLTo
zos Yda
yeet
yeetfile
yourdomain
yyz
Zenos
zizmor
zombocom
zos

View file

@ -0,0 +1,6 @@
- name: telegrambot
action: ALLOW
expression:
all:
- userAgent.matches("TelegramBot")
- verifyFCrDNS(remoteAddress, "ptr\\.telegram\\.org$")

View file

@ -0,0 +1,6 @@
- name: vkbot
action: ALLOW
expression:
all:
- userAgent.matches("vkShare[^+]+\\+http\\://vk\\.com/dev/Share")
- verifyFCrDNS(remoteAddress, "^snipster\\d+\\.go\\.mail\\.ru$")

View file

@ -8,3 +8,4 @@
- import: (data)/crawlers/marginalia.yaml - import: (data)/crawlers/marginalia.yaml
- import: (data)/crawlers/mojeekbot.yaml - import: (data)/crawlers/mojeekbot.yaml
- import: (data)/crawlers/commoncrawl.yaml - import: (data)/crawlers/commoncrawl.yaml
- import: (data)/crawlers/yandexbot.yaml

View file

@ -0,0 +1,6 @@
- name: yandexbot
action: ALLOW
expression:
all:
- userAgent.matches("\\+http\\://yandex\\.com/bots")
- verifyFCrDNS(remoteAddress, "^.*\\.yandex\\.(ru|com|net)$")

View file

@ -0,0 +1,2 @@
- import: (data)/clients/telegram-preview.yaml
- import: (data)/clients/vk-preview.yaml

View file

@ -23,7 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support to simple Valkey/Redis cluster mode - Add support to simple Valkey/Redis cluster mode
- Open Graph passthrough now reuses the configured target Host/SNI/TLS settings, so metadata fetches succeed when the upstream certificate differs from the public domain. ([1283](https://github.com/TecharoHQ/anubis/pull/1283)) - Open Graph passthrough now reuses the configured target Host/SNI/TLS settings, so metadata fetches succeed when the upstream certificate differs from the public domain. ([1283](https://github.com/TecharoHQ/anubis/pull/1283))
- Stabilize the CVE-2025-24369 regression test by always submitting an invalid proof instead of relying on random POW failures. - Stabilize the CVE-2025-24369 regression test by always submitting an invalid proof instead of relying on random POW failures.
- Add Polish locale ([#1292](https://github.com/TecharoHQ/anubis/pull/1309))
### Deprecate `report_as` in challenge configuration ### Deprecate `report_as` in challenge configuration
@ -81,6 +80,31 @@ logging:
Additionally, information about [how Anubis uses each logging level](./admin/policies.mdx#log-levels) has been added to the documentation. Additionally, information about [how Anubis uses each logging level](./admin/policies.mdx#log-levels) has been added to the documentation.
### DNS Features
- CEL expressions for:
- FCrDNS checks
- Forward DNS queries
- Reverse DNS queries
- `arpaReverseIP` to transform IPv4/6 addresses into ARPA reverse IP notation.
- `regexSafe` to escape regex special characters (useful for including `remoteAddress` or headers in regular expressions).
- DNS cache and other optimizations to minimize unnecessary DNS queries.
The DNS cache TTL can be changed in the bots config like this:
```yaml
dns_ttl:
forward: 600
reverse: 600
```
The default value for both forward and reverse queries is 300 seconds.
The `verifyFCrDNS` CEL function has two overloads:
- `(addr)`
Simply verifies that the remote side has PTR records pointing to the target address.
- `(addr, ptrPattern)`
Verifies that the remote side refers to a specific domain and that this domain points to the target IP.
## v1.23.1: Lyse Hext - Echo 1 ## v1.23.1: Lyse Hext - Echo 1
- Fix `SERVE_ROBOTS_TXT` setting after the double slash fix broke it. - Fix `SERVE_ROBOTS_TXT` setting after the double slash fix broke it.

View file

@ -233,6 +233,27 @@ 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.
### `regexSafe`
Available in `bot` expressions.
```ts
function regexSafe(input: string): string;
```
`regexSafe` takes a string and escapes it for safe use inside of a regular expression. This is useful when you are creating regular expressions from headers or variables such as `remoteAddress`.
| Input | Output |
| :------------------------ | :------------------------------ |
| `regexSafe("1.2.3.4")` | `1\\.2\\.3\\.4` |
| `regexSafe("techaro.lol")` | `techaro\\.lol` |
| `regexSafe("star*")` | `star\\*` |
| `regexSafe("plus+")` | `plus\\+` |
| `regexSafe("{braces}")` | `\\{braces\\}` |
| `regexSafe("start^")` | `start\\^` |
| `regexSafe("back\\slash")` | `back\\\\slash` |
| `regexSafe("dash-dash")` | `dash\\-dash` |
### `segments` ### `segments`
Available in `bot` expressions. Available in `bot` expressions.
@ -266,6 +287,99 @@ This is useful if you want to write rules that allow requests that have no query
- size(segments(path)) < 2 - size(segments(path)) < 2
``` ```
### DNS Functions
Anubis can also perform DNS lookups as a part of its expression evaluation. This can be useful for doing things like checking for a valid [Forward-confirmed reverse DNS (FCrDNS)](https://en.wikipedia.org/wiki/Forward-confirmed_reverse_DNS) record.
#### `arpaReverseIP`
Available in `bot` expressions.
```ts
function arpaReverseIP(ip: string): string;
```
`arpaReverseIP` takes an IP address and returns its value in [ARPA notation](https://www.ietf.org/rfc/rfc2317.html). This can be useful when matching PTR record patterns.
| Input | Output |
| :----------------------------- | :------------------------------------------------------------------- |
| `arpaReverseIP("1.2.3.4")` | `4.3.2.1` |
| `arpaReverseIP("2001:db8::1")` | `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` |
#### `lookupHost`
Available in `bot` expressions.
```ts
function lookupHost(host: string): string[];
```
`lookupHost` performs a DNS lookup for the given hostname and returns a list of IP addresses.
```yaml
- name: cloudflare-ip-in-host-header
action: DENY
expression: '"104.16.0.0" in lookupHost(headers["Host"])'
```
#### `reverseDNS`
Available in `bot` expressions.
```ts
function reverseDNS(ip: string): string[];
```
`reverseDNS` takes an IP address and returns the DNS names associated with it. This is useful when you want to check PTR records of an IP address.
```yaml
- name: allow-googlebot
action: ALLOW
expression: 'reverseDNS(remoteAddress).endsWith(".googlebot.com")'
```
::: warning
Do not use this for validating the legitimacy of an IP address. It is possible for DNS records to be out of date or otherwise manipulated. Use [`verifyFCrDNS`](#verifyfcrdns) instead for a more reliable result.
:::
#### `verifyFCrDNS`
Available in `bot` expressions.
```ts
function verifyFCrDNS(ip: string): bool;
function verifyFCrDNS(ip: string, pattern: string): bool;
```
`verifyFCrDNS` checks if the reverse DNS of an IP address matches its forward DNS. This is a common technique to filter out spam and bot traffic. `verifyFCrDNS` comes in two forms:
- `verifyFCrDNS(remoteAddress)` will check that the reverse DNS of the remote address resolves back to the remote address. If no PTR records, returns true.
- `verifyFCrDNS(remoteAddress, pattern)` will check that the reverse DNS of the remote address is matching with pattern and that name resolves back to the remote address.
This is best used in rules like this:
```yaml
- name: require-fcrdns-for-post
action: DENY
expression:
all:
- method == "POST"
- "!verifyFCrDNS(remoteAddress)"
```
Here is an another example that allows requests from telegram:
```yaml
- name: telegrambot
action: ALLOW
expression:
all:
- userAgent.matches("TelegramBot")
- verifyFCrDNS(remoteAddress, "ptr\\.telegram\\.org$")
```
## 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.

70
internal/dns/cache.go Normal file
View file

@ -0,0 +1,70 @@
package dns
import (
"log/slog"
"time"
"github.com/TecharoHQ/anubis/lib/store"
_ "github.com/TecharoHQ/anubis/lib/store/all"
)
type DnsCache struct {
forward store.JSON[[]string]
reverse store.JSON[[]string]
forwardTTL time.Duration
reverseTTL time.Duration
}
func NewDNSCache(forwardTTL int, reverseTTL int, backend store.Interface) *DnsCache {
return &DnsCache{
forward: store.JSON[[]string]{
Underlying: backend,
Prefix: "forwardDNS",
},
reverse: store.JSON[[]string]{
Underlying: backend,
Prefix: "reverseDNS",
},
forwardTTL: time.Duration(forwardTTL) * time.Second,
reverseTTL: time.Duration(reverseTTL) * time.Second,
}
}
func (d *Dns) getCachedForward(host string) ([]string, bool) {
if d.cache == nil {
return nil, false
}
if cached, err := d.cache.forward.Get(d.ctx, host); err == nil {
slog.Debug("DNS: forward cache hit", "name", host, "ips", cached)
return cached, true
}
slog.Debug("DNS: forward cache miss", "name", host)
return nil, false
}
func (d *Dns) getCachedReverse(addr string) ([]string, bool) {
if d.cache == nil {
return nil, false
}
if cached, err := d.cache.reverse.Get(d.ctx, addr); err == nil {
slog.Debug("DNS: reverse cache hit", "addr", addr, "names", cached)
return cached, true
}
slog.Debug("DNS: reverse cache miss", "addr", addr)
return nil, false
}
func (d *Dns) forwardCachePut(host string, entries []string) {
if d.cache == nil {
return
}
d.cache.forward.Set(d.ctx, host, entries, d.cache.forwardTTL)
}
func (d *Dns) reverseCachePut(addr string, entries []string) {
if d.cache == nil {
return
}
d.cache.reverse.Set(d.ctx, addr, entries, d.cache.reverseTTL)
}

174
internal/dns/dns.go Normal file
View file

@ -0,0 +1,174 @@
package dns
import (
"context"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net"
"regexp"
"slices"
"strings"
)
var (
DNSLookupAddr = net.LookupAddr
DNSLookupHost = net.LookupHost
)
type Dns struct {
cache *DnsCache
ctx context.Context
}
func New(ctx context.Context, cache *DnsCache) *Dns {
return &Dns{
cache: cache,
ctx: ctx,
}
}
// ReverseDNS performs a reverse DNS lookup for the given IP address and trims the trailing dot from the results.
func (d *Dns) ReverseDNS(addr string) ([]string, error) {
slog.Debug("DNS: performing reverse lookup", "addr", addr)
if cached, ok := d.getCachedReverse(addr); ok {
return cached, nil
}
names, err := DNSLookupAddr(addr)
if err != nil {
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
slog.Debug("DNS: no PTR record found", "addr", addr)
return []string{}, nil
}
slog.Error("DNS: reverse lookup failed", "addr", addr, "err", err)
return nil, err
}
slog.Debug("DNS: reverse lookup successful", "addr", addr, "names", names)
trimmedNames := make([]string, len(names))
for i, name := range names {
trimmedNames[i] = strings.TrimSuffix(name, ".")
}
d.reverseCachePut(addr, trimmedNames)
return trimmedNames, nil
}
// LookupHost performs a forward DNS lookup for the given hostname.
func (d *Dns) LookupHost(host string) ([]string, error) {
slog.Debug("DNS: performing forward lookup", "host", host)
if cached, ok := d.getCachedForward(host); ok {
return cached, nil
}
addrs, err := DNSLookupHost(host)
if err != nil {
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
slog.Debug("DNS: no A/AAAA record found", "host", host)
return []string{}, nil
}
slog.Error("DNS: forward lookup failed", "host", host, "err", err)
return nil, err
}
slog.Debug("DNS: forward lookup successful", "host", host, "addrs", addrs)
d.forwardCachePut(host, addrs)
return addrs, nil
}
// verifyFCrDNSInternal performs the second half of the FCrDNS check, using a
// pre-fetched list of names to perform the forward lookups.
func (d *Dns) verifyFCrDNSInternal(addr string, names []string) bool {
for _, name := range names {
if cached, err := d.LookupHost(name); err == nil {
if slices.Contains(cached, addr) {
slog.Info("DNS: forward lookup confirmed original IP", "name", name, "addr", addr)
return true
}
continue
}
}
slog.Info("DNS: could not confirm original IP in forward lookups", "addr", addr)
return false
}
// VerifyFCrDNS performs a forward-confirmed reverse DNS (FCrDNS) lookup for the given IP address,
// optionally matching against a provided pattern.
func (d *Dns) VerifyFCrDNS(addr string, pattern *string) bool {
var patternVal string
if pattern != nil {
patternVal = *pattern
}
slog.Debug("DNS: performing FCrDNS lookup", "addr", addr, "pattern", patternVal)
names, err := d.ReverseDNS(addr)
if err != nil {
return false
}
if len(names) == 0 {
return pattern == nil // If no pattern specified, check is passed
}
// If a pattern is provided, check for a match.
if pattern != nil {
anyNameMatched := false
for _, name := range names {
matched, err := regexp.MatchString(*pattern, name)
if err != nil {
slog.Error("DNS: verifyFCrDNS invalid regex pattern", "err", err)
return false // Invalid pattern is a failure.
}
if matched {
anyNameMatched = true
break
}
}
if !anyNameMatched {
slog.Debug("DNS: FCrDNS no PTR matches the pattern", "addr", addr, "pattern", *pattern)
return false
}
slog.Debug("DNS: FCrDNS PTR matched pattern, proceeding with forward check", "addr", addr, "pattern", *pattern)
}
// If we're here, either there was no pattern, or the pattern matched.
// Proceed with the forward lookup confirmation.
return d.verifyFCrDNSInternal(addr, names)
}
// ArpaReverseIP performs translation from ip v4/v6 to arpa reverse notation
func (d *Dns) ArpaReverseIP(addr string) (string, error) {
ip := net.ParseIP(addr)
if ip == nil {
return addr, errors.New("invalid IP address")
}
if ipv4 := ip.To4(); ipv4 != nil {
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]), nil
}
ipv6 := ip.To16()
if ipv6 == nil {
return addr, errors.New("invalid IPv6 address")
}
hexBytes := make([]byte, hex.EncodedLen(len(ipv6)))
hex.Encode(hexBytes, ipv6)
var sb strings.Builder
sb.Grow(len(hexBytes)*2 - 1)
for i := len(hexBytes) - 1; i >= 0; i-- {
sb.WriteByte(hexBytes[i])
if i > 0 {
sb.WriteByte('.')
}
}
return sb.String(), nil
}

308
internal/dns/dns_test.go Normal file
View file

@ -0,0 +1,308 @@
package dns
import (
"context"
"errors"
"net"
"reflect"
"testing"
"time"
"github.com/TecharoHQ/anubis/lib/store/memory"
)
// 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 {
ctx := context.Background()
memStore := memory.New(ctx)
cache := NewDNSCache(forwardTTL, reverseTTL, memStore)
return New(ctx, cache)
}
// mockLookupAddr is a mock implementation of the net.LookupAddr function.
func mockLookupAddr(addr string) ([]string, error) {
switch addr {
case "8.8.8.8":
return []string{"dns.google."}, nil
case "1.1.1.1":
return []string{"one.one.one.one."}, nil
case "208.67.222.222":
return []string{"resolver1.opendns.com."}, nil
case "9.9.9.9":
return nil, &net.DNSError{Err: "no such host", Name: "9.9.9.9", IsNotFound: true}
case "1.2.3.4":
return nil, errors.New("unknown error")
default:
return nil, &net.DNSError{Err: "no such host", Name: addr, IsNotFound: true}
}
}
// mockLookupHost is a mock implementation of the net.LookupHost function.
func mockLookupHost(host string) ([]string, error) {
switch host {
case "dns.google":
return []string{"8.8.8.8", "8.8.4.4"}, nil
case "one.one.one.one":
return []string{"1.1.1.1", "1.0.0.1"}, nil
case "resolver1.opendns.com":
return []string{"208.67.222.222"}, nil
case "example.com":
return nil, &net.DNSError{Err: "no such host", Name: "example.com", IsNotFound: true}
default:
return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
}
}
func TestMain(m *testing.M) {
// Before all tests
originalLookupAddr := DNSLookupAddr
originalLookupHost := DNSLookupHost
DNSLookupAddr = mockLookupAddr
DNSLookupHost = mockLookupHost
// Run tests
exitCode := m.Run()
// After all tests
DNSLookupAddr = originalLookupAddr
DNSLookupHost = originalLookupHost
// Exit
if exitCode != 0 {
panic(exitCode)
}
}
func TestDns_ArpaReverseIP(t *testing.T) {
d := newTestDNS(0, 0)
tests := []struct {
name string
ip string
want string
wantErr bool
}{
{"ipv4", "192.0.2.1", "1.2.0.192", false},
{"ipv6", "2001:db8::1", "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", false},
{"invalid ip", "invalid", "invalid", true},
{"ipv4-mapped ipv6", "::ffff:192.0.2.1", "1.2.0.192", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := d.ArpaReverseIP(tt.ip)
if (err != nil) != tt.wantErr {
t.Errorf("ArpaReverseIP() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ArpaReverseIP() = %v, want %v", got, tt.want)
}
})
}
}
func TestDns_ReverseDNS(t *testing.T) {
d := newTestDNS(1, 1) // short TTL for testing cache
// First call - cache miss
t.Run("cache miss", func(t *testing.T) {
got, err := d.ReverseDNS("8.8.8.8")
if err != nil {
t.Fatalf("ReverseDNS() error = %v", err)
}
want := []string{"dns.google"}
if !reflect.DeepEqual(got, want) {
t.Errorf("ReverseDNS() = %v, want %v", got, want)
}
})
// Second call - cache hit
t.Run("cache hit", func(t *testing.T) {
// Temporarily replace lookup function to ensure cache is used
originalLookupAddr := DNSLookupAddr
DNSLookupAddr = func(addr string) ([]string, error) {
return nil, errors.New("should not be called")
}
defer func() { DNSLookupAddr = originalLookupAddr }()
got, err := d.ReverseDNS("8.8.8.8")
if err != nil {
t.Fatalf("ReverseDNS() error = %v", err)
}
want := []string{"dns.google"}
if !reflect.DeepEqual(got, want) {
t.Errorf("ReverseDNS() = %v, want %v", got, want)
}
})
// Test cache expiration
t.Run("cache expiration", func(t *testing.T) {
time.Sleep(2 * time.Second)
// Now the cache should be expired
// We expect the mock to be called again
// To test this we will change the mock to return something different
originalLookupAddr := DNSLookupAddr
DNSLookupAddr = func(addr string) ([]string, error) {
if addr == "8.8.8.8" {
return []string{"expired.google."}, nil
}
return mockLookupAddr(addr)
}
defer func() { DNSLookupAddr = originalLookupAddr }()
got, err := d.ReverseDNS("8.8.8.8")
if err != nil {
t.Fatalf("ReverseDNS() error = %v", err)
}
want := []string{"expired.google"}
if !reflect.DeepEqual(got, want) {
t.Errorf("ReverseDNS() = %v, want %v", got, want)
}
})
// Test not found
t.Run("not found", func(t *testing.T) {
got, err := d.ReverseDNS("9.9.9.9")
if err != nil {
t.Fatalf("ReverseDNS() error = %v", err)
}
if len(got) != 0 {
t.Errorf("ReverseDNS() = %v, want empty slice", got)
}
})
}
func TestDns_LookupHost(t *testing.T) {
d := newTestDNS(1, 1)
t.Run("cache miss", func(t *testing.T) {
got, err := d.LookupHost("dns.google")
if err != nil {
t.Fatalf("LookupHost() error = %v", err)
}
want := []string{"8.8.8.8", "8.8.4.4"}
if !reflect.DeepEqual(got, want) {
t.Errorf("LookupHost() = %v, want %v", got, want)
}
})
t.Run("cache hit", func(t *testing.T) {
originalLookupHost := DNSLookupHost
DNSLookupHost = func(host string) ([]string, error) {
return nil, errors.New("should not be called")
}
defer func() { DNSLookupHost = originalLookupHost }()
got, err := d.LookupHost("dns.google")
if err != nil {
t.Fatalf("LookupHost() error = %v", err)
}
want := []string{"8.8.8.8", "8.8.4.4"}
if !reflect.DeepEqual(got, want) {
t.Errorf("LookupHost() = %v, want %v", got, want)
}
})
t.Run("cache expiration", func(t *testing.T) {
time.Sleep(2 * time.Second)
originalLookupHost := DNSLookupHost
DNSLookupHost = func(host string) ([]string, error) {
if host == "dns.google" {
return []string{"9.9.9.9"}, nil
}
return mockLookupHost(host)
}
defer func() { DNSLookupHost = originalLookupHost }()
got, err := d.LookupHost("dns.google")
if err != nil {
t.Fatalf("LookupHost() error = %v", err)
}
want := []string{"9.9.9.9"}
if !reflect.DeepEqual(got, want) {
t.Errorf("LookupHost() = %v, want %v", got, want)
}
})
t.Run("not found", func(t *testing.T) {
got, err := d.LookupHost("example.com")
if err != nil {
t.Fatalf("LookupHost() error = %v", err)
}
if len(got) != 0 {
t.Errorf("LookupHost() = %v, want empty slice", got)
}
})
}
func TestDns_VerifyFCrDNS(t *testing.T) {
d := newTestDNS(1, 1)
// Helper to convert string to *string
p := func(s string) *string {
return &s
}
tests := []struct {
name string
ip string
pattern *string
want bool
}{
// Cases without pattern
{"valid no pattern", "8.8.8.8", nil, true},
{"valid partial no pattern", "1.1.1.1", nil, true},
{"not found no pattern", "9.9.9.9", nil, true},
{"unknown error no pattern", "1.2.3.4", nil, false},
// Cases with pattern
{"valid match", "8.8.8.8", p(`.*\.google$`), true},
{"valid no match", "8.8.8.8", p(`\.com$`), false},
{"not found with pattern", "9.9.9.9", p(".*"), false},
{"unknown error with pattern", "1.2.3.4", p(".*"), false},
{"invalid pattern", "8.8.8.8", p(`[`), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := d.VerifyFCrDNS(tt.ip, tt.pattern); got != tt.want {
t.Errorf("VerifyFCrDNS() = %v, want %v", got, tt.want)
}
})
}
t.Run("reverse cache hit", func(t *testing.T) {
// Prime the cache
if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true {
t.Fatalf("VerifyFCrDNS() priming failed, got %v, want true", got)
}
// Now test with a failing lookup to ensure cache is used
originalLookupAddr := DNSLookupAddr
DNSLookupAddr = func(addr string) ([]string, error) {
return nil, errors.New("should not be called")
}
defer func() { DNSLookupAddr = originalLookupAddr }()
if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true {
t.Errorf("VerifyFCrDNS() = %v, want true", got)
}
})
t.Run("forward cache hit", func(t *testing.T) {
// Prime the cache
if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true {
t.Fatalf("VerifyFCrDNS() priming failed, got %v, want true", got)
}
// Now test with a failing lookup to ensure cache is used
originalLookupHost := DNSLookupHost
DNSLookupHost = func(host string) ([]string, error) {
return nil, errors.New("should not be called")
}
defer func() { DNSLookupHost = originalLookupHost }()
if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true {
t.Errorf("VerifyFCrDNS() = %v, want true", got)
}
})
}

View file

@ -332,6 +332,7 @@ type fileConfig struct {
Thresholds []Threshold `json:"thresholds"` Thresholds []Threshold `json:"thresholds"`
StatusCodes StatusCodes `json:"status_codes"` StatusCodes StatusCodes `json:"status_codes"`
DNSBL bool `json:"dnsbl"` DNSBL bool `json:"dnsbl"`
DNSTTL DnsTTL `json:"dns_ttl"`
Logging *Logging `json:"logging"` Logging *Logging `json:"logging"`
} }
@ -387,6 +388,10 @@ func Load(fin io.Reader, fname string) (*Config, error) {
Challenge: http.StatusOK, Challenge: http.StatusOK,
Deny: http.StatusOK, Deny: http.StatusOK,
}, },
DNSTTL: DnsTTL{
Forward: 300,
Reverse: 300,
},
Store: &Store{ Store: &Store{
Backend: "memory", Backend: "memory",
}, },
@ -402,7 +407,8 @@ func Load(fin io.Reader, fname string) (*Config, error) {
} }
result := &Config{ result := &Config{
DNSBL: c.DNSBL, DNSBL: c.DNSBL,
DNSTTL: c.DNSTTL,
OpenGraph: OpenGraph{ OpenGraph: OpenGraph{
Enabled: c.OpenGraph.Enabled, Enabled: c.OpenGraph.Enabled,
ConsiderHost: c.OpenGraph.ConsiderHost, ConsiderHost: c.OpenGraph.ConsiderHost,
@ -469,6 +475,29 @@ func Load(fin io.Reader, fname string) (*Config, error) {
return result, nil return result, nil
} }
type DnsTTL struct {
Forward int `json:"forward"`
Reverse int `json:"reverse"`
}
func (sc DnsTTL) Valid() error {
var errs []error
if sc.Forward < 0 {
errs = append(errs, fmt.Errorf("%w: forward TTL is %d", ErrStatusCodeNotValid, sc.Forward))
}
if sc.Reverse < 0 {
errs = append(errs, fmt.Errorf("%w: reverse TTL is %d", ErrStatusCodeNotValid, sc.Reverse))
}
if len(errs) != 0 {
return fmt.Errorf("dns TTL values not valid:\n%w", errors.Join(errs...))
}
return nil
}
type Config struct { type Config struct {
Impressum *Impressum Impressum *Impressum
Store *Store Store *Store
@ -478,6 +507,7 @@ type Config struct {
StatusCodes StatusCodes StatusCodes StatusCodes
Logging *Logging Logging *Logging
DNSBL bool DNSBL bool
DNSTTL DnsTTL
} }
func (c Config) Valid() error { func (c Config) Valid() error {

View file

@ -0,0 +1,8 @@
dns_ttl:
forward: 60.0
reverse: "600"
bots:
- name: "test"
user_agent_regex: ".*"
action: "DENY"

View file

@ -0,0 +1,8 @@
dns_ttl:
forward: 600
reverse: 600
bots:
- name: "test"
user_agent_regex: ".*"
action: "DENY"

View file

@ -15,7 +15,6 @@
"nb", "nb",
"nl", "nl",
"nn", "nn",
"pl",
"pt-BR", "pt-BR",
"ru", "ru",
"tr", "tr",

View file

@ -1,66 +0,0 @@
{
"loading": "Ładowanie...",
"why_am_i_seeing": "Dlaczego to widzę?",
"protected_by": "Chronione przez",
"protected_from": "Przed",
"made_with": "Stworzone z ❤️ w 🇨🇦",
"mascot_design": "Projekt maskotki:",
"ai_companies_explanation": "Widzisz to, ponieważ administrator tej strony skonfigurował Anubisa, aby chronić serwer przed masowym skanowaniem treści przez firmy tworzące AI. Powoduje to obciążenie i przestoje, przez co zasoby strony stają się niedostępne dla wszystkich.",
"anubis_compromise": "Anubis jest kompromisem. Używa mechanizmu Proof-of-Work w stylu Hashcash — proponowanego systemu ograniczania spamu e-mail. Pomysł polega na tym, że dla indywidualnych użytkowników dodatkowe obciążenie jest niezauważalne, ale w skali masowego skanowania koszt szybko rośnie.",
"hack_purpose": "Docelowo jest to rozwiązanie tymczasowe, aby zyskać czas na ulepszenie metod identyfikacji przeglądarek bez interfejsu graficznego (np. poprzez analizę renderowania czcionek), by w przyszłości nie musieć wyświetlać strony z zadaniem Proof-of-Work użytkownikom, którzy najprawdopodobniej są prawidłowi.",
"simplified_explanation": "To zabezpieczenie przed botami i złośliwymi żądaniami, podobne do CAPTCHA. Jednak zamiast wykonywać zadanie samodzielnie, przeglądarka otrzymuje obliczenie do wykonania, aby potwierdzić, że jest prawidłowym klientem. Ten mechanizm to <a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">Proof of Work</a>. Zadanie trwa kilka sekund i uzyskujesz dostęp do strony. Dziękujemy za cierpliwość.",
"jshelter_note": "Uwaga: Anubis wymaga nowoczesnych funkcji JavaScript, które wtyczki typu JShelter mogą blokować. Wyłącz JShelter lub podobne dodatki dla tej domeny.",
"version_info": "Ta strona działa na Anubis w wersji",
"try_again": "Spróbuj ponownie",
"go_home": "Wróć na stronę główną",
"contact_webmaster": "lub jeśli uważasz, że nie powinieneś być blokowany, skontaktuj się z administratorem pod adresem",
"connection_security": "Poczekaj chwilę, sprawdzamy bezpieczeństwo Twojego połączenia.",
"javascript_required": "Niestety, aby przejść tę próbę, musisz włączyć obsługę JavaScript. Jest to konieczne, ponieważ firmy zajmujące się sztuczną inteligencją zmieniły umowę społeczną dotyczącą funkcjonowania hostingu stron internetowych. Rozwiązanie bez obsługi JavaScript jest w trakcie opracowywania.",
"benchmark_requires_js": "Uruchomienie narzędzia testowego wymaga włączonego JavaScript.",
"difficulty": "Trudność:",
"algorithm": "Algorytm:",
"compare": "Porównaj:",
"time": "Czas",
"iters": "Iteracje",
"time_a": "Czas A",
"iters_a": "Iteracje A",
"time_b": "Czas B",
"iters_b": "Iteracje B",
"static_check_endpoint": "To jedynie punkt kontrolny do użytku przez Twój reverse proxy.",
"authorization_required": "Wymagane uwierzytelnienie",
"cookies_disabled": "Twoja przeglądarka blokuje ciasteczka. Anubis wymaga ich, aby potwierdzić, że jesteś prawidłowym klientem. Włącz ciasteczka dla tej domeny.",
"access_denied": "Brak dostępu: kod błędu",
"dronebl_entry": "DroneBL zgłosił wpis",
"see_dronebl_lookup": "zobacz",
"internal_server_error": "Błąd wewnętrzny serwera: administrator błędnie skonfigurował Anubis. Skontaktuj się z administratorem i poproś o sprawdzenie logów",
"invalid_redirect": "Nieprawidłowe przekierowanie",
"redirect_not_parseable": "Nie można odczytać adresu przekierowania",
"redirect_domain_not_allowed": "Domena przekierowania niedozwolona",
"missing_required_forwarded_headers": "Brak wymaganych nagłówków X-Forwarded-*",
"failed_to_sign_jwt": "Nie udało się podpisać JWT",
"invalid_invocation": "Nieprawidłowe wywołanie MakeChallenge",
"client_error_browser": "Błąd klienta: upewnij się, że Twoja przeglądarka jest aktualna i spróbuj ponownie później.",
"oh_noes": "O nie!",
"benchmarking_anubis": "Testowanie wydajności Anubis!",
"you_are_not_a_bot": "Nie jesteś botem!",
"making_sure_not_bot": "Sprawdzamy, czy nie jesteś botem!",
"celphase": "CELPHASE",
"js_web_crypto_error": "Twoja przeglądarka nie obsługuje web.crypto. Czy korzystasz z bezpiecznego połączenia?",
"js_web_workers_error": "Twoja przeglądarka nie obsługuje web workers (Anubis ich używa, by nie zawieszać przeglądarki). Czy masz zainstalowaną wtyczkę typu JShelter?",
"js_cookies_error": "Twoja przeglądarka nie zapisuje ciasteczek. Anubis używa ich do przechowywania podpisanego tokenu potwierdzającego przejście zabezpieczenia. Włącz zapis ciasteczek dla tej domeny. Nazwy ciasteczek mogą zmieniać się bez zapowiedzi. Nazwy oraz zawartość ciasteczek nie są cześcią publicznego API.",
"js_context_not_secure": "Kontekst nie jest bezpieczny!",
"js_context_not_secure_msg": "Spróbuj połączyć się przez HTTPS lub poinformuj administratora, by skonfigurował HTTPS. Więcej informacji na <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>.",
"js_calculating": "Obliczanie...",
"js_missing_feature": "Brakująca funkcja",
"js_challenge_error": "Błąd wyzwania!",
"js_challenge_error_msg": "Nie udało się ustalić algorytmu sprawdzającego. Możesz spróbować odświeżyć stronę.",
"js_calculating_difficulty": "Obliczanie...<br/>Trudność:",
"js_speed": "Prędkość:",
"js_verification_longer": "Weryfikacja trwa dłużej niż zwykle. Proszę nie odświeżać strony.",
"js_success": "Sukces!",
"js_done_took": "Gotowe! Zajęło to",
"js_iterations": "iteracji",
"js_finished_reading": "Skończyłem czytać, kontynuuj →",
"js_calculation_error": "Błąd obliczeń!",
"js_calculation_error_msg": "Nie udało się obliczyć zadania:"
}

View file

@ -24,7 +24,6 @@ func TestLocalizationService(t *testing.T) {
"nb": "Laster inn...", "nb": "Laster inn...",
"nl": "Laden...", "nl": "Laden...",
"nn": "Lastar inn...", "nn": "Lastar inn...",
"pl": "Ładowanie...",
"pt-BR": "Carregando...", "pt-BR": "Carregando...",
"tr": "Yükleniyor...", "tr": "Yükleniyor...",
"ru": "Загрузка...", "ru": "Загрузка...",

View file

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/internal/dns"
"github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/expressions" "github.com/TecharoHQ/anubis/lib/policy/expressions"
"github.com/google/cel-go/cel" "github.com/google/cel-go/cel"
@ -16,8 +17,8 @@ type CELChecker struct {
src string src string
} }
func NewCELChecker(cfg *config.ExpressionOrList) (*CELChecker, error) { func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns) (*CELChecker, error) {
env, err := expressions.BotEnvironment() env, err := expressions.BotEnvironment(dnsObj)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -4,6 +4,7 @@ import (
"math/rand/v2" "math/rand/v2"
"strings" "strings"
"github.com/TecharoHQ/anubis/internal/dns"
"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"
@ -15,7 +16,7 @@ import (
// variables and functions that are passed into the CEL scope so that // variables and functions that are passed into the CEL scope so that
// Anubis can fail loudly and early when something is invalid instead // Anubis can fail loudly and early when something is invalid instead
// of blowing up at runtime. // of blowing up at runtime.
func BotEnvironment() (*cel.Env, error) { func BotEnvironment(dnsObj *dns.Dns) (*cel.Env, error) {
return New( return New(
// Variables exposed to CEL programs: // Variables exposed to CEL programs:
cel.Variable("remoteAddress", cel.StringType), cel.Variable("remoteAddress", cel.StringType),
@ -57,6 +58,118 @@ func BotEnvironment() (*cel.Env, error) {
), ),
), ),
cel.Function("reverseDNS",
cel.Overload("reverseDNS_string_list_string",
[]*cel.Type{cel.StringType},
cel.ListType(cel.StringType),
cel.UnaryBinding(func(addr ref.Val) ref.Val {
addrStr, ok := addr.(types.String)
if !ok {
return types.ValOrErr(addr, "addr is not a string, but is %T", addr)
}
names, err := dnsObj.ReverseDNS(string(addrStr))
if err != nil {
return types.NewStringList(types.DefaultTypeAdapter, []string{})
}
return types.NewStringList(types.DefaultTypeAdapter, names)
}),
),
),
cel.Function("lookupHost",
cel.Overload("lookupHost_string_list_string",
[]*cel.Type{cel.StringType},
cel.ListType(cel.StringType),
cel.UnaryBinding(func(host ref.Val) ref.Val {
hostStr, ok := host.(types.String)
if !ok {
return types.ValOrErr(host, "host is not a string, but is %T", host)
}
addrs, err := dnsObj.LookupHost(string(hostStr))
if err != nil {
return types.NewStringList(types.DefaultTypeAdapter, []string{})
}
return types.NewStringList(types.DefaultTypeAdapter, addrs)
}),
),
),
cel.Function("verifyFCrDNS",
cel.Overload("verifyFCrDNS_string_bool",
[]*cel.Type{cel.StringType},
cel.BoolType,
cel.UnaryBinding(func(addr ref.Val) ref.Val {
addrStr, ok := addr.(types.String)
if !ok {
return types.ValOrErr(addr, "addr is not a string")
}
return types.Bool(dnsObj.VerifyFCrDNS(string(addrStr), nil))
}),
),
cel.Overload("verifyFCrDNS_string_string_bool",
[]*cel.Type{cel.StringType, cel.StringType},
cel.BoolType,
cel.BinaryBinding(func(addr, pattern ref.Val) ref.Val {
addrStr, ok := addr.(types.String)
if !ok {
return types.ValOrErr(addr, "addr is not a string")
}
patternStr, ok := pattern.(types.String)
if !ok {
return types.ValOrErr(pattern, "pattern is not a string")
}
p := string(patternStr)
return types.Bool(dnsObj.VerifyFCrDNS(string(addrStr), &p))
}),
),
),
// arpaReverseIP transforms ip into arpa reverse notation like this
// 1.2.3.4 -> 4.3.2.1
// 2001:db8::1 -> 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
cel.Function("arpaReverseIP",
cel.Overload("arpaReverseIP_string_string",
[]*cel.Type{cel.StringType},
cel.StringType,
cel.UnaryBinding(func(addr ref.Val) ref.Val {
s, ok := addr.(types.String)
if !ok {
return types.ValOrErr(addr, "addr is not a string")
}
reversedIp, err := dnsObj.ArpaReverseIP(string(s))
if err != nil {
return types.ValOrErr(addr, "%s", err.Error())
}
return types.String(reversedIp)
}),
),
),
// regexSafe escapes a string for insertion into a regular expression
cel.Function("regexSafe",
cel.Overload("regexSafe_string_string",
[]*cel.Type{cel.StringType},
cel.StringType,
cel.UnaryBinding(func(str ref.Val) ref.Val {
s, ok := str.(types.String)
if !ok {
return types.ValOrErr(str, "addr is not a string")
}
escapes := []string{"\\", ".", ":", "*", "?", "-", "[", "]", "(", ")", "+", "{", "}", "|", "^", "$"}
r := string(s)
for _, escape := range escapes {
r = strings.ReplaceAll(r, escape, "\\"+escape)
}
return types.String(r)
}),
),
),
cel.Function("segments", cel.Function("segments",
cel.Overload("segments_string_list_string", cel.Overload("segments_string_list_string",
[]*cel.Type{cel.StringType}, []*cel.Type{cel.StringType},

View file

@ -1,13 +1,29 @@
package expressions package expressions
import ( import (
"context"
"errors"
"net"
"strings"
"testing" "testing"
"github.com/TecharoHQ/anubis/internal/dns"
"github.com/TecharoHQ/anubis/lib/store/memory"
"github.com/google/cel-go/common/types" "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) { func TestBotEnvironment(t *testing.T) {
env, err := BotEnvironment() dnsObj := newTestDNS(300, 300)
env, err := BotEnvironment(dnsObj)
if err != nil { if err != nil {
t.Fatalf("failed to create bot environment: %v", err) t.Fatalf("failed to create bot environment: %v", err)
} }
@ -235,6 +251,344 @@ func TestBotEnvironment(t *testing.T) {
} }
}) })
}) })
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]interface{}{})
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]interface{}{})
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]interface{}{})
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]interface{}{})
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]interface{}{})
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) { func TestThresholdEnvironment(t *testing.T) {

View file

@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/internal/dns"
"github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/checker" "github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/store" "github.com/TecharoHQ/anubis/lib/store"
@ -42,6 +43,8 @@ type ParsedConfig struct {
StatusCodes config.StatusCodes StatusCodes config.StatusCodes
DefaultDifficulty int DefaultDifficulty int
DNSBL bool DNSBL bool
DnsCache *dns.DnsCache
Dns *dns.Dns
Logger *slog.Logger Logger *slog.Logger
} }
@ -89,6 +92,22 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
lg := result.Logger.With("at", "config-validate") lg := result.Logger.With("at", "config-validate")
stFac, ok := store.Get(c.Store.Backend)
switch ok {
case true:
store, err := stFac.Build(ctx, c.Store.Parameters)
if err != nil {
validationErrs = append(validationErrs, err)
} else {
result.Store = store
}
case false:
validationErrs = append(validationErrs, config.ErrUnknownStoreBackend)
}
result.DnsCache = dns.NewDNSCache(result.orig.DNSTTL.Forward, result.orig.DNSTTL.Reverse, result.Store)
result.Dns = dns.New(ctx, result.DnsCache)
for _, b := range c.Bots { for _, b := range c.Bots {
if berr := b.Valid(); berr != nil { if berr := b.Valid(); berr != nil {
validationErrs = append(validationErrs, berr) validationErrs = append(validationErrs, berr)
@ -139,7 +158,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
} }
if b.Expression != nil { if b.Expression != nil {
c, err := NewCELChecker(b.Expression) c, err := NewCELChecker(b.Expression, result.Dns)
if err != nil { if err != nil {
validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s expressions: %w", b.Name, err)) validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s expressions: %w", b.Name, err))
} else { } else {
@ -219,19 +238,6 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
result.Thresholds = append(result.Thresholds, threshold) result.Thresholds = append(result.Thresholds, threshold)
} }
stFac, ok := store.Get(c.Store.Backend)
switch ok {
case true:
store, err := stFac.Build(ctx, c.Store.Parameters)
if err != nil {
validationErrs = append(validationErrs, err)
} else {
result.Store = store
}
case false:
validationErrs = append(validationErrs, config.ErrUnknownStoreBackend)
}
if len(validationErrs) > 0 { if len(validationErrs) > 0 {
return nil, fmt.Errorf("errors validating policy config JSON %s: %w", fname, errors.Join(validationErrs...)) return nil, fmt.Errorf("errors validating policy config JSON %s: %w", fname, errors.Join(validationErrs...))
} }