Compare commits

..

6 Commits

Author SHA1 Message Date
Xe Iaso c8bff8f137 docs(admin/policies): give people a python command
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-22 23:52:09 -04:00
Xe Iaso cca88fa520 chore: spelling
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-22 23:27:05 -04:00
Xe Iaso 6ecb909bfd doc: document HTTP basic auth for metrics server
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-22 23:24:23 -04:00
Xe Iaso 1a7b7601f9 feat(metrics): wire up basic auth
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-22 23:16:31 -04:00
Xe Iaso 2cbc1b0917 feat(config): add HTTP basic auth for metrics
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-22 23:16:20 -04:00
Xe Iaso 40de0bf9f4 feat(internal): add basic auth HTTP middleware
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-22 23:04:24 -04:00
44 changed files with 482 additions and 1208 deletions
-2
View File
@@ -39,5 +39,3 @@ wenet
qwertiko
setuplistener
mba
xfu
xou
-4
View File
@@ -120,7 +120,6 @@ fahedouch
fastcgi
FCr
fcrdns
fcvg
fediverse
ffprobe
fhdr
@@ -239,7 +238,6 @@ mnt
Mojeek
mojeekbot
mozilla
mqvh
myclient
mymaster
mypass
@@ -318,7 +316,6 @@ screenshots
searchbot
searx
sebest
seccomp
secretplans
selfsigned
Semrush
@@ -390,7 +387,6 @@ vnd
VPS
Vultr
WAIFU
wcg
weblate
webmaster
webpage
-9
View File
@@ -52,15 +52,6 @@ jobs:
platforms: linux/amd64
push: true
- name: Pin docs image to built digest
working-directory: docs/manifest
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
KUSTOMIZE_VERSION=v5.4.3
curl -fsSL "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize/${KUSTOMIZE_VERSION}/kustomize_${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" | tar -xz
./kustomize edit set image "ghcr.io/techarohq/anubis/docs=ghcr.io/techarohq/anubis/docs@${DIGEST}"
- name: Apply k8s manifests to limsa lominsa
uses: actions-hub/kubectl@934aaa4354bbbc3d2176ae8d7cae92d515032dff # v1.35.3
env:
-1
View File
@@ -27,7 +27,6 @@ jobs:
- palemoon/amd64
#- palemoon/i386
- robots_txt
- traefik
runs-on: ubuntu-latest
steps:
- name: Checkout code
+1 -1
View File
@@ -259,7 +259,7 @@ func main() {
}
lg.Info("loading policy file", "fname", *policyFname)
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty, *slogLevel, strings.TrimSpace(*target) == "")
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty, *slogLevel)
if err != nil {
log.Fatalf("can't parse policy file: %v", err)
}
+2 -2
View File
@@ -1,11 +1,11 @@
FROM docker.io/library/node:22.22.3-alpine AS build
FROM docker.io/library/node:lts AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM ghcr.io/xe/nginx-micro:v1.29.0
FROM ghcr.io/xe/nginx-micro
COPY --from=build /app/build /www
COPY ./manifest/cfg/nginx/nginx.conf /conf
LABEL org.opencontainers.image.source="https://github.com/TecharoHQ/anubis"
-15
View File
@@ -13,8 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- This changes the project to: -->
- Patch [GHSA-6wcg-mqvh-fcvg](https://github.com/TecharoHQ/anubis/security/advisories/GHSA-6wcg-mqvh-fcvg) by containing subrequest logic to Anubis instances in subrequest mode.
- Implement robot9001 style delays on the honeypot feature so that the first hit takes 1 millisecond, the second takes 2, etc.
- Move metrics server configuration to [the policy file](./admin/policies.mdx#metrics-server).
- Expose [pprof endpoints](https://pkg.go.dev/net/http/pprof) on the metrics listener to enable profiling Anubis in production.
- fix: prevent nil pointer panic in challenge validation when threshold rules match during PassChallenge (#1463)
@@ -22,22 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed mixed tab/space indentation in Caddy documentation code block
- Improve error messages and fix broken REDIRECT_DOMAINS link in docs ([#1193](https://github.com/TecharoHQ/anubis/issues/1193))
- Add Bulgarian locale ([#1394](https://github.com/TecharoHQ/anubis/pull/1394))
- Fixed case-sensitivity mismatch in geoipchecker.go
- Fix CEL internal errors when iterating `headers`/`query` map wrappers by implementing map iterators for `HTTPHeaders` and `URLValues` ([#1465](https://github.com/TecharoHQ/anubis/pull/1465)).
- Enable [metrics serving via TLS](./admin/policies.mdx#tls), including [mutual TLS (mTLS)](./admin/policies.mdx#mtls).
- Enable [HTTP basic auth](./admin/policies.mdx#http-basic-authentication) for the metrics server.
- Fix a bug in the dataset poisoning maze that could allow denial of service [#1580](https://github.com/TecharoHQ/anubis/issues/1580).
- Add config option to add ASN to logs/metrics.
- Log weight when issuing challenge.
- Gate pprof endpoints behind `metrics.debug` in the policy file.
- Limit naive honeypot r9k delay to one second.
- Fix an obscure case where adding query values to a subrequest match could cause an invalid rule match when using path based matching for protected resources.
- Fix an edge case where load average expression values could nil pointer dereference when Anubis just started up.
- Fix an obscure case where Anubis in subrequest mode could allow redirects to invalid domains with strange instructions.
- Fix `path_regex` and CEL `path` rules not matching when using Traefik `forwardAuth` middleware. Anubis now checks `X-Forwarded-Uri` (Traefik) in addition to `X-Original-URI` (nginx) when resolving the request path in subrequest mode ([#1628](https://github.com/TecharoHQ/anubis/issues/1628)).
- Validate bounds in the CEL `randInt` helper so non-positive or platform-overflowing arguments surface a typed CEL error instead of an evaluator panic.
- Harden the public docs deployment: add a pod-level security context to the nginx container (non-root uid 101, dropped capabilities, read-only root filesystem, `RuntimeDefault` seccomp) and rebind it to unprivileged port `8080`.
- Pin docs deployment images to immutable digests with `imagePullPolicy: IfNotPresent`, and have the docs-deploy workflow overlay the just-built digest via `kustomize edit set image` so each rollout references an auditable artifact instead of a floating `:main` tag. The docs `Dockerfile` now pins `node` and `nginx-micro` base images to specific versions.
## v1.25.0: Necron
-19
View File
@@ -138,24 +138,6 @@ metrics:
socketMode: "0700" # must be a string
```
### Debug routes
Anubis' metrics server supports [pprof](https://pkg.go.dev/runtime/pprof), the Go standard library tool for profiling Go applications. This is very useful for debugging how Anubis works in the wild with regards to CPU, multicore, and RAM usage. pprof is very powerful and can expose command line arguments as part of the debugging setup (inside Google, everything is done with command line flags).
Prior versions of Anubis exposed pprof endpoints on all TCP bindhosts by default. This means that machines with incorrectly configured firewalls can expose command line arguments to the public internet in the right conditions.
In order to enable pprof profiling endpoints on the Metrics server, set the `debug` flag under the `metrics` block:
```yaml
metrics:
bind: ":9090"
network: "tcp"
debug: true
```
To err on the side of caution, this defaults to disabled. If this defaults migration breaks your configuration, please let us know in a ticket.
### TLS
If you want to serve the metrics server over TLS, use the `tls` block:
@@ -429,7 +411,6 @@ Anubis exposes the following logging settings in the policy file:
| `level` | [log level](#log-levels) | `info` | The logging level threshold. Any logs that are at or above this threshold will be drained to the sink. Any other logs will be discarded. |
| `sink` | string | `stdio`, `file` | The sink where the logs drain to as they are being recorded in Anubis. |
| `parameters` | object | | Parameters for the given logging sink. This will vary based on the logging sink of choice. See below for more information. |
| `asn` | bool | `true`, `false` | Add ASN information to logs/metrics. (Requires a Thoth client configured) |
Anubis supports the following logging sinks:
+2 -8
View File
@@ -1,7 +1,7 @@
user nginx;
worker_processes 2;
error_log /dev/stdout warn;
pid /tmp/nginx.pid;
pid /nginx.pid;
events {
worker_connections 1024;
@@ -12,17 +12,11 @@ http {
default_type application/octet-stream;
access_log /dev/stdout;
client_body_temp_path /tmp/client_body;
proxy_temp_path /tmp/proxy;
fastcgi_temp_path /tmp/fastcgi;
uwsgi_temp_path /tmp/uwsgi;
scgi_temp_path /tmp/scgi;
sendfile on;
keepalive_timeout 65;
server {
listen 8080 default_server;
listen 80 default_server;
server_name _;
error_page 404 /404.html;
+8 -23
View File
@@ -20,12 +20,10 @@ spec:
name: nginx-cfg
- name: temporary-data
emptyDir: {}
- name: nginx-tmp
emptyDir: {}
containers:
- name: anubis-docs
image: ghcr.io/techarohq/anubis/docs@sha256:f13a7c862bbcba8e19feba9f157120c6f03e23b03367ace4ca55da69dc894e12
imagePullPolicy: IfNotPresent
image: ghcr.io/techarohq/anubis/docs:main
imagePullPolicy: Always
resources:
limits:
memory: "128Mi"
@@ -36,36 +34,23 @@ spec:
volumeMounts:
- name: nginx
mountPath: /conf
- name: nginx-tmp
mountPath: /tmp
ports:
- containerPort: 8080
- containerPort: 80
readinessProbe:
httpGet:
path: /
port: 8080
port: 80
initialDelaySeconds: 1
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 8080
port: 80
initialDelaySeconds: 10
periodSeconds: 20
securityContext:
runAsUser: 101
runAsGroup: 101
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
- name: anubis
image: ghcr.io/techarohq/anubis@sha256:533e57956ae3afd1612dab16f02dd4db937ca14fad5399208f403686e14feed5
imagePullPolicy: IfNotPresent
image: ghcr.io/techarohq/anubis:main
imagePullPolicy: Always
env:
- name: "BIND"
value: ":8081"
@@ -80,7 +65,7 @@ spec:
- name: "SERVE_ROBOTS_TXT"
value: "false"
- name: "TARGET"
value: "http://localhost:8080"
value: "http://localhost:80"
# - name: "SLOG_LEVEL"
# value: "debug"
volumeMounts:
+1 -1
View File
@@ -7,7 +7,7 @@ spec:
app: anubis-docs
ports:
- port: 80
targetPort: 8080
targetPort: 80
name: http
- port: 8081
targetPort: 8081
+1 -1
View File
@@ -106,7 +106,7 @@ require (
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.16.2 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
+2 -2
View File
@@ -189,8 +189,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+22 -17
View File
@@ -5,7 +5,6 @@ import (
_ "embed"
"fmt"
"log/slog"
"math"
"math/rand/v2"
"net/http"
"net/netip"
@@ -77,6 +76,13 @@ type Impl struct {
affirmation, body, title spintax.Spintax
}
func (i *Impl) incrementUA(ctx context.Context, userAgent string) int {
result, _ := i.uaWeight.Get(ctx, internal.SHA256sum(userAgent))
result++
i.uaWeight.Set(ctx, internal.SHA256sum(userAgent), result, time.Hour)
return result
}
func (i *Impl) incrementNetwork(ctx context.Context, network string) int {
result, _ := i.networkWeight.Get(ctx, internal.SHA256sum(network))
result++
@@ -84,19 +90,20 @@ func (i *Impl) incrementNetwork(ctx context.Context, network string) int {
return result
}
func (i *Impl) CheckUA() checker.Impl {
return checker.Func(func(r *http.Request) (bool, error) {
result, _ := i.uaWeight.Get(r.Context(), internal.SHA256sum(r.UserAgent()))
if result >= 25 {
return true, nil
}
return false, nil
})
}
func (i *Impl) CheckNetwork() checker.Impl {
return checker.Func(func(r *http.Request) (bool, error) {
realIP, _ := internal.RealIP(r)
if !realIP.IsValid() {
realIP = netip.MustParseAddr(r.Header.Get("X-Real-Ip"))
}
network, ok := internal.ClampIP(realIP)
if !ok {
return false, nil
}
result, _ := i.networkWeight.Get(r.Context(), internal.SHA256sum(network.String()))
result, _ := i.uaWeight.Get(r.Context(), internal.SHA256sum(r.UserAgent()))
if result >= 25 {
return true, nil
}
@@ -157,6 +164,7 @@ func (i *Impl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
networkCount := i.incrementNetwork(r.Context(), network.String())
uaCount := i.incrementUA(r.Context(), r.UserAgent())
stage := r.PathValue("stage")
@@ -164,14 +172,11 @@ func (i *Impl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
lg.Debug("found new entrance point", "id", id, "stage", stage, "userAgent", r.UserAgent(), "clampedIP", network)
} else {
switch {
case networkCount%256 == 0:
lg.Warn("found possible crawler", "id", id, "network", network, "userAgent", r.UserAgent())
case networkCount%256 == 0, uaCount%256 == 0:
lg.Warn("found possible crawler", "id", id, "network", network)
}
}
millisecondAmount := min(math.Pow(float64(networkCount), 2), 1000)
time.Sleep(time.Duration(millisecondAmount) * time.Millisecond)
spins := i.makeSpins()
affirmations := i.makeAffirmations()
title := i.makeTitle()
+1 -1
View File
@@ -595,7 +595,7 @@ func spawnAnubisWithOptions(t *testing.T, basePrefix string) string {
fmt.Fprintf(w, "<html><body><span id=anubis-test>%d</span></body></html>", time.Now().Unix())
})
policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), "", anubis.DefaultDifficulty, "info", false)
policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), "", anubis.DefaultDifficulty, "info")
if err != nil {
t.Fatal(err)
}
+15 -72
View File
@@ -11,7 +11,6 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -33,7 +32,6 @@ import (
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/store"
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
// challenge implementations
_ "github.com/TecharoHQ/anubis/lib/challenge/metarefresh"
@@ -41,52 +39,31 @@ import (
_ "github.com/TecharoHQ/anubis/lib/challenge/proofofwork"
)
type contextKey int
const asnContextKey contextKey = iota
type asnInfo struct {
ASN string
Description string
}
func asnFromContext(ctx context.Context) (string, string) {
if v, ok := ctx.Value(asnContextKey).(asnInfo); ok {
return v.ASN, v.Description
}
return "", ""
}
var (
challengesIssued = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_challenges_issued",
Help: "The total number of challenges issued",
}, []string{"method", "asn", "asn_description"})
}, []string{"method"})
challengesValidated = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_challenges_validated",
Help: "The total number of challenges validated",
}, []string{"method", "asn", "asn_description"})
}, []string{"method"})
droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_dronebl_hits",
Help: "The total number of hits from DroneBL",
}, []string{"status", "asn", "asn_description"})
}, []string{"status"})
failedValidations = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_failed_validations",
Help: "The total number of failed validations",
}, []string{"method", "asn", "asn_description"})
}, []string{"method"})
requestsProxied = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_proxied_requests_total",
Help: "Number of requests proxied through Anubis to upstream targets",
}, []string{"host", "asn", "asn_description"})
requestsByASN = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_requests_by_asn_total",
Help: "Number of requests by ASN",
}, []string{"asn", "asn_description"})
}, []string{"host"})
)
type Server struct {
@@ -101,28 +78,6 @@ type Server struct {
hs512Secret []byte
}
func (s *Server) getRequestLogger(r *http.Request) (*slog.Logger, *http.Request) {
lg := internal.GetRequestLogger(s.logger, r)
if s.policy.LogASN && s.policy.ThothClient != nil {
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
ip := r.Header.Get("X-Real-Ip")
if info, err := s.policy.ThothClient.IPToASN.Lookup(ctx, &iptoasnv1.LookupRequest{IpAddress: ip}); err == nil && info.GetAnnounced() {
asn := strconv.FormatUint(uint64(info.GetAsNumber()), 10)
lg = lg.With("asn", info.GetAsNumber(), "asn_description", info.GetDescription())
requestsByASN.WithLabelValues(asn, info.GetDescription()).Inc()
r = r.WithContext(context.WithValue(r.Context(), asnContextKey, asnInfo{
ASN: asn,
Description: info.GetDescription(),
}))
}
}
return lg, r
}
func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
// return ED25519 key if HS512 is not set
if len(s.hs512Secret) == 0 {
@@ -186,7 +141,7 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.L
return nil, err
}
lg.Info("new challenge issued", "challenge", id.String(), "weight", cr.Weight)
lg.Info("new challenge issued", "challenge", id.String())
return &chall, err
}
@@ -238,7 +193,7 @@ func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request)
}
func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
lg, r := s.getRequestLogger(r)
lg := internal.GetRequestLogger(s.logger, r)
if val, _ := s.store.Get(r.Context(), fmt.Sprintf("ogtags:allow:%s%s", r.Host, r.URL.String())); val != nil {
lg.Debug("serving opengraph tag asset")
@@ -263,10 +218,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
r.Header.Add("X-Anubis-Rule", cr.Name)
r.Header.Add("X-Anubis-Action", string(cr.Rule))
lg = lg.With("check_result", cr)
{
asn, asnDesc := asnFromContext(r.Context())
policy.Applications.WithLabelValues(cr.Name, string(cr.Rule), asn, asnDesc).Add(1)
}
policy.Applications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1)
ip := r.Header.Get("X-Real-Ip")
@@ -396,8 +348,7 @@ func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string,
lg.Error("can't look up ip in dnsbl", "err", err)
}
db.Set(r.Context(), ip, resp, 24*time.Hour)
asn, asnDesc := asnFromContext(r.Context())
droneBLHits.WithLabelValues(resp.String(), asn, asnDesc).Inc()
droneBLHits.WithLabelValues(resp.String()).Inc()
}
if resp != dnsbl.AllGood {
@@ -415,7 +366,7 @@ func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string,
}
func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
lg, r := s.getRequestLogger(r)
lg := internal.GetRequestLogger(s.logger, r)
localizer := localization.GetLocalizer(r)
redir := r.FormValue("redir")
@@ -484,14 +435,11 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
return
}
lg.Debug("made challenge", "challenge", chall, "rules", rule.Challenge, "cr", cr)
{
asn, asnDesc := asnFromContext(r.Context())
challengesIssued.WithLabelValues("api", asn, asnDesc).Inc()
}
challengesIssued.WithLabelValues("api").Inc()
}
func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
lg, r := s.getRequestLogger(r)
lg := internal.GetRequestLogger(s.logger, r)
localizer := localization.GetLocalizer(r)
redir := r.FormValue("redir")
@@ -582,8 +530,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
}
if err := impl.Validate(r, lg, in); err != nil {
asn, asnDesc := asnFromContext(r.Context())
failedValidations.WithLabelValues(rule.Challenge.Algorithm, asn, asnDesc).Inc()
failedValidations.WithLabelValues(rule.Challenge.Algorithm).Inc()
var cerr *challenge.Error
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
lg.Debug("challenge validate call failed", "err", err)
@@ -643,10 +590,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
lg.Debug("can't update information about challenge", "err", err)
}
{
asn, asnDesc := asnFromContext(r.Context())
challengesValidated.WithLabelValues(rule.Challenge.Algorithm, asn, asnDesc).Inc()
}
challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc()
lg.Debug("challenge passed, redirecting to app")
http.Redirect(w, r, redir, http.StatusFound)
}
@@ -685,8 +629,7 @@ func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *p
return cr("bot/"+b.Name, b.Action, weight), &b, nil
case config.RuleWeigh:
lg.Debug("adjusting weight", "name", b.Name, "delta", b.Weight.Adjust)
asn, asnDesc := asnFromContext(r.Context())
policy.Applications.WithLabelValues("bot/"+b.Name, "WEIGH", asn, asnDesc).Add(1)
policy.Applications.WithLabelValues("bot/"+b.Name, "WEIGH").Add(1)
weight += b.Weight.Adjust
}
}
+2 -2
View File
@@ -58,7 +58,7 @@ func loadPolicies(t *testing.T, fname string, difficulty int) *policy.ParsedConf
t.Logf("loading policy file: %s", fname)
anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty, "info", false)
anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty, "info")
if err != nil {
t.Fatal(err)
}
@@ -250,7 +250,7 @@ func TestLoadPolicies(t *testing.T) {
}
defer fin.Close()
if _, err := policy.ParseConfig(t.Context(), fin, fname, 4, "info", false); err != nil {
if _, err := policy.ParseConfig(t.Context(), fin, fname, 4, "info"); err != nil {
t.Fatal(err)
}
})
+10 -2
View File
@@ -55,7 +55,7 @@ type Options struct {
DifficultyInJWT bool
}
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string, subrequestMode bool) (*policy.ParsedConfig, error) {
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string) (*policy.ParsedConfig, error) {
var fin io.ReadCloser
var err error
@@ -79,7 +79,7 @@ func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty
}
}(fin)
anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty, logLevel, subrequestMode)
anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty, logLevel)
if err != nil {
return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err)
}
@@ -190,6 +190,14 @@ func New(opts Options) (*Server, error) {
},
Name: "honeypot/network",
},
policy.Bot{
Rules: mazeGen.CheckUA(),
Action: config.RuleWeigh,
Weight: &config.Weight{
Adjust: 30,
},
Name: "honeypot/user-agent",
},
)
} else {
result.logger.Error("can't init honeypot subsystem", "err", err)
-1
View File
@@ -17,7 +17,6 @@ type Logging struct {
Sink string `json:"sink"` // Logging sink, either "stdio" or "file"
Level *slog.Level `json:"level"` // Log level, if set supersedes the level in flags
Parameters *LoggingFileConfig `json:"parameters"` // Logging parameters, to be dynamic in the future
LogASN bool `json:"asn" yaml:"asn"`
}
const (
-1
View File
@@ -32,7 +32,6 @@ type Metrics struct {
Network string `json:"network" yaml:"network"`
SocketMode string `json:"socketMode" yaml:"socketMode"`
TLS *MetricsTLS `json:"tls" yaml:"tls"`
Debug bool `json:"debug" yaml:"debug"`
BasicAuth *MetricsBasicAuth `json:"basicAuth" yaml:"basicAuth"`
}
+4 -4
View File
@@ -12,7 +12,7 @@ import (
)
func TestInvalidChallengeMethod(t *testing.T) {
if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4, "info", false); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) {
if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4, "info"); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) {
t.Fatalf("wanted error %v but got %v", policy.ErrChallengeRuleHasWrongAlgorithm, err)
}
}
@@ -25,7 +25,7 @@ func TestBadConfigs(t *testing.T) {
for _, st := range finfos {
t.Run(st.Name(), func(t *testing.T) {
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "bad", st.Name()), anubis.DefaultDifficulty, "info", false); err == nil {
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "bad", st.Name()), anubis.DefaultDifficulty, "info"); err == nil {
t.Fatal(err)
} else {
t.Log(err)
@@ -44,13 +44,13 @@ func TestGoodConfigs(t *testing.T) {
t.Run(st.Name(), func(t *testing.T) {
t.Run("with-thoth", func(t *testing.T) {
ctx := thothmock.WithMockThoth(t)
if _, err := LoadPoliciesOrDefault(ctx, filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info", false); err != nil {
if _, err := LoadPoliciesOrDefault(ctx, filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info"); err != nil {
t.Fatal(err)
}
})
t.Run("without-thoth", func(t *testing.T) {
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info", false); err != nil {
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info"); err != nil {
t.Fatal(err)
}
})
+14 -19
View File
@@ -207,7 +207,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
return
}
lg, r := s.getRequestLogger(r)
lg := internal.GetRequestLogger(s.logger, r)
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) {
lg.Error("client was given a challenge but does not in fact support gzip compression")
@@ -215,10 +215,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
return
}
{
asn, asnDesc := asnFromContext(r.Context())
challengesIssued.WithLabelValues("embedded", asn, asnDesc).Add(1)
}
challengesIssued.WithLabelValues("embedded").Add(1)
chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
if err != nil {
lg.Error("can't get challenge", "err", err)
@@ -309,14 +306,14 @@ func (s *Server) constructRedirectURL(r *http.Request) (string, error) {
case "http", "https":
// allowed
default:
lg, _ := s.getRequestLogger(r)
lg := internal.GetRequestLogger(s.logger, r)
lg.Warn("invalid protocol in X-Forwarded-Proto", "proto", proto)
return "", errors.New(localizer.T("invalid_redirect"))
}
// Check if host is allowed in RedirectDomains (supports '*' via glob)
if len(s.opts.RedirectDomains) > 0 && !matchRedirectDomain(s.opts.RedirectDomains, host) {
lg, _ := s.getRequestLogger(r)
lg := internal.GetRequestLogger(s.logger, r)
lg.Debug("domain not allowed", "domain", host)
return "", errors.New(localizer.T("redirect_domain_not_allowed"))
}
@@ -403,15 +400,14 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
localizer := localization.GetLocalizer(r)
redir := r.FormValue("redir")
urlParsed, err := url.Parse(redir)
urlParsed, err := url.ParseRequestURI(redir)
if err != nil {
s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), http.StatusBadRequest)
return
}
if urlParsed.Opaque != "" || (urlParsed.Scheme == "" && strings.HasPrefix(redir, "//")) {
s.respondWithStatus(w, r, localizer.T("invalid_redirect"), "", http.StatusBadRequest)
return
// if ParseRequestURI fails, try as relative URL
urlParsed, err = r.URL.Parse(redir)
if err != nil {
s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), http.StatusBadRequest)
return
}
}
// validate URL scheme to prevent javascript:, data:, file:, tel:, etc.
@@ -419,7 +415,7 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
case "", "http", "https":
// allowed: empty scheme means relative URL
default:
lg, _ := s.getRequestLogger(r)
lg := internal.GetRequestLogger(s.logger, r)
lg.Warn("XSS attempt blocked, invalid redirect scheme", "scheme", urlParsed.Scheme, "redir", redir)
s.respondWithStatus(w, r, localizer.T("invalid_redirect"), "", http.StatusBadRequest)
return
@@ -431,7 +427,7 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
hostMismatch := r.URL.Host != "" && urlParsed.Host != "" && urlParsed.Host != r.URL.Host
if hostNotAllowed || hostMismatch {
lg, _ := s.getRequestLogger(r)
lg := internal.GetRequestLogger(s.logger, r)
lg.Debug("domain not allowed", "domain", urlParsed.Host)
s.respondWithStatus(w, r, localizer.T("redirect_domain_not_allowed"), makeCode(err), http.StatusBadRequest)
return
@@ -446,8 +442,7 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
web.Base(localizer.T("you_are_not_a_bot"), web.StaticHappy(localizer), s.policy.Impressum, localizer),
).ServeHTTP(w, r)
} else {
asn, asnDesc := asnFromContext(r.Context())
requestsProxied.WithLabelValues(r.Host, asn, asnDesc).Inc()
requestsProxied.WithLabelValues(r.Host).Inc()
r = s.stripBasePrefixFromRequest(r)
s.next.ServeHTTP(w, r)
}
-14
View File
@@ -223,17 +223,3 @@ func TestNoCacheOnError(t *testing.T) {
})
}
}
func TestRejectsHostlessRedirect(t *testing.T) {
pol := loadPolicies(t, "testdata/useragent.yaml", 0)
srv := spawnAnubis(t, Options{Policy: pol, RedirectDomains: []string{"allowed.example"}})
req := httptest.NewRequest(http.MethodGet, "https://anubis.example/.within.website/?redir=%2f%2fevil.example%2fphish", nil)
rr := httptest.NewRecorder()
srv.ServeHTTPNext(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected hostless redirect to be rejected, got HTTP %d body %q", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); got != "" {
t.Fatalf("expected no Location header on rejected redirect, got %q", got)
}
}
+6 -10
View File
@@ -34,15 +34,11 @@ func (s *Server) Run(ctx context.Context, done func()) {
func (s *Server) run(ctx context.Context, lg *slog.Logger) error {
mux := http.NewServeMux()
if s.Config.Debug {
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
}
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
mux.Handle("/metrics", promhttp.Handler())
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
st, ok := internal.GetHealth("anubis")
@@ -68,7 +64,7 @@ func (s *Server) run(ctx context.Context, lg *slog.Logger) error {
ErrorLog: internal.GetFilteredHTTPLogger(),
}
ln, metricsURL, err := internal.SetupListener(s.Config.Network, s.Config.Bind, s.Config.SocketMode)
ln, metricsURL, err := internal.SetupListener(s.Config.Bind, s.Config.Network, s.Config.SocketMode)
if err != nil {
return fmt.Errorf("can't setup listener: %w", err)
}
-49
View File
@@ -1,49 +0,0 @@
package metrics
import (
"context"
"io"
"log/slog"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/TecharoHQ/anubis/lib/config"
)
func TestMetricsPprofCmdlineExposedWithoutAuthentication(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
_ = ln.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
srv := &Server{
Config: &config.Metrics{Network: "tcp", Bind: addr},
Log: slog.Default(),
}
go srv.Run(ctx, func() { close(done) })
url := "http://" + addr + "/debug/pprof/cmdline"
var body []byte
resp, err := http.Get(url)
if err == nil {
body, err = io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("can't read body: %v", err)
}
defer resp.Body.Close()
}
time.Sleep(50 * time.Millisecond)
if strings.Contains(string(body), "metrics.test") {
t.Fatalf("pprof is enabled by default, cmdline process arguments: %q", string(body))
}
cancel()
<-done
}
+6 -17
View File
@@ -13,12 +13,11 @@ import (
)
type CELChecker struct {
program cel.Program
src string
subRequestMode bool
program cel.Program
src string
}
func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns, subRequestMode bool) (*CELChecker, error) {
func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns) (*CELChecker, error) {
env, err := expressions.BotEnvironment(dnsObj)
if err != nil {
return nil, err
@@ -30,9 +29,8 @@ func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns, subRequestMode
}
return &CELChecker{
src: cfg.String(),
program: program,
subRequestMode: subRequestMode,
src: cfg.String(),
program: program,
}, nil
}
@@ -41,7 +39,7 @@ func (cc *CELChecker) Hash() string {
}
func (cc *CELChecker) Check(r *http.Request) (bool, error) {
result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r, cc.subRequestMode})
result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r})
if err != nil {
return false, err
@@ -56,7 +54,6 @@ func (cc *CELChecker) Check(r *http.Request) (bool, error) {
type CELRequest struct {
*http.Request
subRequestMode bool
}
func (cr *CELRequest) Parent() cel.Activation { return nil }
@@ -74,14 +71,6 @@ func (cr *CELRequest) ResolveName(name string) (any, bool) {
case "userAgent":
return cr.UserAgent(), true
case "path":
if cr.subRequestMode {
if xou := cr.Header.Get("X-Original-URI"); xou != "" {
return xou, true
}
if xfu := cr.Header.Get("X-Forwarded-Uri"); xfu != "" {
return xfu, true
}
}
return cr.URL.Path, true
case "query":
return expressions.URLValues{Values: cr.URL.Query()}, true
+1 -75
View File
@@ -23,7 +23,7 @@ func TestCELChecker_MapIterationWrappers(t *testing.T) {
Expression: `headers.exists(k, k == "Accept") && query.exists(k, k == "format")`,
}
checker, err := NewCELChecker(cfg, newTestDNS(t), false)
checker, err := NewCELChecker(cfg, newTestDNS(t))
if err != nil {
t.Fatalf("creating CEL checker failed: %v", err)
}
@@ -42,77 +42,3 @@ func TestCELChecker_MapIterationWrappers(t *testing.T) {
t.Fatal("expected expression to evaluate true")
}
}
func TestCELChecker_PathWithForwardedUri(t *testing.T) {
tests := []struct {
name string
expression string
xForwardedUri string
urlPath string
subRequestMode bool
want bool
}{
{
name: "path matches X-Forwarded-Uri in subrequest mode",
expression: `path.startsWith("/admin")`,
xForwardedUri: "/admin/secret",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "path with query string",
expression: `path.startsWith("/api/secret")`,
xForwardedUri: "/api/secret?token=abc",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "path falls back to url path when no header",
expression: `path == "/public/page"`,
urlPath: "/public/page",
subRequestMode: true,
want: true,
},
{
name: "non-subrequest mode ignores X-Forwarded-Uri",
expression: `path.startsWith("/admin")`,
xForwardedUri: "/admin/secret",
urlPath: "/public/page",
subRequestMode: false,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.ExpressionOrList{
Expression: tt.expression,
}
checker, err := NewCELChecker(cfg, newTestDNS(t), tt.subRequestMode)
if err != nil {
t.Fatalf("NewCELChecker() error: %v", err)
}
req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
if tt.xForwardedUri != "" {
req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
}
got, err := checker.Check(req)
if err != nil {
t.Fatalf("Check() error: %v", err)
}
if got != tt.want {
t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Forwarded-Uri=%q)",
got, tt.want, tt.subRequestMode, tt.urlPath, tt.xForwardedUri)
}
})
}
}
+8 -18
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"net/netip"
"net/url"
"regexp"
"strings"
@@ -95,32 +94,23 @@ func (hmc *HeaderMatchesChecker) Hash() string {
}
type PathChecker struct {
regexp *regexp.Regexp
hash string
subRequestMode bool
regexp *regexp.Regexp
hash string
}
func NewPathChecker(rexStr string, subrequestMode bool) (checker.Impl, error) {
func NewPathChecker(rexStr string) (checker.Impl, error) {
rex, err := regexp.Compile(strings.TrimSpace(rexStr))
if err != nil {
return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
}
return &PathChecker{rex, internal.FastHash(rexStr), subrequestMode}, nil
return &PathChecker{rex, internal.FastHash(rexStr)}, nil
}
func (pc *PathChecker) Check(r *http.Request) (bool, error) {
if pc.subRequestMode {
originalUrl := r.Header.Get("X-Original-URI")
if originalUrl == "" {
originalUrl = r.Header.Get("X-Forwarded-Uri")
}
if originalUrl != "" {
if parsed, err := url.ParseRequestURI(originalUrl); err == nil {
originalUrl = parsed.Path
}
if pc.regexp.MatchString(originalUrl) {
return true, nil
}
originalUrl := r.Header.Get("X-Original-URI")
if originalUrl != "" {
if pc.regexp.MatchString(originalUrl) {
return true, nil
}
}
+2 -223
View File
@@ -272,8 +272,8 @@ func TestPathChecker_XOriginalURI(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create the PathChecker in subrequest mode so X-Original-URI is honored.
pc, err := NewPathChecker(tt.regex, true)
// Create the PathChecker
pc, err := NewPathChecker(tt.regex)
if err != nil {
if !tt.expectError {
t.Fatalf("NewPathChecker() unexpected error: %v", err)
@@ -305,224 +305,3 @@ func TestPathChecker_XOriginalURI(t *testing.T) {
})
}
}
// TestPathChecker_GHSA_6wcg_mqvh_fcvg is a regression test for
// https://github.com/TecharoHQ/anubis/security/advisories/GHSA-6wcg-mqvh-fcvg.
//
// PR https://github.com/TecharoHQ/anubis/pull/1015 added the ability for
// reverse proxies using Anubis in subrequest auth mode to look at the path
// of a request as there are many rules in the wild that rely on checking
// the path. This is how access to things like robots.txt or anything in the
// .well-known directory is unaffected by Anubis.
//
// However this logic was also enabled for non-subrequest deployments of Anubis,
// meaning that a specially crafted request could include a /.well-known/
// path in it and then get around Anubis with little effort.
//
// This fix gates the logic behind a new plumbed variable named subrequestMode
// that only fires when Anubis is running in subrequest auth mode. This
// properly contains that workaround so that the logic does not fire in
// most deployments.
func TestPathChecker_GHSA_6wcg_mqvh_fcvg(t *testing.T) {
tests := []struct {
name string
regex string
urlPath string
xOriginalURI string
subRequestMode bool
want bool
}{
{
name: "default mode ignores spoofed X-Original-URI when real path matches",
regex: "^/admin/.*",
urlPath: "/admin/secret",
xOriginalURI: "/public/index",
subRequestMode: false,
want: true,
},
{
name: "default mode ignores spoofed X-Original-URI when real path does not match",
regex: "^/admin/.*",
urlPath: "/public/index",
xOriginalURI: "/admin/secret",
subRequestMode: false,
want: false,
},
{
name: "default mode without X-Original-URI matches real path",
regex: "^/admin/.*",
urlPath: "/admin/dashboard",
xOriginalURI: "",
subRequestMode: false,
want: true,
},
{
name: "subrequest mode honors X-Original-URI",
regex: "^/admin/.*",
urlPath: "/auth",
xOriginalURI: "/admin/secret",
subRequestMode: true,
want: true,
},
{
name: "subrequest mode falls back to URL.Path when X-Original-URI does not match",
regex: "^/admin/.*",
urlPath: "/admin/dashboard",
xOriginalURI: "/public/index",
subRequestMode: true,
want: true,
},
{
name: "subrequest mode with empty X-Original-URI uses URL.Path",
regex: "^/admin/.*",
urlPath: "/admin/dashboard",
xOriginalURI: "",
subRequestMode: true,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pc, err := NewPathChecker(tt.regex, tt.subRequestMode)
if err != nil {
t.Fatalf("NewPathChecker(%q, %v) returned error: %v", tt.regex, tt.subRequestMode, err)
}
req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
if tt.xOriginalURI != "" {
req.Header.Set("X-Original-URI", tt.xOriginalURI)
}
got, err := pc.Check(req)
if err != nil {
t.Fatalf("Check() unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Original-URI=%q)",
got, tt.want, tt.subRequestMode, tt.urlPath, tt.xOriginalURI)
}
})
}
}
func TestPathChecker_XForwardedUri(t *testing.T) {
tests := []struct {
name string
regex string
xForwardedUri string
xOriginalURI string
urlPath string
subRequestMode bool
want bool
}{
{
name: "X-Forwarded-Uri matches regex in subrequest mode",
regex: "^/admin/.*",
xForwardedUri: "/admin/users",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "X-Forwarded-Uri with query string",
regex: "^/admin/.*",
xForwardedUri: "/admin/users?page=1",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "X-Original-URI takes priority over X-Forwarded-Uri",
regex: "^/admin/.*",
xForwardedUri: "/public/page",
xOriginalURI: "/admin/users",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "falls back to X-Forwarded-Uri when no X-Original-URI",
regex: "^/admin/.*",
xForwardedUri: "/admin/dashboard",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: true,
},
{
name: "neither header matches, url path matches",
regex: "^/public/.*",
xForwardedUri: "/admin/users",
urlPath: "/public/page",
subRequestMode: true,
want: true,
},
{
name: "nothing matches",
regex: "^/admin/.*",
xForwardedUri: "/public/page",
urlPath: "/.within.website/x/cmd/anubis/api/check",
subRequestMode: true,
want: false,
},
{
name: "non-subrequest mode ignores X-Forwarded-Uri",
regex: "^/admin/.*",
xForwardedUri: "/admin/users",
urlPath: "/public/page",
subRequestMode: false,
want: false,
},
{
name: "non-subrequest mode uses url path",
regex: "^/admin/.*",
xForwardedUri: "/public/page",
urlPath: "/admin/secret",
subRequestMode: false,
want: true,
},
{
name: "empty X-Forwarded-Uri falls back to url path",
regex: "^/check$",
urlPath: "/check",
subRequestMode: true,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pc, err := NewPathChecker(tt.regex, tt.subRequestMode)
if err != nil {
t.Fatalf("NewPathChecker(%q, %v) returned error: %v", tt.regex, tt.subRequestMode, err)
}
req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
if tt.xForwardedUri != "" {
req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
}
if tt.xOriginalURI != "" {
req.Header.Set("X-Original-URI", tt.xOriginalURI)
}
got, err := pc.Check(req)
if err != nil {
t.Fatalf("Check() unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Forwarded-Uri=%q, X-Original-URI=%q)",
got, tt.want, tt.subRequestMode, tt.urlPath, tt.xForwardedUri, tt.xOriginalURI)
}
})
}
}
+1 -10
View File
@@ -222,16 +222,7 @@ func New(opts ...cel.EnvOption) (*cel.Env, error) {
return types.ValOrErr(val, "value is not an integer, but is %T", val)
}
if n <= 0 {
return types.NewErr("randInt bound must be positive, got %d", int64(n))
}
bound := int(n)
if types.Int(bound) != n {
return types.NewErr("randInt bound %d overflows platform int", int64(n))
}
return types.Int(rand.IntN(bound))
return types.Int(rand.IntN(int(n)))
}),
),
),
@@ -9,7 +9,6 @@ import (
"github.com/TecharoHQ/anubis/internal/dns"
"github.com/TecharoHQ/anubis/lib/store/memory"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
)
@@ -689,14 +688,6 @@ func TestNewEnvironment(t *testing.T) {
description: "should return values in correct range",
shouldCompile: true,
},
{
name: "randInt-large-bound",
expression: `randInt(2147483647) >= 0`,
variables: map[string]any{},
expectBool: boolPtr(true),
description: "should accept int32-max bounds without overflow",
shouldCompile: true,
},
{
name: "strings-extension-size",
expression: `"hello".size() == 5`,
@@ -759,65 +750,3 @@ func TestNewEnvironment(t *testing.T) {
func boolPtr(b bool) *bool {
return &b
}
func TestRandIntInvalidBounds(t *testing.T) {
env, err := New(cel.Variable("contentLength", cel.IntType))
if err != nil {
t.Fatalf("failed to create environment: %v", err)
}
tests := []struct {
name string
expression string
variables map[string]any
wantErrText string
description string
}{
{
name: "zero-bound-literal",
expression: `randInt(0)`,
variables: map[string]any{},
wantErrText: "randInt bound must be positive",
description: "randInt(0) should return a CEL error, not panic",
},
{
name: "negative-bound-literal",
expression: `randInt(-5)`,
variables: map[string]any{},
wantErrText: "randInt bound must be positive",
description: "randInt(-5) should return a CEL error, not panic",
},
{
name: "zero-bound-from-variable",
expression: `randInt(contentLength)`,
variables: map[string]any{"contentLength": 0},
wantErrText: "randInt bound must be positive",
description: "attacker-controlled zero contentLength should error gracefully",
},
{
name: "negative-bound-from-variable",
expression: `randInt(contentLength)`,
variables: map[string]any{"contentLength": -1},
wantErrText: "randInt bound must be positive",
description: "attacker-controlled negative contentLength should error gracefully",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prog, err := Compile(env, tt.expression)
if err != nil {
t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
}
result, _, err := prog.Eval(tt.variables)
if err == nil {
t.Fatalf("%s: expected an evaluation error, got result %v", tt.description, result)
}
if !strings.Contains(err.Error(), tt.wantErrText) {
t.Errorf("%s: expected error containing %q, got %q", tt.description, tt.wantErrText, err.Error())
}
})
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ var (
)
func init() {
globalLoadAvg = &loadAvg{data: &load.AvgStat{}}
globalLoadAvg = &loadAvg{}
go globalLoadAvg.updateThread(context.Background())
}
+4 -14
View File
@@ -27,7 +27,7 @@ var (
Applications = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_policy_results",
Help: "The results of each policy rule",
}, []string{"rule", "action", "asn", "asn_description"})
}, []string{"rule", "action"})
ErrChallengeRuleHasWrongAlgorithm = errors.New("config.Bot.ChallengeRules: algorithm is invalid")
warnedAboutThresholds = &atomic.Bool{}
@@ -47,8 +47,6 @@ type ParsedConfig struct {
Dns *dns.Dns
Logger *slog.Logger
Metrics *config.Metrics
ThothClient *thoth.Client
LogASN bool
}
func newParsedConfig(orig *config.Config) *ParsedConfig {
@@ -60,7 +58,7 @@ func newParsedConfig(orig *config.Config) *ParsedConfig {
}
}
func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int, logLevel string, subrequestMode bool) (*ParsedConfig, error) {
func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int, logLevel string) (*ParsedConfig, error) {
c, err := config.Load(fin, fname)
if err != nil {
return nil, err
@@ -72,10 +70,6 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
result := newParsedConfig(c)
result.DefaultDifficulty = defaultDifficulty
result.LogASN = c.Logging.LogASN
if hasThothClient {
result.ThothClient = tc
}
if c.Logging.Level != nil {
logLevel = c.Logging.Level.String()
@@ -100,10 +94,6 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
lg := result.Logger.With("at", "config-validate")
if result.LogASN && !hasThothClient {
lg.Warn("logging.asn is enabled but no Thoth client is configured; ASN logging and metrics will be skipped. Please read https://anubis.techaro.lol/docs/admin/thoth for more information")
}
stFac, ok := store.Get(c.Store.Backend)
switch ok {
case true:
@@ -152,7 +142,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
}
if b.PathRegex != nil {
c, err := NewPathChecker(*b.PathRegex, subrequestMode)
c, err := NewPathChecker(*b.PathRegex)
if err != nil {
validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s path regex: %w", b.Name, err))
} else {
@@ -170,7 +160,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
}
if b.Expression != nil {
c, err := NewCELChecker(b.Expression, result.Dns, subrequestMode)
c, err := NewCELChecker(b.Expression, result.Dns)
if err != nil {
validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s expressions: %w", b.Name, err))
} else {
+4 -30
View File
@@ -1,8 +1,6 @@
package policy
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
@@ -21,7 +19,7 @@ func TestDefaultPolicyMustParse(t *testing.T) {
}
defer fin.Close()
if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty, "info", false); err != nil {
if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty, "info"); err != nil {
t.Fatalf("can't parse config: %v", err)
}
}
@@ -43,7 +41,7 @@ func TestGoodConfigs(t *testing.T) {
defer fin.Close()
ctx := thothmock.WithMockThoth(t)
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info"); err != nil {
t.Fatal(err)
}
})
@@ -55,7 +53,7 @@ func TestGoodConfigs(t *testing.T) {
}
defer fin.Close()
if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, "info"); err != nil {
t.Fatal(err)
}
})
@@ -79,7 +77,7 @@ func TestBadConfigs(t *testing.T) {
}
defer fin.Close()
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err == nil {
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info"); err == nil {
t.Fatal(err)
} else {
t.Log(err)
@@ -87,27 +85,3 @@ func TestBadConfigs(t *testing.T) {
})
}
}
func TestPathCheckerStripsForwardedURIQuery(t *testing.T) {
checker, err := NewPathChecker("^/admin$", true)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "https://anubis.local/.within.website/x/cmd/anubis/api/check", nil)
req.Header.Set("X-Forwarded-Uri", "/admin?x=1")
matched, err := checker.Check(req)
if err != nil {
t.Fatal(err)
}
if !matched {
t.Fatalf("expected exact path checker to match forwarded URI when query string is appended")
}
req.Header.Set("X-Forwarded-Uri", "/admin")
matched, err = checker.Check(req)
if err != nil {
t.Fatal(err)
}
if !matched {
t.Fatalf("expected exact path checker to match forwarded URI without query string")
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ func (c *Client) GeoIPCheckerFor(countries []string) checker.Impl {
var sb strings.Builder
fmt.Fprintln(&sb, "GeoIPChecker")
for _, cc := range countries {
countryMap[strings.ToLower(cc)] = struct{}{}
countryMap[cc] = struct{}{}
fmt.Fprintln(&sb, cc)
}
+357 -324
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -20,11 +20,11 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@commitlint/cli": "^20.5.3",
"@commitlint/config-conventional": "^20.5.3",
"baseline-browser-mapping": "^2.10.27",
"cssnano": "^7.1.8",
"cssnano-preset-advanced": "^7.0.16",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"baseline-browser-mapping": "^2.10.15",
"cssnano": "^7.1.4",
"cssnano-preset-advanced": "^7.0.12",
"esbuild": "^0.28.0",
"husky": "^9.1.7",
"playwright": "^1.52.0",
@@ -32,7 +32,7 @@
"postcss-import": "^16.1.1",
"postcss-import-url": "^7.2.0",
"postcss-url": "^10.1.3",
"prettier": "^3.8.3"
"prettier": "^3.8.1"
},
"dependencies": {
"@aws-crypto/sha256-js": "^5.2.0",
-16
View File
@@ -1,16 +0,0 @@
bots:
- name: block-admin-via-regex
path_regex: ^/admin(/.*)?$
action: DENY
- name: block-secret-via-cel
expression:
all:
- 'path.startsWith("/api/secret")'
action: DENY
- import: (data)/meta/default-config.yaml
status_codes:
CHALLENGE: 200
DENY: 403
-27
View File
@@ -1,27 +0,0 @@
services:
traefik:
image: traefik:v3.3
restart: always
ports:
- 8080:80
volumes:
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./http.yaml:/config/http.yaml:ro
anubis:
image: ko.local/anubis
restart: always
environment:
BIND: ":8080"
TARGET: " "
POLICY_FNAME: /etc/techaro/anubis.yaml
PUBLIC_URL: http://localhost:8080/.within.website/x/cmd/anubis
COOKIE_DOMAIN: localhost
USE_REMOTE_ADDRESS: "true"
volumes:
- ./anubis.yaml:/etc/techaro/anubis.yaml
backend:
image: ghcr.io/xe/x/httpdebug
pull_policy: always
restart: always
-30
View File
@@ -1,30 +0,0 @@
http:
middlewares:
anubis:
forwardAuth:
address: http://anubis:8080/.within.website/x/cmd/anubis/api/check
trustForwardHeader: true
routers:
anubis-assets:
rule: Host(`localhost`) && PathPrefix(`/.within.website/x/cmd/anubis`)
entryPoints:
- web
service: anubis
backend:
rule: Host(`localhost`)
entryPoints:
- web
service: backend
middlewares:
- anubis
services:
anubis:
loadBalancer:
servers:
- url: http://anubis:8080
backend:
loadBalancer:
servers:
- url: http://backend:3000
-33
View File
@@ -1,33 +0,0 @@
// Smoke test for https://github.com/TecharoHQ/anubis/issues/1628
//
// Traefik's forwardAuth middleware calls Anubis at the literal path
// /.within.website/x/cmd/anubis/api/check and conveys the original URL in the
// X-Forwarded-Uri header. Path-targeting policy rules must match that header
// (not r.URL.Path), otherwise every request looks like a request to /check.
const BASE = "http://localhost:8080";
const UA = "Mozilla/5.0 (compatible; AnubisTraefikSmoke/1.0)";
const cases = [
{ path: "/", expected: 307, why: "control: no DENY rule, default challenge redirect" },
{ path: "/free", expected: 307, why: "control: no DENY rule, default challenge redirect" },
{ path: "/admin", expected: 403, why: "path_regex must match X-Forwarded-Uri, not 307 or 200" },
{ path: "/admin/users", expected: 403, why: "path_regex must match X-Forwarded-Uri, not 307 or 200" },
{ path: "/api/secret", expected: 403, why: "CEL path must match X-Forwarded-Uri, not 307 or 200" },
];
let failed = false;
for (const c of cases) {
const resp = await fetch(`${BASE}${c.path}`, {
headers: { "User-Agent": UA },
redirect: "manual",
});
const ok = resp.status === c.expected;
console.log(
`${ok ? "PASS" : "FAIL"}: GET ${c.path}${resp.status} (want ${c.expected}: ${c.why})`,
);
if (!ok) failed = true;
}
process.exit(failed ? 1 : 0);
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
export VERSION=${GITHUB_SHA:-devel}-test
export KO_DOCKER_REPO=ko.local
set -u
source ../lib/lib.sh
build_anubis_ko
function cleanup() {
docker compose down -t 1 || :
}
trap cleanup EXIT SIGINT
docker compose up -d
backoff-retry --try-count 20 node ./test.mjs
-8
View File
@@ -1,8 +0,0 @@
entryPoints:
web:
address: ":80"
providers:
file:
directory: /config
watch: false
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore