Compare commits

..

7 Commits

Author SHA1 Message Date
Jason Cameron
51e63bad25 fix: pin Node.js and Go versions in CI configuration files 2025-11-30 21:58:20 -05:00
dependabot[bot]
21d7753b1c build(deps): bump actions-hub/kubectl in the github-actions group (#1303)
Bumps the github-actions group with 1 update: [actions-hub/kubectl](https://github.com/actions-hub/kubectl).


Updates `actions-hub/kubectl` from 1.34.1 to 1.34.2
- [Release notes](https://github.com/actions-hub/kubectl/releases)
- [Commits](f14933a23b...1d2c1e96fe)

---
updated-dependencies:
- dependency-name: actions-hub/kubectl
  dependency-version: 1.34.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Co-authored-by: Jason Cameron <git@jasoncameron.dev>
2025-11-30 13:53:32 -05:00
tbodt
43b8658bfd Show how to use subrequest auth with Caddy (#1312)
Signed-off-by: tbodt <tblodt@icloud.com>
2025-11-27 09:04:28 -05:00
The Ninth
00fa939acf 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>
2025-11-26 22:24:45 -05:00
Xe Iaso
4ead3ed16e fix(config): deprecate the report_as field for challenges (#1311)
* fix(config): deprecate the report_as field for challenges

This was a bad idea when it was added and it is irresponsible to
continue to have it. It causes more UX problems than it fixes with
slight of hand.

Closes: #1310
Closes: #1307
Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(policy): use the new logger for config validation messages

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

* docs(admin/thresholds): remove this report_as setting

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

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-11-25 23:25:17 -05:00
bplajzer
1f9c2272e6 add Polish language translation (#1309)
* feat(localization): add Polish language translation

* feat(localization): add Polish language translation
2025-11-24 11:55:47 -05:00
Xe Iaso
b11d8132dd chore: add dependabot cooldown (#1302)
* chore: add dependabot cooldown

One of the things I need to worry about with Anubis is the idea that
could pwn a dependency and then get malicious code into prod without
realizing it, a-la Jia Tan. Given that Anubis relies on tools like
Dependabot to manage updating dependencies (good for other reasons),
it makes sense to have Dependabot have a 7 day cooldown for new
versions of dependencies.

This follows the advice from Yossarian on their blog at [1]. Thanks
for the post and easy to copy/paste snippets!

[1]: https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns

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

* chore: update spelling

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

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-11-21 19:05:26 +00:00
52 changed files with 1816 additions and 591 deletions

View File

@@ -10,3 +10,5 @@ ABee
tencent tencent
maintnotifications maintnotifications
azurediamond azurediamond
cooldown
verifyfcrdns

View File

@@ -1,3 +1,4 @@
acs acs
Actorified Actorified
actorifiedstore actorifiedstore
@@ -13,6 +14,7 @@ apnic
APNICRANDNETAU APNICRANDNETAU
Applebot Applebot
archlinux archlinux
arpa
asnc asnc
asnchecker asnchecker
asns asns
@@ -85,6 +87,7 @@ distros
dnf dnf
dnsbl dnsbl
dnserr dnserr
DNSTTL
domainhere domainhere
dracula dracula
dronebl dronebl
@@ -108,6 +111,8 @@ facebookgo
Factset Factset
fahedouch fahedouch
fastcgi fastcgi
FCr
fcrdns
fediverse fediverse
ffprobe ffprobe
financials financials
@@ -157,7 +162,6 @@ hostable
htmlc htmlc
htmx htmx
httpdebug httpdebug
Huawei
huawei huawei
hypertext hypertext
iaskspider iaskspider
@@ -240,6 +244,7 @@ oklch
omgili omgili
omgilibot omgilibot
openai openai
opendns
opengraph opengraph
openrc openrc
oswald oswald
@@ -305,6 +310,7 @@ simprint
sitemap sitemap
sls sls
sni sni
snipster
Spambot Spambot
sparkline sparkline
spyderbot spyderbot
@@ -325,6 +331,7 @@ tbn
tbr tbr
techaro techaro
techarohq techarohq
telegrambot
templ templ
templruntime templruntime
testarea testarea
@@ -348,7 +355,9 @@ valkey
Varis Varis
Velen Velen
vendored vendored
verify
vhosts vhosts
vkbot
VKE VKE
vnd vnd
VPS VPS
@@ -364,7 +373,6 @@ wildbase
withthothmock withthothmock
wolfbeast wolfbeast
wordpress wordpress
Workaround
workaround workaround
workdir workdir
wpbot wpbot

View File

@@ -8,6 +8,8 @@ updates:
github-actions: github-actions:
patterns: patterns:
- "*" - "*"
cooldown:
default-days: 7
- package-ecosystem: gomod - package-ecosystem: gomod
directory: / directory: /
@@ -17,6 +19,8 @@ updates:
gomod: gomod:
patterns: patterns:
- "*" - "*"
cooldown:
default-days: 7
- package-ecosystem: npm - package-ecosystem: npm
directory: / directory: /
@@ -26,3 +30,5 @@ updates:
npm: npm:
patterns: patterns:
- "*" - "*"
cooldown:
default-days: 7

View File

@@ -24,11 +24,10 @@ jobs:
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: '24.11.0'
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: '1.25.4'
- name: install node deps - name: install node deps
run: | run: |

View File

@@ -28,11 +28,10 @@ jobs:
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: '24.11.0'
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: '1.25.4'
- uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9 - uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9

View File

@@ -38,11 +38,10 @@ jobs:
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: '24.11.0'
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: '1.25.4'
- uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9 - uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9

View File

@@ -53,14 +53,14 @@ jobs:
push: true push: true
- name: Apply k8s manifests to limsa lominsa - name: Apply k8s manifests to limsa lominsa
uses: actions-hub/kubectl@f14933a23bc8c582b5aa7d108defd8e2cb9fa86d # v1.34.1 uses: actions-hub/kubectl@1d2c1e96fe0ae23b0c95ee8240ae151b1e638c23 # v1.34.2
env: env:
KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }} KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }}
with: with:
args: apply -k docs/manifest args: apply -k docs/manifest
- name: Apply k8s manifests to limsa lominsa - name: Apply k8s manifests to limsa lominsa
uses: actions-hub/kubectl@f14933a23bc8c582b5aa7d108defd8e2cb9fa86d # v1.34.1 uses: actions-hub/kubectl@1d2c1e96fe0ae23b0c95ee8240ae151b1e638c23 # v1.34.2
env: env:
KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }} KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }}
with: with:

View File

@@ -19,7 +19,7 @@ jobs:
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: '1.25.4'
- name: Check go.mod and go.sum in main directory - name: Check go.mod and go.sum in main directory
run: | run: |

View File

@@ -26,11 +26,10 @@ jobs:
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: '24.11.0'
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: '1.25.4'
- name: Cache playwright binaries - name: Cache playwright binaries
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0

View File

@@ -27,11 +27,10 @@ jobs:
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: '24.11.0'
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: '1.25.4'
- name: install node deps - name: install node deps
run: | run: |

View File

@@ -28,11 +28,10 @@ jobs:
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: '24.11.0'
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: '1.25.4'
- name: install node deps - name: install node deps
run: | run: |

View File

@@ -35,11 +35,10 @@ jobs:
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: '24.11.0'
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: '1.25.4'
- uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9 - uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9

View File

@@ -37,7 +37,7 @@ jobs:
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: '1.25.4'
- name: Run CI - name: Run CI
run: go run ./utils/cmd/backoff-retry bash test/ssh-ci/rigging.sh ${{ matrix.host }} run: go run ./utils/cmd/backoff-retry bash test/ssh-ci/rigging.sh ${{ matrix.host }}

View File

@@ -50,8 +50,7 @@ bots:
# user_agent_regex: (?i:bot|crawler) # user_agent_regex: (?i:bot|crawler)
# action: CHALLENGE # action: CHALLENGE
# challenge: # challenge:
# difficulty: 16 # impossible # difficulty: 16 # impossible
# report_as: 4 # lie to the operator
# algorithm: slow # intentionally waste CPU cycles and time # algorithm: slow # intentionally waste CPU cycles and time
# Requires a subscription to Thoth to use, see # Requires a subscription to Thoth to use, see
@@ -249,7 +248,6 @@ thresholds:
# https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh
algorithm: metarefresh algorithm: metarefresh
difficulty: 1 difficulty: 1
report_as: 1
# For clients that are browser-like but have either gained points from custom rules or # For clients that are browser-like but have either gained points from custom rules or
# report as a standard browser. # report as a standard browser.
- name: moderate-suspicion - name: moderate-suspicion
@@ -262,7 +260,6 @@ thresholds:
# https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work
algorithm: fast algorithm: fast
difficulty: 2 # two leading zeros, very fast for most clients difficulty: 2 # two leading zeros, very fast for most clients
report_as: 2
- name: mild-proof-of-work - name: mild-proof-of-work
expression: expression:
all: all:
@@ -273,7 +270,6 @@ thresholds:
# https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work
algorithm: fast algorithm: fast
difficulty: 4 difficulty: 4
report_as: 4
# For clients that are browser like and have gained many points from custom rules # For clients that are browser like and have gained many points from custom rules
- name: extreme-suspicion - name: extreme-suspicion
expression: weight >= 30 expression: weight >= 30
@@ -282,4 +278,3 @@ thresholds:
# https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work
algorithm: fast algorithm: fast
difficulty: 6 difficulty: 6
report_as: 6

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

