Compare commits

..

8 Commits

Author SHA1 Message Date
Xe Iaso 8b72ded756 chore: spelling
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-21 15:30:49 -04:00
Xe Iaso 439860443a doc(default-config): add vague references to metrics server
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-21 15:28:55 -04:00
Xe Iaso 33bc4669cb doc: add metrics server configuration docs
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-21 15:25:19 -04:00
Xe Iaso fa5ceedf23 feat: move metrics server to a dedicated package
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-21 15:16:21 -04:00
Xe Iaso 49dcdbc49e fix(config): add metrics socket mode
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-21 15:07:08 -04:00
Xe Iaso 578f248ce1 fix(main): use internal.SetupListener
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-21 15:00:16 -04:00
Xe Iaso 298fc0b847 feat(internal): move SetupListener from main
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-21 14:57:59 -04:00
Xe Iaso 8af9845117 feat(config): add metrics bind config to policy file with flag hack
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-04-21 14:05:05 -04:00
60 changed files with 495 additions and 2205 deletions
-3
View File
@@ -38,6 +38,3 @@ Samsung
wenet
qwertiko
setuplistener
mba
xfu
xou
-9
View File
@@ -47,7 +47,6 @@ cachediptoasn
Caddyfile
caninetools
Cardyb
CAs
celchecker
celphase
cerr
@@ -120,7 +119,6 @@ fahedouch
fastcgi
FCr
fcrdns
fcvg
fediverse
ffprobe
fhdr
@@ -205,10 +203,8 @@ kagi
kagibot
Keyfunc
keypair
keypairreloader
KHTML
kinda
kpr
KUBECONFIG
lcj
ldflags
@@ -233,13 +229,11 @@ metarefresh
metrix
mimi
Minfilia
minica
mistralai
mnt
Mojeek
mojeekbot
mozilla
mqvh
myclient
mymaster
mypass
@@ -318,9 +312,7 @@ screenshots
searchbot
searx
sebest
seccomp
secretplans
selfsigned
Semrush
Seo
setsebool
@@ -390,7 +382,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)
}
-21
View File
@@ -174,27 +174,6 @@ status_codes:
# metrics:
# bind: ":9090"
# network: "tcp"
#
# # To protect your metrics server with basic auth, set credentials below:
# #
# # https://anubis.techaro.lol/docs/admin/policies#http-basic-authentication
# basicAuth:
# username: ""
# password: ""
#
# # To serve metrics over TLS, set the path to the right TLS certificate and key
# # here. When the files change on disk, they will automatically be reloaded.
# #
# # https://anubis.techaro.lol/docs/admin/policies#tls
# tls:
# certificate: /path/to/tls.crt
# key: /path/to/tls.key
#
# # If you want to secure your metrics endpoint using mutual TLS (mTLS), set
# # the path to a certificate authority public certificate here.
# #
# # https://anubis.techaro.lol/docs/admin/policies#mtls
# ca: /path/to/ca.crt
# Anubis can store temporary data in one of a few backends. See the storage
# backends section of the docs for more information:
+1 -1
View File
@@ -4,5 +4,5 @@
# - Claude-SearchBot: No published IP allowlist
- name: "ai-crawlers-search"
user_agent_regex: >-
OAI-SearchBot|Claude-SearchBot|PerplexityBot|meta-webindexer
OAI-SearchBot|Claude-SearchBot|PerplexityBot
action: DENY
+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"
-17
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,7 @@ 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
-70
View File
@@ -138,75 +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:
```yaml
metrics:
bind: ":9090"
network: "tcp"
tls:
certificate: /path/to/tls.crt
key: /path/to/tls.key
```
The certificate and key will automatically be reloaded when the respective files change.
### mTLS
If you want to validate requests to ensure that they use a client certificate signed by a certificate authority (mutual TLS or mTLS), set the `ca` value in the `tls` block:
```yaml
metrics:
bind: ":9090"
network: "tcp"
tls:
certificate: /path/to/tls.crt
key: /path/to/tls.key
ca: /path/to/ca.crt
```
As it is not expected for certificate authority certificates to change often, the CA certificate will NOT be automatically reloaded when the respective file changes.
### HTTP basic authentication
Anubis' metrics server also supports setting HTTP basic auth as a lightweight protection against unauthorized users viewing metrics data. As the basic auth credentials are hardcoded in the configuration file, administrators SHOULD use randomly generated credentials, such as type-4 UUIDs or other high entropy strings. These credentials MUST NOT be sensitive or used to protect other high value systems.
Configure it with the `basicAuth` block under `metrics`:
```yaml
metrics:
bind: ":9090"
network: "tcp"
basicAuth:
username: azurediamond
password: hunter2
```
If you have Python installed, you can generate a high entropy password with `python -c 'import secrets; print(secrets.token_urlsafe(32))'`.
## Imprint / Impressum support
Anubis has support for showing imprint / impressum information. This is defined in the `impressum` block of your configuration. See [Imprint / Impressum configuration](./configuration/impressum.mdx) for more information.
@@ -429,7 +360,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=
-52
View File
@@ -1,52 +0,0 @@
package internal
import (
"crypto/sha256"
"crypto/subtle"
"fmt"
"log/slog"
"net/http"
)
// BasicAuth wraps next in HTTP Basic authentication using the provided
// credentials. If either username or password is empty, next is returned
// unchanged and a debug log line is emitted.
//
// Credentials are compared in constant time to avoid leaking information
// through timing side channels.
func BasicAuth(realm, username, password string, next http.Handler) http.Handler {
if username == "" || password == "" {
slog.Debug("skipping middleware, basic auth credentials are empty")
return next
}
expectedUser := sha256.Sum256([]byte(username))
expectedPass := sha256.Sum256([]byte(password))
challenge := fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", realm)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok {
unauthorized(w, challenge)
return
}
gotUser := sha256.Sum256([]byte(user))
gotPass := sha256.Sum256([]byte(pass))
userMatch := subtle.ConstantTimeCompare(gotUser[:], expectedUser[:])
passMatch := subtle.ConstantTimeCompare(gotPass[:], expectedPass[:])
if userMatch&passMatch != 1 {
unauthorized(w, challenge)
return
}
next.ServeHTTP(w, r)
})
}
func unauthorized(w http.ResponseWriter, challenge string) {
w.Header().Set("WWW-Authenticate", challenge)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
-138
View File
@@ -1,138 +0,0 @@
package internal
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func okHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
}
func TestBasicAuth(t *testing.T) {
t.Parallel()
const (
realm = "test-realm"
username = "admin"
password = "hunter2"
)
for _, tt := range []struct {
name string
setAuth bool
user string
pass string
wantStatus int
wantBody string
wantChall bool
}{
{
name: "valid credentials",
setAuth: true,
user: username,
pass: password,
wantStatus: http.StatusOK,
wantBody: "ok",
},
{
name: "missing credentials",
setAuth: false,
wantStatus: http.StatusUnauthorized,
wantChall: true,
},
{
name: "wrong username",
setAuth: true,
user: "nobody",
pass: password,
wantStatus: http.StatusUnauthorized,
wantChall: true,
},
{
name: "wrong password",
setAuth: true,
user: username,
pass: "wrong",
wantStatus: http.StatusUnauthorized,
wantChall: true,
},
{
name: "empty supplied credentials",
setAuth: true,
user: "",
pass: "",
wantStatus: http.StatusUnauthorized,
wantChall: true,
},
} {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h := BasicAuth(realm, username, password, okHandler())
req := httptest.NewRequest(http.MethodGet, "/", nil)
if tt.setAuth {
req.SetBasicAuth(tt.user, tt.pass)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
if tt.wantBody != "" && rec.Body.String() != tt.wantBody {
t.Errorf("body = %q, want %q", rec.Body.String(), tt.wantBody)
}
chall := rec.Header().Get("WWW-Authenticate")
if tt.wantChall {
if chall == "" {
t.Error("WWW-Authenticate header missing on 401")
}
if !strings.Contains(chall, realm) {
t.Errorf("WWW-Authenticate = %q, want realm %q", chall, realm)
}
} else if chall != "" {
t.Errorf("unexpected WWW-Authenticate header: %q", chall)
}
})
}
}
func TestBasicAuthPassthrough(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
username string
password string
}{
{name: "empty username", username: "", password: "hunter2"},
{name: "empty password", username: "admin", password: ""},
{name: "both empty", username: "", password: ""},
} {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h := BasicAuth("realm", tt.username, tt.password, okHandler())
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d (passthrough expected)", rec.Code, http.StatusOK)
}
if rec.Body.String() != "ok" {
t.Errorf("body = %q, want %q", rec.Body.String(), "ok")
}
})
}
}
+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 (
+9 -122
View File
@@ -1,39 +1,24 @@
package config
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"os"
"strconv"
)
var (
ErrInvalidMetricsConfig = errors.New("config: invalid metrics configuration")
ErrInvalidMetricsTLSConfig = errors.New("config: invalid metrics TLS configuration")
ErrInvalidMetricsBasicAuthConfig = errors.New("config: invalid metrics basic auth configuration")
ErrNoMetricsBind = errors.New("config.Metrics: must define bind")
ErrNoMetricsNetwork = errors.New("config.Metrics: must define network")
ErrNoMetricsSocketMode = errors.New("config.Metrics: must define socket mode when using unix sockets")
ErrInvalidMetricsSocketMode = errors.New("config.Metrics: invalid unix socket mode")
ErrInvalidMetricsNetwork = errors.New("config.Metrics: invalid metrics network")
ErrNoMetricsTLSCertificate = errors.New("config.Metrics.TLS: must define certificate file")
ErrNoMetricsTLSKey = errors.New("config.Metrics.TLS: must define key file")
ErrInvalidMetricsTLSKeypair = errors.New("config.Metrics.TLS: keypair is invalid")
ErrInvalidMetricsCACertificate = errors.New("config.Metrics.TLS: invalid CA certificate")
ErrCantReadFile = errors.New("config: can't read required file")
ErrNoMetricsBasicAuthUsername = errors.New("config.Metrics.BasicAuth: must define username")
ErrNoMetricsBasicAuthPassword = errors.New("config.Metrics.BasicAuth: must define password")
ErrInvalidMetricsConfig = errors.New("config: invalid metrics configuration")
ErrNoMetricsBind = errors.New("config.Metrics: must define bind")
ErrNoMetricsNetwork = errors.New("config.Metrics: must define network")
ErrNoMetricsSocketMode = errors.New("config.Metrics: must define socket mode when using unix sockets")
ErrInvalidMetricsSocketMode = errors.New("config.Metrics: invalid unix socket mode")
ErrInvalidMetricsNetwork = errors.New("config.Metrics: invalid metrics network")
)
type Metrics struct {
Bind string `json:"bind" yaml:"bind"`
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"`
Bind string `json:"bind" yaml:"bind"`
Network string `json:"network" yaml:"network"`
SocketMode string `json:"socketMode" yaml:"socketMode"`
}
func (m *Metrics) Valid() error {
@@ -61,107 +46,9 @@ func (m *Metrics) Valid() error {
errs = append(errs, ErrInvalidMetricsNetwork)
}
if m.TLS != nil {
if err := m.TLS.Valid(); err != nil {
errs = append(errs, err)
}
}
if m.BasicAuth != nil {
if err := m.BasicAuth.Valid(); err != nil {
errs = append(errs, err)
}
}
if len(errs) != 0 {
return errors.Join(ErrInvalidMetricsConfig, errors.Join(errs...))
}
return nil
}
type MetricsTLS struct {
Certificate string `json:"certificate" yaml:"certificate"`
Key string `json:"key" yaml:"key"`
CA string `json:"ca" yaml:"ca"`
}
func (mt *MetricsTLS) Valid() error {
var errs []error
if mt.Certificate == "" {
errs = append(errs, ErrNoMetricsTLSCertificate)
}
if err := canReadFile(mt.Certificate); err != nil {
errs = append(errs, fmt.Errorf("%w %s: %w", ErrCantReadFile, mt.Certificate, err))
}
if mt.Key == "" {
errs = append(errs, ErrNoMetricsTLSKey)
}
if err := canReadFile(mt.Key); err != nil {
errs = append(errs, fmt.Errorf("%w %s: %w", ErrCantReadFile, mt.Key, err))
}
if _, err := tls.LoadX509KeyPair(mt.Certificate, mt.Key); err != nil {
errs = append(errs, fmt.Errorf("%w: %w", ErrInvalidMetricsTLSKeypair, err))
}
if mt.CA != "" {
caCert, err := os.ReadFile(mt.CA)
if err != nil {
errs = append(errs, fmt.Errorf("%w %s: %w", ErrCantReadFile, mt.CA, err))
}
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(caCert) {
errs = append(errs, fmt.Errorf("%w %s", ErrInvalidMetricsCACertificate, mt.CA))
}
}
if len(errs) != 0 {
return errors.Join(ErrInvalidMetricsTLSConfig, errors.Join(errs...))
}
return nil
}
func canReadFile(fname string) error {
fin, err := os.Open(fname)
if err != nil {
return err
}
defer fin.Close()
data := make([]byte, 64)
if _, err := fin.Read(data); err != nil {
return fmt.Errorf("can't read %s: %w", fname, err)
}
return nil
}
type MetricsBasicAuth struct {
Username string `json:"username" yaml:"username"`
Password string `json:"password" yaml:"password"`
}
func (mba *MetricsBasicAuth) Valid() error {
var errs []error
if mba.Username == "" {
errs = append(errs, ErrNoMetricsBasicAuthUsername)
}
if mba.Password == "" {
errs = append(errs, ErrNoMetricsBasicAuthPassword)
}
if len(errs) != 0 {
return errors.Join(ErrInvalidMetricsBasicAuthConfig, errors.Join(errs...))
}
return nil
}
-155
View File
@@ -75,161 +75,6 @@ func TestMetricsValid(t *testing.T) {
},
err: ErrInvalidMetricsNetwork,
},
{
name: "invalid TLS config",
input: &Metrics{
Bind: ":9090",
Network: "tcp",
TLS: &MetricsTLS{},
},
err: ErrInvalidMetricsTLSConfig,
},
{
name: "selfsigned TLS cert",
input: &Metrics{
Bind: ":9090",
Network: "tcp",
TLS: &MetricsTLS{
Certificate: "./testdata/tls/selfsigned.crt",
Key: "./testdata/tls/selfsigned.key",
},
},
},
{
name: "wrong path to selfsigned TLS cert",
input: &Metrics{
Bind: ":9090",
Network: "tcp",
TLS: &MetricsTLS{
Certificate: "./testdata/tls2/selfsigned.crt",
Key: "./testdata/tls2/selfsigned.key",
},
},
err: ErrCantReadFile,
},
{
name: "unparseable TLS cert",
input: &Metrics{
Bind: ":9090",
Network: "tcp",
TLS: &MetricsTLS{
Certificate: "./testdata/tls/invalid.crt",
Key: "./testdata/tls/invalid.key",
},
},
err: ErrInvalidMetricsTLSKeypair,
},
{
name: "mTLS with CA",
input: &Metrics{
Bind: ":9090",
Network: "tcp",
TLS: &MetricsTLS{
Certificate: "./testdata/tls/selfsigned.crt",
Key: "./testdata/tls/selfsigned.key",
CA: "./testdata/tls/minica.pem",
},
},
},
{
name: "mTLS with nonexistent CA",
input: &Metrics{
Bind: ":9090",
Network: "tcp",
TLS: &MetricsTLS{
Certificate: "./testdata/tls/selfsigned.crt",
Key: "./testdata/tls/selfsigned.key",
CA: "./testdata/tls/nonexistent.crt",
},
},
err: ErrCantReadFile,
},
{
name: "mTLS with invalid CA",
input: &Metrics{
Bind: ":9090",
Network: "tcp",
TLS: &MetricsTLS{
Certificate: "./testdata/tls/selfsigned.crt",
Key: "./testdata/tls/selfsigned.key",
CA: "./testdata/tls/invalid.crt",
},
},
err: ErrInvalidMetricsCACertificate,
},
{
name: "basic auth credentials set",
input: &Metrics{
Bind: ":9090",
Network: "tcp",
BasicAuth: &MetricsBasicAuth{
Username: "admin",
Password: "hunter2",
},
},
},
{
name: "invalid basic auth config",
input: &Metrics{
Bind: ":9090",
Network: "tcp",
BasicAuth: &MetricsBasicAuth{},
},
err: ErrInvalidMetricsBasicAuthConfig,
},
} {
t.Run(tt.name, func(t *testing.T) {
if err := tt.input.Valid(); !errors.Is(err, tt.err) {
t.Logf("wanted error: %v", tt.err)
t.Logf("got error: %v", err)
t.Error("validation failed")
}
})
}
}
func TestMetricsBasicAuthValid(t *testing.T) {
for _, tt := range []struct {
name string
input *MetricsBasicAuth
err error
}{
{
name: "both set",
input: &MetricsBasicAuth{
Username: "admin",
Password: "hunter2",
},
},
{
name: "empty username and password",
input: &MetricsBasicAuth{},
err: ErrInvalidMetricsBasicAuthConfig,
},
{
name: "missing username",
input: &MetricsBasicAuth{
Password: "hunter2",
},
err: ErrNoMetricsBasicAuthUsername,
},
{
name: "missing password",
input: &MetricsBasicAuth{
Username: "admin",
},
err: ErrNoMetricsBasicAuthPassword,
},
{
name: "missing both surfaces wrapper error",
input: &MetricsBasicAuth{},
err: ErrNoMetricsBasicAuthUsername,
},
{
name: "missing both surfaces password error",
input: &MetricsBasicAuth{},
err: ErrNoMetricsBasicAuthPassword,
},
} {
t.Run(tt.name, func(t *testing.T) {
if err := tt.input.Valid(); !errors.Is(err, tt.err) {
-12
View File
@@ -1,12 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIB1zCCAVygAwIBAgIIYO0SAFtXlVgwCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV
bWluaWNhIHJvb3QgY2EgNDE2MmMwMB4XDTI2MDQyMjIzMjUwMVoXDTI4MDUyMjIz
MjUwMVowEjEQMA4GA1UEAxMHMS4xLjEuMTB2MBAGByqGSM49AgEGBSuBBAAiA2IA
BLsuA2LKGbEBuSA4LTm1KaKc7/QCkUOsipXR4+D5/3sWBZiAH7iWUgHwpx5YZf2q
kZn6oRda+ks0JLTQ6VhteQedmb7l86bMeDMR8p4Lg2b38l/xEr7S25UfUDKudXrO
AqNxMG8wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEF
BQcDAjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFE/7VDxF2+cUs9bu0pJM3xoC
L1TSMA8GA1UdEQQIMAaHBAEBAQEwCgYIKoZIzj0EAwMDaQAwZgIxAPLXds9MMH4K
F5FxTf9i0PKPsLQARsABVTgwB94hMR70rqW8Pwbjl7ZGNaYlaeRHUwIxAPMQ8zoF
nim+YS1xLqQek/LXuJto8jxcfkQQBsboVzcTa5uaNRhNd5YwrpomGl3lKA==
-----END CERTIFICATE-----
-6
View File
@@ -1,6 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBN8QsHxxHGJpStu8K7
D/FmaBBNo6c514KGFSIfqGFuREF5aOL3gN/W11yk2OIibdWhZANiAAS7LgNiyhmx
AbkgOC05tSminO/0ApFDrIqV0ePg+f97FgWYgB+4llIB8KceWGX9qpGZ+qEXWvpL
NCS00OlYbXkHnZm+5fOmzHgzEfKeC4Nm9/Jf8RK+0tuVH1AyrnV6zgI=
-----END PRIVATE KEY-----
View File
View File
-6
View File
@@ -1,6 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDr9QQo7ZaTgUL6d73G
2BG7+YRTFJHAZa0FogRglfc+jYttL1J4/xTig3RmHoqSgrehZANiAASDhijM9Xe0
G9Vam6AJMeKC6aWDNSLwrxNVmPxemsY/yJ1urBgnxRd9GFH6YW1ki/B8rS+Xl1UX
NnhBrukLaXvgAQQq782/5IUYGsvK5jw8+dSscYVMCQJwGfmQuaNeczQ=
-----END PRIVATE KEY-----
-13
View File
@@ -1,13 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIB+zCCAYKgAwIBAgIIQWLAtv4ijQ0wCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV
bWluaWNhIHJvb3QgY2EgNDE2MmMwMCAXDTI2MDQyMjIzMjUwMVoYDzIxMjYwNDIy
MjMyNTAxWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSA0MTYyYzAwdjAQBgcq
hkjOPQIBBgUrgQQAIgNiAASDhijM9Xe0G9Vam6AJMeKC6aWDNSLwrxNVmPxemsY/
yJ1urBgnxRd9GFH6YW1ki/B8rS+Xl1UXNnhBrukLaXvgAQQq782/5IUYGsvK5jw8
+dSscYVMCQJwGfmQuaNeczSjgYYwgYMwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQW
MBQGCCsGAQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1Ud
DgQWBBRP+1Q8RdvnFLPW7tKSTN8aAi9U0jAfBgNVHSMEGDAWgBRP+1Q8RdvnFLPW
7tKSTN8aAi9U0jAKBggqhkjOPQQDAwNnADBkAjBfY7vb7cuLTjg7uoe+kl07FMYT
BGMSnWdhN3yXqMUS3A6XZxD/LntXT6V7yFOlAJYCMH7w8/ATYaTqbk2jBRyQt9/x
ajN+kZ6ZK+fKttqE8CD62mbHg09xoNxRq+K2I3PVyQ==
-----END CERTIFICATE-----
-11
View File
@@ -1,11 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBnzCCAVGgAwIBAgIUK39B3Ft+kU5o81IuISs79O4u1ncwBQYDK2VwMEUxCzAJ
BgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5l
dCBXaWRnaXRzIFB0eSBMdGQwHhcNMjYwNDIyMTQyNjE4WhcNMjYwNTIyMTQyNjE4
WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwY
SW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMCowBQYDK2VwAyEAfgpAUpp8MIOOdQpH
fxaw3R7mFKQRMR6Kmxzk1Xn/2VujUzBRMB0GA1UdDgQWBBSmkBmzo0RiZ2iocMR8
uIIpz9cZyTAfBgNVHSMEGDAWgBSmkBmzo0RiZ2iocMR8uIIpz9cZyTAPBgNVHRMB
Af8EBTADAQH/MAUGAytlcANBAG37XXZrVUUzGyy3T9qsPIzvJQAGpGhdjJ7bt9O6
sBhzrliTONPrudYuyUggWsHgFb0JlN2xs4/2HhKU+PY7AAQ=
-----END CERTIFICATE-----
-3
View File
@@ -1,3 +0,0 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIL0HxjjfVlg6zQPB9/zTLq0IBzfp8gEoifEYzQZYIj+T
-----END PRIVATE KEY-----
+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)
}
}
-81
View File
@@ -1,81 +0,0 @@
package metrics
import (
"crypto/tls"
"fmt"
"log/slog"
"os"
"sync"
"time"
)
type KeypairReloader struct {
certMu sync.RWMutex
cert *tls.Certificate
certPath string
keyPath string
modTime time.Time
lg *slog.Logger
}
func NewKeypairReloader(certPath, keyPath string, lg *slog.Logger) (*KeypairReloader, error) {
result := &KeypairReloader{
certPath: certPath,
keyPath: keyPath,
lg: lg,
}
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
result.cert = &cert
st, err := os.Stat(certPath)
if err != nil {
return nil, err
}
result.modTime = st.ModTime()
return result, nil
}
func (kpr *KeypairReloader) maybeReload() error {
kpr.lg.Debug("loading new keypair", "cert", kpr.certPath, "key", kpr.keyPath)
newCert, err := tls.LoadX509KeyPair(kpr.certPath, kpr.keyPath)
if err != nil {
return err
}
st, err := os.Stat(kpr.certPath)
if err != nil {
return err
}
kpr.certMu.Lock()
defer kpr.certMu.Unlock()
kpr.cert = &newCert
kpr.modTime = st.ModTime()
return nil
}
func (kpr *KeypairReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
st, err := os.Stat(kpr.certPath)
if err != nil {
return nil, fmt.Errorf("stat(%q): %w", kpr.certPath, err)
}
kpr.certMu.RLock()
needsReload := st.ModTime().After(kpr.modTime)
kpr.certMu.RUnlock()
if needsReload {
if err := kpr.maybeReload(); err != nil {
return nil, fmt.Errorf("reload cert: %w", err)
}
}
kpr.certMu.RLock()
defer kpr.certMu.RUnlock()
return kpr.cert, nil
}
-265
View File
@@ -1,265 +0,0 @@
package metrics
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"log/slog"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)
func discardLogger() *slog.Logger {
return slog.New(slog.DiscardHandler)
}
// writeKeypair generates a fresh self-signed cert + RSA key and writes them
// as PEM files in dir. Returns the paths and the cert's DER bytes so callers
// can identify which pair was loaded.
func writeKeypair(t *testing.T, dir, prefix string) (certPath, keyPath string, certDER []byte) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: "keypairreloader-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"keypairreloader-test"},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
certPath = filepath.Join(dir, prefix+"cert.pem")
keyPath = filepath.Join(dir, prefix+"key.pem")
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
return certPath, keyPath, der
}
func TestNewKeypairReloader(t *testing.T) {
dir := t.TempDir()
goodCert, goodKey, _ := writeKeypair(t, dir, "good-")
garbagePath := filepath.Join(dir, "garbage.pem")
if err := os.WriteFile(garbagePath, []byte("not a pem file"), 0o600); err != nil {
t.Fatalf("write garbage: %v", err)
}
tests := []struct {
name string
certPath string
keyPath string
wantErr error
wantNil bool
}{
{
name: "valid cert and key",
certPath: goodCert,
keyPath: goodKey,
},
{
name: "missing cert file",
certPath: filepath.Join(dir, "does-not-exist.pem"),
keyPath: goodKey,
wantErr: os.ErrNotExist,
wantNil: true,
},
{
name: "missing key file",
certPath: goodCert,
keyPath: filepath.Join(dir, "does-not-exist-key.pem"),
wantErr: os.ErrNotExist,
wantNil: true,
},
{
name: "cert file is garbage",
certPath: garbagePath,
keyPath: goodKey,
wantNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
kpr, err := NewKeypairReloader(tt.certPath, tt.keyPath, discardLogger())
if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
t.Errorf("err = %v, want errors.Is(..., %v)", err, tt.wantErr)
}
if tt.wantErr == nil && !tt.wantNil && err != nil {
t.Errorf("unexpected err: %v", err)
}
if tt.wantNil && kpr != nil {
t.Errorf("kpr = %+v, want nil", kpr)
}
if !tt.wantNil && kpr == nil {
t.Errorf("kpr is nil, want non-nil")
}
})
}
}
func TestKeypairReloader_GetCertificate(t *testing.T) {
tests := []struct {
name string
run func(t *testing.T)
}{
{
name: "returns loaded cert",
run: func(t *testing.T) {
dir := t.TempDir()
certPath, keyPath, wantDER := writeKeypair(t, dir, "a-")
kpr, err := NewKeypairReloader(certPath, keyPath, discardLogger())
if err != nil {
t.Fatalf("NewKeypairReloader: %v", err)
}
got, err := kpr.GetCertificate(nil)
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
if len(got.Certificate) == 0 || !bytes.Equal(got.Certificate[0], wantDER) {
t.Errorf("GetCertificate returned wrong cert bytes")
}
},
},
{
name: "reloads when mtime advances",
run: func(t *testing.T) {
dir := t.TempDir()
certPath, keyPath, _ := writeKeypair(t, dir, "a-")
kpr, err := NewKeypairReloader(certPath, keyPath, discardLogger())
if err != nil {
t.Fatalf("NewKeypairReloader: %v", err)
}
// Overwrite with a new pair at the same paths and bump mtime.
newCertPath, newKeyPath, newDER := writeKeypair(t, dir, "b-")
mustRename(t, newCertPath, certPath)
mustRename(t, newKeyPath, keyPath)
future := time.Now().Add(time.Hour)
if err := os.Chtimes(certPath, future, future); err != nil {
t.Fatalf("Chtimes: %v", err)
}
got, err := kpr.GetCertificate(nil)
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
if len(got.Certificate) == 0 || !bytes.Equal(got.Certificate[0], newDER) {
t.Errorf("GetCertificate did not return reloaded cert")
}
},
},
{
name: "does not reload when mtime unchanged",
run: func(t *testing.T) {
dir := t.TempDir()
certPath, keyPath, originalDER := writeKeypair(t, dir, "a-")
kpr, err := NewKeypairReloader(certPath, keyPath, discardLogger())
if err != nil {
t.Fatalf("NewKeypairReloader: %v", err)
}
// Overwrite the cert/key files with a *different* keypair, then
// rewind mtime so the reloader must not pick up the change.
newCertPath, newKeyPath, newDER := writeKeypair(t, dir, "b-")
mustRename(t, newCertPath, certPath)
mustRename(t, newKeyPath, keyPath)
past := time.Unix(0, 0)
if err := os.Chtimes(certPath, past, past); err != nil {
t.Fatalf("Chtimes: %v", err)
}
got, err := kpr.GetCertificate(nil)
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
if len(got.Certificate) == 0 {
t.Fatal("empty cert chain")
}
if bytes.Equal(got.Certificate[0], newDER) {
t.Errorf("GetCertificate reloaded despite unchanged mtime")
}
if !bytes.Equal(got.Certificate[0], originalDER) {
t.Errorf("GetCertificate did not return original cert")
}
},
},
{
name: "does not panic when reload fails after mtime bump",
run: func(t *testing.T) {
dir := t.TempDir()
certPath, keyPath, _ := writeKeypair(t, dir, "a-")
kpr, err := NewKeypairReloader(certPath, keyPath, discardLogger())
if err != nil {
t.Fatalf("NewKeypairReloader: %v", err)
}
// Corrupt the cert file and bump mtime. maybeReload will fail.
if err := os.WriteFile(certPath, []byte("not a pem file"), 0o600); err != nil {
t.Fatalf("corrupt cert: %v", err)
}
future := time.Now().Add(time.Hour)
if err := os.Chtimes(certPath, future, future); err != nil {
t.Fatalf("Chtimes: %v", err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("GetCertificate panicked on reload failure: %v", r)
}
}()
got, err := kpr.GetCertificate(nil)
if err == nil {
t.Errorf("GetCertificate returned nil err for corrupt cert; got %+v", got)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.run(t)
})
}
}
func mustRename(t *testing.T, from, to string) {
t.Helper()
if err := os.Rename(from, to); err != nil {
t.Fatalf("rename %q -> %q: %v", from, to, err)
}
}
+9 -62
View File
@@ -2,14 +2,11 @@ package metrics
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/pprof"
"os"
"time"
"github.com/TecharoHQ/anubis/internal"
@@ -23,26 +20,16 @@ type Server struct {
Log *slog.Logger
}
func (s *Server) Run(ctx context.Context, done func()) {
func (s *Server) Run(ctx context.Context, done func()) error {
defer done()
lg := s.Log.With("subsystem", "metrics")
if err := s.run(ctx, lg); err != nil {
lg.Error("can't serve metrics server", "err", err)
}
}
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,46 +55,13 @@ 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)
}
defer ln.Close()
if s.Config.TLS != nil {
kpr, err := NewKeypairReloader(s.Config.TLS.Certificate, s.Config.TLS.Key, lg)
if err != nil {
return fmt.Errorf("can't setup keypair reloader: %w", err)
}
srv.TLSConfig = &tls.Config{
GetCertificate: kpr.GetCertificate,
}
if s.Config.TLS.CA != "" {
caCert, err := os.ReadFile(s.Config.TLS.CA)
if err != nil {
return fmt.Errorf("%w %s: %w", config.ErrCantReadFile, s.Config.TLS.CA, err)
}
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(caCert) {
return fmt.Errorf("%w %s", config.ErrInvalidMetricsCACertificate, s.Config.TLS.CA)
}
srv.TLSConfig.ClientCAs = certPool
srv.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert
}
}
if s.Config.BasicAuth != nil {
var h http.Handler = mux
h = internal.BasicAuth("anubis-metrics", s.Config.BasicAuth.Username, s.Config.BasicAuth.Password, mux)
srv.Handler = h
}
lg.Debug("listening for metrics", "url", metricsURL)
go func() {
@@ -119,15 +73,8 @@ func (s *Server) run(ctx context.Context, lg *slog.Logger) error {
}
}()
switch s.Config.TLS != nil {
case true:
if err := srv.ServeTLS(ln, "", ""); !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("can't serve TLS metrics server: %w", err)
}
case false:
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("can't serve metrics server: %w", err)
}
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("can't serve metrics server: %w", err)
}
return nil
-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
-1
View File
@@ -54,7 +54,6 @@ User-agent: meta-externalagent
User-agent: Meta-ExternalAgent
User-agent: meta-externalfetcher
User-agent: Meta-ExternalFetcher
User-agent: meta-webindexer
User-agent: MistralAI-User
User-agent: MistralAI-User/1.0
User-agent: MyCentralAIScraperBot