@@ -1,55 +0,0 @@
# Assert behaviour that only genuine browsers display. This ensures that modern Chrome
# or Firefox versions will get through without a challenge.
#
# These rules have been known to be bypassed by some of the worst automated scrapers.
# Use at your own risk.
- name: realistic-browser-catchall
expression:
all:
- '"User-Agent" in headers'
- '( userAgent.contains("Firefox") ) || ( userAgent.contains("Chrome") ) || ( userAgent.contains("Safari") )'
- '"Accept" in headers'
- '"Sec-Fetch-Dest" in headers'
- '"Sec-Fetch-Mode" in headers'
- '"Sec-Fetch-Site" in headers'
- '"Accept-Encoding" in headers'
- '( headers["Accept-Encoding"].contains("zstd") || headers["Accept-Encoding"].contains("br") )'
- '"Accept-Language" in headers'
action: WEIGH
weight:
adjust: -10
# The Upgrade-Insecure-Requests header is typically sent by browsers, but not always
- name: upgrade-insecure-requests
expression: '"Upgrade-Insecure-Requests" in headers'
action: WEIGH
weight:
adjust: -2
# Chrome should behave like Chrome
- name: chrome-is-proper
expression:
all:
- userAgent.contains("Chrome")
- '"Sec-Ch-Ua" in headers'
- 'headers["Sec-Ch-Ua"].contains("Chromium")'
- '"Sec-Ch-Ua-Mobile" in headers'
- '"Sec-Ch-Ua-Platform" in headers'
action: WEIGH
weight:
adjust: -5
- name: should-have-accept
expression: '!("Accept" in headers)'
action: WEIGH
weight:
adjust: 5
# Generic catchall rule
- name: generic-browser
user_agent_regex: >-
Mozilla|Opera
action: WEIGH
weight:
adjust: 10

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

@@ -35,7 +35,6 @@
# action: CHALLENGE # action: CHALLENGE
# challenge: # challenge:
# difficulty: 16 # impossible # difficulty: 16 # impossible
# report_as: 4 # lie to the operator
# algorithm: slow # intentionally waste CPU cycles and time # algorithm: slow # intentionally waste CPU cycles and time
# Requires a subscription to Thoth to use, see # Requires a subscription to Thoth to use, see

View File

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

View File

@@ -24,6 +24,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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.
### Deprecate `report_as` in challenge configuration
Previously Anubis let you lie to users about the difficulty of a challenge to interfere with operators of malicious scrapers as a psychological attack:
```yaml
bots:
# Punish any bot with "bot" in the user-agent string
# This is known to have a high false-positive rate, use at your own risk
- name: generic-bot-catchall
user_agent_regex: (?i:bot|crawler)
action: CHALLENGE
challenge:
difficulty: 16 # impossible
report_as: 4 # lie to the operator
algorithm: slow # intentionally waste CPU cycles and time
```
This has turned out to be a bad idea because it has caused massive user experience problems and has been removed. If you are using this setting, you will get a warning in your logs like this:
```json
{
"time": "2025-11-25T23:10:31.092201549-05:00",
"level": "WARN",
"source": {
"function": "github.com/TecharoHQ/anubis/lib/policy.ParseConfig",
"file": "/home/xe/code/TecharoHQ/anubis/lib/policy/policy.go",
"line": 201
},
"msg": "use of deprecated report_as setting detected, please remove this from your policy file when possible",
"at": "config-validate",
"name": "mild-suspicion"
}
```
To remove this warning, remove this setting from your policy file.
### Logging customization ### Logging customization
Anubis now supports the ability to log to multiple backends ("sinks"). This allows you to have Anubis [log to a file](./admin/policies.mdx#file-sink) instead of just logging to standard out. You can also customize the [logging level](./admin/policies.mdx#log-levels) in the policy file: Anubis now supports the ability to log to multiple backends ("sinks"). This allows you to have Anubis [log to a file](./admin/policies.mdx#file-sink) instead of just logging to standard out. You can also customize the [logging level](./admin/policies.mdx#log-levels) in the policy file:
@@ -44,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

@@ -12,7 +12,6 @@ To use it in your Anubis configuration:
action: CHALLENGE action: CHALLENGE
challenge: challenge:
difficulty: 1 # Number of seconds to wait before refreshing the page difficulty: 1 # Number of seconds to wait before refreshing the page
report_as: 4 # Unused by this challenge method
algorithm: metarefresh # Specify a non-JS challenge method algorithm: metarefresh # Specify a non-JS challenge method
``` ```

View File

@@ -12,7 +12,6 @@ To use it in your Anubis configuration:
action: CHALLENGE action: CHALLENGE
challenge: challenge:
difficulty: 1 # Number of seconds to wait before refreshing the page difficulty: 1 # Number of seconds to wait before refreshing the page
report_as: 4 # Unused by this challenge method
algorithm: preact algorithm: preact
``` ```

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.

View File

@@ -13,8 +13,6 @@ bots:
- # This correlates to data/bots/ai-catchall.yaml in the source tree - # This correlates to data/bots/ai-catchall.yaml in the source tree
import: (data)/bots/ai-catchall.yaml import: (data)/bots/ai-catchall.yaml
- import: (data)/bots/cloudflare-workers.yaml - import: (data)/bots/cloudflare-workers.yaml
# Import all the rules in the default configuration
- import: (data)/meta/default-config.yaml
``` ```
Of note, a bot rule can either have inline bot configuration or import a bot config snippet. You cannot do both in a single bot rule. Of note, a bot rule can either have inline bot configuration or import a bot config snippet. You cannot do both in a single bot rule.
@@ -37,33 +35,6 @@ config.BotOrImport: rule definition is invalid, you must set either bot rules or
Paths can either be prefixed with `(data)` to import from the [the data folder in the Anubis source tree](https://github.com/TecharoHQ/anubis/tree/main/data) or anywhere on the filesystem. If you don't have access to the Anubis source tree, check /usr/share/docs/anubis/data or in the tarball you extracted Anubis from. Paths can either be prefixed with `(data)` to import from the [the data folder in the Anubis source tree](https://github.com/TecharoHQ/anubis/tree/main/data) or anywhere on the filesystem. If you don't have access to the Anubis source tree, check /usr/share/docs/anubis/data or in the tarball you extracted Anubis from.
## Importing the default configuration
If you want to base your configuration off of the default configuration, import `(data)/meta/default-config.yaml`:
```yaml
bots:
- import: (data)/meta/default-config.yaml
# Write your rules here
```
This will keep your configuration up to date as Anubis adapts to emerging threats.
## How do I exempt most modern browsers from Anubis challenges?
If you want to exempt most modern browsers from Anubis challenges, import `(data)/common/acts-like-browser.yaml`:
```yaml
bots:
- import: (data)/meta/default-config.yaml
- import: (data)/common/acts-like-browser.yaml
# Write your rules here
```
These rules will allow traffic that "looks like" it's from a modern copy of Edge, Safari, Chrome, or Firefox. These rules used to be enabled by default, however user reports have suggested that AI scraper bots have adapted to conform to these rules to scrape without regard for the infrastructure they are attacking.
Use these rules at your own risk.
## Importing from imports ## Importing from imports
You can also import from an imported file in case you want to import an entire folder of rules at once. You can also import from an imported file in case you want to import an entire folder of rules at once.

View File

@@ -156,3 +156,68 @@ server {
``` ```
</details> </details>
## Caddy
Anubis can be used with the [`forward_auth`](https://caddyserver.com/docs/caddyfile/directives/forward_auth) directive in Caddy.
First, the `TARGET` environment variable in Anubis must be set to a space, eg:
<Tabs>
<TabItem value="env-file" label="Environment file" default>
```shell
# anubis.env
TARGET=" "
# ...
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
```yaml
services:
anubis-caddy:
image: ghcr.io/techarohq/anubis:latest
environment:
TARGET: " "
# ...
```
</TabItem>
<TabItem value="k8s" label="Kubernetes">
Inside your Deployment, StatefulSet, or Pod:
```yaml
- name: anubis
image: ghcr.io/techarohq/anubis:latest
env:
- name: TARGET
value: " "
# ...
```
</TabItem>
</Tabs>
Then configure the necessary directives in your site block:
```caddy
route {
# Assumption: Anubis is running in the same network namespace as
# caddy on localhost TCP port 8923
reverse_proxy /.within.website/* 127.0.0.1:8923
forward_auth 127.0.0.1:8923 {
uri /.within.website/x/cmd/anubis/api/check
trusted_proxies private_ranges
@unauthorized status 401
handle_response @unauthorized {
redir * /.within.website/?redir={uri} 307
}
}
}
```
If you want to use this for multiple sites, you can create a [snippet](https://caddyserver.com/docs/caddyfile/concepts#snippets) and import it in multiple site blocks.

View File

@@ -41,7 +41,6 @@ thresholds:
challenge: challenge:
algorithm: metarefresh algorithm: metarefresh
difficulty: 1 difficulty: 1
report_as: 1
- name: moderate-suspicion - name: moderate-suspicion
expression: expression:
@@ -52,7 +51,6 @@ thresholds:
challenge: challenge:
algorithm: fast algorithm: fast
difficulty: 2 difficulty: 2
report_as: 2
- name: extreme-suspicion - name: extreme-suspicion
expression: weight >= 20 expression: weight >= 20
@@ -60,7 +58,6 @@ thresholds:
challenge: challenge:
algorithm: fast algorithm: fast
difficulty: 4 difficulty: 4
report_as: 4
``` ```
This defines a suite of 4 thresholds: This defines a suite of 4 thresholds:
@@ -130,7 +127,6 @@ action: CHALLENGE
challenge: challenge:
algorithm: metarefresh algorithm: metarefresh
difficulty: 1 difficulty: 1
report_as: 1
``` ```
</td> </td>

View File

@@ -84,7 +84,6 @@ This rule has been known to have a high false positive rate in testing. Please u
action: CHALLENGE action: CHALLENGE
challenge: challenge:
difficulty: 16 # impossible difficulty: 16 # impossible
report_as: 4 # lie to the operator
algorithm: slow # intentionally waste CPU cycles and time algorithm: slow # intentionally waste CPU cycles and time
``` ```
@@ -93,7 +92,6 @@ Challenges can be configured with these settings:
| Key | Example | Description | | Key | Example | Description |
| :----------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- | | :----------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `difficulty` | `4` | The challenge difficulty (number of leading zeros) for proof-of-work. See [Why does Anubis use Proof-of-Work?](/docs/design/why-proof-of-work) for more details. | | `difficulty` | `4` | The challenge difficulty (number of leading zeros) for proof-of-work. See [Why does Anubis use Proof-of-Work?](/docs/design/why-proof-of-work) for more details. |
| `report_as` | `4` | What difficulty the UI should report to the user. Useful for messing with industrial-scale scraping efforts. |
| `algorithm` | `"fast"` | The challenge method to use. See [the list of challenge methods](./configuration/challenges/) for more information. | | `algorithm` | `"fast"` | The challenge method to use. See [the list of challenge methods](./configuration/challenges/) for more information. |
### Remote IP based filtering ### Remote IP based filtering

View File

@@ -49,7 +49,6 @@ bots:
# action: CHALLENGE # action: CHALLENGE
# challenge: # challenge:
# difficulty: 16 # impossible # difficulty: 16 # impossible
# report_as: 4 # lie to the operator
# algorithm: slow # intentionally waste CPU cycles and time # algorithm: slow # intentionally waste CPU cycles and time
- name: rss-feed-blog - name: rss-feed-blog
@@ -105,7 +104,6 @@ thresholds:
# https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh
algorithm: metarefresh algorithm: metarefresh
difficulty: 1 difficulty: 1
report_as: 1
# For clients that are browser-like but have either gained points from custom rules or # For clients that are browser-like but have either gained points from custom rules or
# report as a standard browser. # report as a standard browser.
- name: moderate-suspicion - name: moderate-suspicion
@@ -122,7 +120,6 @@ thresholds:
# challenge data, and forwards that to the client. # challenge data, and forwards that to the client.
algorithm: preact algorithm: preact
difficulty: 1 difficulty: 1
report_as: 1
- name: mild-proof-of-work - name: mild-proof-of-work
expression: expression:
all: all:
@@ -133,7 +130,6 @@ thresholds:
# https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work
algorithm: fast algorithm: fast
difficulty: 2 # two leading zeros, very fast for most clients difficulty: 2 # two leading zeros, very fast for most clients
report_as: 2
# For clients that are browser like and have gained many points from custom rules # For clients that are browser like and have gained many points from custom rules
- name: extreme-suspicion - name: extreme-suspicion
expression: weight >= 30 expression: weight >= 30
@@ -142,7 +138,6 @@ thresholds:
# https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work
algorithm: fast algorithm: fast
difficulty: 4 difficulty: 4
report_as: 4
dnsbl: false dnsbl: false

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

@@ -167,8 +167,8 @@ func (s *Server) hydrateChallengeRule(rule *policy.Bot, chall *challenge.Challen
if rule.Challenge.Difficulty == 0 { if rule.Challenge.Difficulty == 0 {
rule.Challenge.Difficulty = chall.Difficulty rule.Challenge.Difficulty = chall.Difficulty
} }
if rule.Challenge.ReportAs == 0 { if rule.Challenge.ReportAs != 0 {
rule.Challenge.ReportAs = chall.Difficulty s.logger.Warn("[DEPRECATION] the report_as field in this bot rule is deprecated, see https://github.com/TecharoHQ/anubis/issues/1310 for more information", "bot_name", rule.Name, "difficulty", rule.Challenge.Difficulty, "report_as", rule.Challenge.ReportAs)
} }
if rule.Challenge.Algorithm == "" { if rule.Challenge.Algorithm == "" {
rule.Challenge.Algorithm = chall.Method rule.Challenge.Algorithm = chall.Method
@@ -648,7 +648,6 @@ func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *p
return cr("default/allow", config.RuleAllow, weight), &policy.Bot{ return cr("default/allow", config.RuleAllow, weight), &policy.Bot{
Challenge: &config.ChallengeRules{ Challenge: &config.ChallengeRules{
Difficulty: s.policy.DefaultDifficulty, Difficulty: s.policy.DefaultDifficulty,
ReportAs: s.policy.DefaultDifficulty,
Algorithm: config.DefaultAlgorithm, Algorithm: config.DefaultAlgorithm,
}, },
Rules: &checker.List{}, Rules: &checker.List{},

View File

@@ -464,10 +464,6 @@ func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
if bot.Challenge.Difficulty != i { if bot.Challenge.Difficulty != i {
t.Errorf("Challenge.Difficulty is wrong, wanted %d, got: %d", i, bot.Challenge.Difficulty) t.Errorf("Challenge.Difficulty is wrong, wanted %d, got: %d", i, bot.Challenge.Difficulty)
} }
if bot.Challenge.ReportAs != i {
t.Errorf("Challenge.ReportAs is wrong, wanted %d, got: %d", i, bot.Challenge.ReportAs)
}
}) })
} }
} }

View File

@@ -36,7 +36,6 @@ func TestBasic(t *testing.T) {
Challenge: &config.ChallengeRules{ Challenge: &config.ChallengeRules{
Algorithm: "fast", Algorithm: "fast",
Difficulty: 0, Difficulty: 0,
ReportAs: 0,
}, },
} }
const challengeStr = "hunter" const challengeStr = "hunter"

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

@@ -110,7 +110,6 @@ func TestBotValid(t *testing.T) {
PathRegex: p("Mozilla"), PathRegex: p("Mozilla"),
Challenge: &ChallengeRules{ Challenge: &ChallengeRules{
Difficulty: -1, Difficulty: -1,
ReportAs: 4,
Algorithm: "fast", Algorithm: "fast",
}, },
}, },
@@ -124,7 +123,6 @@ func TestBotValid(t *testing.T) {
PathRegex: p("Mozilla"), PathRegex: p("Mozilla"),
Challenge: &ChallengeRules{ Challenge: &ChallengeRules{
Difficulty: 420, Difficulty: 420,
ReportAs: 4,
Algorithm: "fast", Algorithm: "fast",
}, },
}, },
@@ -361,7 +359,6 @@ func TestBotConfigZero(t *testing.T) {
b.Challenge = &ChallengeRules{ b.Challenge = &ChallengeRules{
Difficulty: 4, Difficulty: 4,
ReportAs: 4,
Algorithm: DefaultAlgorithm, Algorithm: DefaultAlgorithm,
} }
if b.Zero() { if b.Zero() {

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

@@ -18,7 +18,6 @@ thresholds:
challenge: challenge:
algorithm: metarefresh algorithm: metarefresh
difficulty: 1 difficulty: 1
report_as: 1
- name: moderate-suspicion - name: moderate-suspicion
expression: expression:
all: all:
@@ -28,11 +27,9 @@ thresholds:
challenge: challenge:
algorithm: fast algorithm: fast
difficulty: 2 difficulty: 2
report_as: 2
- name: extreme-suspicion - name: extreme-suspicion
expression: weight >= 20 expression: weight >= 20
action: CHALLENGE action: CHALLENGE
challenge: challenge:
algorithm: fast algorithm: fast
difficulty: 4 difficulty: 4
report_as: 4

View File

@@ -24,7 +24,6 @@ var (
Challenge: &ChallengeRules{ Challenge: &ChallengeRules{
Algorithm: "fast", Algorithm: "fast",
Difficulty: anubis.DefaultDifficulty, Difficulty: anubis.DefaultDifficulty,
ReportAs: anubis.DefaultDifficulty,
}, },
}, },
} }

View File

@@ -32,7 +32,6 @@ func TestThresholdValid(t *testing.T) {
Challenge: &ChallengeRules{ Challenge: &ChallengeRules{
Algorithm: "fast", Algorithm: "fast",
Difficulty: 1, Difficulty: 1,
ReportAs: 1,
}, },
}, },
err: nil, err: nil,

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
} }
@@ -66,6 +69,45 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
result := newParsedConfig(c) result := newParsedConfig(c)
result.DefaultDifficulty = defaultDifficulty result.DefaultDifficulty = defaultDifficulty
if c.Logging.Level != nil {
logLevel = c.Logging.Level.String()
}
switch c.Logging.Sink {
case config.LogSinkStdio:
result.Logger = internal.InitSlog(logLevel, os.Stderr)
case config.LogSinkFile:
out := &logrotate.Logger{
Filename: c.Logging.Parameters.Filename,
FilenameTimeFormat: time.RFC3339,
MaxBytes: c.Logging.Parameters.MaxBytes,
MaxAge: c.Logging.Parameters.MaxAge,
MaxBackups: c.Logging.Parameters.MaxBackups,
LocalTime: c.Logging.Parameters.UseLocalTime,
Compress: c.Logging.Parameters.Compress,
}
result.Logger = internal.InitSlog(logLevel, out)
}
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)
@@ -116,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 {
@@ -126,7 +168,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
if b.ASNs != nil { if b.ASNs != nil {
if !hasThothClient { if !hasThothClient {
slog.Warn("You have specified a Thoth specific check but you have no Thoth client configured. Please read https://anubis.techaro.lol/docs/admin/thoth for more information", "check", "asn", "settings", b.ASNs) lg.Warn("You have specified a Thoth specific check but you have no Thoth client configured. Please read https://anubis.techaro.lol/docs/admin/thoth for more information", "check", "asn", "settings", b.ASNs)
continue continue
} }
@@ -135,7 +177,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
if b.GeoIP != nil { if b.GeoIP != nil {
if !hasThothClient { if !hasThothClient {
slog.Warn("You have specified a Thoth specific check but you have no Thoth client configured. Please read https://anubis.techaro.lol/docs/admin/thoth for more information", "check", "geoip", "settings", b.GeoIP) lg.Warn("You have specified a Thoth specific check but you have no Thoth client configured. Please read https://anubis.techaro.lol/docs/admin/thoth for more information", "check", "geoip", "settings", b.GeoIP)
continue continue
} }
@@ -145,7 +187,6 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
if b.Challenge == nil { if b.Challenge == nil {
parsedBot.Challenge = &config.ChallengeRules{ parsedBot.Challenge = &config.ChallengeRules{
Difficulty: defaultDifficulty, Difficulty: defaultDifficulty,
ReportAs: defaultDifficulty,
Algorithm: "fast", Algorithm: "fast",
} }
} else { } else {
@@ -155,7 +196,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
} }
if parsedBot.Challenge.Algorithm == "slow" { if parsedBot.Challenge.Algorithm == "slow" {
slog.Warn("use of deprecated algorithm \"slow\" detected, please update this to \"fast\" when possible", "name", parsedBot.Name) lg.Warn("use of deprecated algorithm \"slow\" detected, please update this to \"fast\" when possible", "name", parsedBot.Name)
} }
} }
@@ -172,17 +213,20 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
for _, t := range c.Thresholds { for _, t := range c.Thresholds {
if t.Challenge != nil && t.Challenge.Algorithm == "slow" { if t.Challenge != nil && t.Challenge.Algorithm == "slow" {
slog.Warn("use of deprecated algorithm \"slow\" detected, please update this to \"fast\" when possible", "name", t.Name) lg.Warn("use of deprecated algorithm \"slow\" detected, please update this to \"fast\" when possible", "name", t.Name)
}
if t.Challenge != nil && t.Challenge.ReportAs != 0 {
lg.Warn("use of deprecated report_as setting detected, please remove this from your policy file when possible", "name", t.Name)
} }
if t.Name == "legacy-anubis-behaviour" && t.Expression.String() == "true" { if t.Name == "legacy-anubis-behaviour" && t.Expression.String() == "true" {
if !warnedAboutThresholds.Load() { if !warnedAboutThresholds.Load() {
slog.Warn("configuration file does not contain thresholds, see docs for details on how to upgrade", "fname", fname, "docs_url", "https://anubis.techaro.lol/docs/admin/configuration/thresholds/") lg.Warn("configuration file does not contain thresholds, see docs for details on how to upgrade", "fname", fname, "docs_url", "https://anubis.techaro.lol/docs/admin/configuration/thresholds/")
warnedAboutThresholds.Store(true) warnedAboutThresholds.Store(true)
} }
t.Challenge.Difficulty = defaultDifficulty t.Challenge.Difficulty = defaultDifficulty
t.Challenge.ReportAs = defaultDifficulty
} }
threshold, err := ParsedThresholdFromConfig(t) threshold, err := ParsedThresholdFromConfig(t)
@@ -194,40 +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 c.Logging.Level != nil {
logLevel = c.Logging.Level.String()
}
switch c.Logging.Sink {
case config.LogSinkStdio:
result.Logger = internal.InitSlog(logLevel, os.Stderr)
case config.LogSinkFile:
out := &logrotate.Logger{
Filename: c.Logging.Parameters.Filename,
FilenameTimeFormat: time.RFC3339,
MaxBytes: c.Logging.Parameters.MaxBytes,
MaxAge: c.Logging.Parameters.MaxAge,
MaxBackups: c.Logging.Parameters.MaxBackups,
LocalTime: c.Logging.Parameters.UseLocalTime,
Compress: c.Logging.Parameters.Compress,
}
result.Logger = internal.InitSlog(logLevel, out)
}
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...))
} }

View File

@@ -4,5 +4,4 @@ bots:
action: CHALLENGE action: CHALLENGE
challenge: challenge:
difficulty: 16 difficulty: 16
report_as: 4
algorithm: hunter2 # invalid algorithm algorithm: hunter2 # invalid algorithm

View File

@@ -42,4 +42,3 @@ thresholds:
challenge: challenge:
algorithm: fast algorithm: fast
difficulty: 1 difficulty: 1
report_as: 1

View File

@@ -42,4 +42,3 @@ thresholds:
challenge: challenge:
algorithm: fast algorithm: fast
difficulty: 0 difficulty: 0
report_as: 0

View File

@@ -4,7 +4,6 @@ bots:
action: CHALLENGE action: CHALLENGE
challenge: challenge:
difficulty: 2 difficulty: 2
report_as: 2
algorithm: fast algorithm: fast
status_codes: status_codes:

View File

@@ -155,7 +155,7 @@ const t = (key) => translations[`js_${key}`] || translations[key] || key;
return; return;
} }
status.innerHTML = `${t('calculating_difficulty')} ${rules.report_as}, `; status.innerHTML = `${t('calculating_difficulty')} ${rules.difficulty}, `;
progress.style.display = "inline-block"; progress.style.display = "inline-block";
// the whole text, including "Speed:", as a single node, because some browsers // the whole text, including "Speed:", as a single node, because some browsers
@@ -166,7 +166,7 @@ const t = (key) => translations[`js_${key}`] || translations[key] || key;
let lastSpeedUpdate = 0; let lastSpeedUpdate = 0;
let showingApology = false; let showingApology = false;
const likelihood = Math.pow(16, -rules.report_as); const likelihood = Math.pow(16, -rules.difficulty);
try { try {
const t0 = Date.now(); const t0 = Date.now();