mirror of
https://github.com/TecharoHQ/anubis.git
synced 2026-05-21 21:47:48 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 652cef7ffe | |||
| 97d15cd803 | |||
| 120a730a66 | |||
| 386e92eb97 | |||
| e3f500cb56 | |||
| 75aa251406 | |||
| 324c2f4fed | |||
| b57508afcd | |||
| 276b537776 | |||
| 9f479f578a | |||
| c184028d42 | |||
| 0491f1fac2 | |||
| d3a00da448 | |||
| 7e037b65e8 | |||
| ebf9a30878 | |||
| f8605bcd3c | |||
| 1d700a0370 | |||
| 681c2cc2ed | |||
| 8f8ae76d56 | |||
| f21706eb12 | |||
| d5ccf9c670 |
@@ -38,3 +38,6 @@ Samsung
|
||||
wenet
|
||||
qwertiko
|
||||
setuplistener
|
||||
mba
|
||||
xfu
|
||||
xou
|
||||
|
||||
@@ -47,6 +47,7 @@ cachediptoasn
|
||||
Caddyfile
|
||||
caninetools
|
||||
Cardyb
|
||||
CAs
|
||||
celchecker
|
||||
celphase
|
||||
cerr
|
||||
@@ -119,6 +120,7 @@ fahedouch
|
||||
fastcgi
|
||||
FCr
|
||||
fcrdns
|
||||
fcvg
|
||||
fediverse
|
||||
ffprobe
|
||||
fhdr
|
||||
@@ -203,8 +205,10 @@ kagi
|
||||
kagibot
|
||||
Keyfunc
|
||||
keypair
|
||||
keypairreloader
|
||||
KHTML
|
||||
kinda
|
||||
kpr
|
||||
KUBECONFIG
|
||||
lcj
|
||||
ldflags
|
||||
@@ -229,11 +233,13 @@ metarefresh
|
||||
metrix
|
||||
mimi
|
||||
Minfilia
|
||||
minica
|
||||
mistralai
|
||||
mnt
|
||||
Mojeek
|
||||
mojeekbot
|
||||
mozilla
|
||||
mqvh
|
||||
myclient
|
||||
mymaster
|
||||
mypass
|
||||
@@ -312,7 +318,9 @@ screenshots
|
||||
searchbot
|
||||
searx
|
||||
sebest
|
||||
seccomp
|
||||
secretplans
|
||||
selfsigned
|
||||
Semrush
|
||||
Seo
|
||||
setsebool
|
||||
@@ -382,6 +390,7 @@ vnd
|
||||
VPS
|
||||
Vultr
|
||||
WAIFU
|
||||
wcg
|
||||
weblate
|
||||
webmaster
|
||||
webpage
|
||||
|
||||
@@ -52,6 +52,15 @@ 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:
|
||||
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
- palemoon/amd64
|
||||
#- palemoon/i386
|
||||
- robots_txt
|
||||
- traefik
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
+1
-1
@@ -259,7 +259,7 @@ func main() {
|
||||
}
|
||||
|
||||
lg.Info("loading policy file", "fname", *policyFname)
|
||||
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty, *slogLevel)
|
||||
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty, *slogLevel, strings.TrimSpace(*target) == "")
|
||||
if err != nil {
|
||||
log.Fatalf("can't parse policy file: %v", err)
|
||||
}
|
||||
|
||||
@@ -174,6 +174,27 @@ 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:
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# - Claude-SearchBot: No published IP allowlist
|
||||
- name: "ai-crawlers-search"
|
||||
user_agent_regex: >-
|
||||
OAI-SearchBot|Claude-SearchBot|PerplexityBot
|
||||
OAI-SearchBot|Claude-SearchBot|PerplexityBot|meta-webindexer
|
||||
action: DENY
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
FROM docker.io/library/node:lts AS build
|
||||
FROM docker.io/library/node:22.22.3-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN npm ci && npm run build
|
||||
|
||||
FROM ghcr.io/xe/nginx-micro
|
||||
FROM ghcr.io/xe/nginx-micro:v1.29.0
|
||||
COPY --from=build /app/build /www
|
||||
COPY ./manifest/cfg/nginx/nginx.conf /conf
|
||||
LABEL org.opencontainers.image.source="https://github.com/TecharoHQ/anubis"
|
||||
@@ -13,6 +13,8 @@ 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)
|
||||
@@ -20,7 +22,22 @@ 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
|
||||
|
||||
|
||||
@@ -138,6 +138,75 @@ 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.
|
||||
@@ -360,6 +429,7 @@ 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:
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
user nginx;
|
||||
worker_processes 2;
|
||||
error_log /dev/stdout warn;
|
||||
pid /nginx.pid;
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
@@ -12,11 +12,17 @@ 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 80 default_server;
|
||||
listen 8080 default_server;
|
||||
server_name _;
|
||||
|
||||
error_page 404 /404.html;
|
||||
|
||||
@@ -20,10 +20,12 @@ spec:
|
||||
name: nginx-cfg
|
||||
- name: temporary-data
|
||||
emptyDir: {}
|
||||
- name: nginx-tmp
|
||||
emptyDir: {}
|
||||
containers:
|
||||
- name: anubis-docs
|
||||
image: ghcr.io/techarohq/anubis/docs:main
|
||||
imagePullPolicy: Always
|
||||
image: ghcr.io/techarohq/anubis/docs@sha256:f13a7c862bbcba8e19feba9f157120c6f03e23b03367ace4ca55da69dc894e12
|
||||
imagePullPolicy: IfNotPresent
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
@@ -34,23 +36,36 @@ spec:
|
||||
volumeMounts:
|
||||
- name: nginx
|
||||
mountPath: /conf
|
||||
- name: nginx-tmp
|
||||
mountPath: /tmp
|
||||
ports:
|
||||
- containerPort: 80
|
||||
- containerPort: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
port: 8080
|
||||
initialDelaySeconds: 1
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
port: 8080
|
||||
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:main
|
||||
imagePullPolicy: Always
|
||||
image: ghcr.io/techarohq/anubis@sha256:533e57956ae3afd1612dab16f02dd4db937ca14fad5399208f403686e14feed5
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: "BIND"
|
||||
value: ":8081"
|
||||
@@ -65,7 +80,7 @@ spec:
|
||||
- name: "SERVE_ROBOTS_TXT"
|
||||
value: "false"
|
||||
- name: "TARGET"
|
||||
value: "http://localhost:80"
|
||||
value: "http://localhost:8080"
|
||||
# - name: "SLOG_LEVEL"
|
||||
# value: "debug"
|
||||
volumeMounts:
|
||||
|
||||
@@ -7,7 +7,7 @@ spec:
|
||||
app: anubis-docs
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
targetPort: 8080
|
||||
name: http
|
||||
- port: 8081
|
||||
targetPort: 8081
|
||||
|
||||
@@ -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.4 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.5 // 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
|
||||
|
||||
@@ -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.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-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-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=
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -76,13 +77,6 @@ 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++
|
||||
@@ -90,20 +84,19 @@ 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) {
|
||||
result, _ := i.uaWeight.Get(r.Context(), internal.SHA256sum(r.UserAgent()))
|
||||
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()))
|
||||
if result >= 25 {
|
||||
return true, nil
|
||||
}
|
||||
@@ -164,7 +157,6 @@ 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")
|
||||
|
||||
@@ -172,11 +164,14 @@ 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, uaCount%256 == 0:
|
||||
lg.Warn("found possible crawler", "id", id, "network", network)
|
||||
case networkCount%256 == 0:
|
||||
lg.Warn("found possible crawler", "id", id, "network", network, "userAgent", r.UserAgent())
|
||||
}
|
||||
}
|
||||
|
||||
millisecondAmount := min(math.Pow(float64(networkCount), 2), 1000)
|
||||
time.Sleep(time.Duration(millisecondAmount) * time.Millisecond)
|
||||
|
||||
spins := i.makeSpins()
|
||||
affirmations := i.makeAffirmations()
|
||||
title := i.makeTitle()
|
||||
|
||||
@@ -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")
|
||||
policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), "", anubis.DefaultDifficulty, "info", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+72
-15
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -32,6 +33,7 @@ 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"
|
||||
@@ -39,31 +41,52 @@ 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"})
|
||||
}, []string{"method", "asn", "asn_description"})
|
||||
|
||||
challengesValidated = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_challenges_validated",
|
||||
Help: "The total number of challenges validated",
|
||||
}, []string{"method"})
|
||||
}, []string{"method", "asn", "asn_description"})
|
||||
|
||||
droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_dronebl_hits",
|
||||
Help: "The total number of hits from DroneBL",
|
||||
}, []string{"status"})
|
||||
}, []string{"status", "asn", "asn_description"})
|
||||
|
||||
failedValidations = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_failed_validations",
|
||||
Help: "The total number of failed validations",
|
||||
}, []string{"method"})
|
||||
}, []string{"method", "asn", "asn_description"})
|
||||
|
||||
requestsProxied = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_proxied_requests_total",
|
||||
Help: "Number of requests proxied through Anubis to upstream targets",
|
||||
}, []string{"host"})
|
||||
}, []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"})
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -78,6 +101,28 @@ 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 {
|
||||
@@ -141,7 +186,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())
|
||||
lg.Info("new challenge issued", "challenge", id.String(), "weight", cr.Weight)
|
||||
|
||||
return &chall, err
|
||||
}
|
||||
@@ -193,7 +238,7 @@ func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, r := s.getRequestLogger(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")
|
||||
@@ -218,7 +263,10 @@ 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)
|
||||
policy.Applications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1)
|
||||
{
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
policy.Applications.WithLabelValues(cr.Name, string(cr.Rule), asn, asnDesc).Add(1)
|
||||
}
|
||||
|
||||
ip := r.Header.Get("X-Real-Ip")
|
||||
|
||||
@@ -348,7 +396,8 @@ 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)
|
||||
droneBLHits.WithLabelValues(resp.String()).Inc()
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
droneBLHits.WithLabelValues(resp.String(), asn, asnDesc).Inc()
|
||||
}
|
||||
|
||||
if resp != dnsbl.AllGood {
|
||||
@@ -366,7 +415,7 @@ func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string,
|
||||
}
|
||||
|
||||
func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, r := s.getRequestLogger(r)
|
||||
localizer := localization.GetLocalizer(r)
|
||||
|
||||
redir := r.FormValue("redir")
|
||||
@@ -435,11 +484,14 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
lg.Debug("made challenge", "challenge", chall, "rules", rule.Challenge, "cr", cr)
|
||||
challengesIssued.WithLabelValues("api").Inc()
|
||||
{
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
challengesIssued.WithLabelValues("api", asn, asnDesc).Inc()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, r := s.getRequestLogger(r)
|
||||
localizer := localization.GetLocalizer(r)
|
||||
|
||||
redir := r.FormValue("redir")
|
||||
@@ -530,7 +582,8 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := impl.Validate(r, lg, in); err != nil {
|
||||
failedValidations.WithLabelValues(rule.Challenge.Algorithm).Inc()
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
failedValidations.WithLabelValues(rule.Challenge.Algorithm, asn, asnDesc).Inc()
|
||||
var cerr *challenge.Error
|
||||
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
||||
lg.Debug("challenge validate call failed", "err", err)
|
||||
@@ -590,7 +643,10 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
lg.Debug("can't update information about challenge", "err", err)
|
||||
}
|
||||
|
||||
challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc()
|
||||
{
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
challengesValidated.WithLabelValues(rule.Challenge.Algorithm, asn, asnDesc).Inc()
|
||||
}
|
||||
lg.Debug("challenge passed, redirecting to app")
|
||||
http.Redirect(w, r, redir, http.StatusFound)
|
||||
}
|
||||
@@ -629,7 +685,8 @@ 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)
|
||||
policy.Applications.WithLabelValues("bot/"+b.Name, "WEIGH").Add(1)
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
policy.Applications.WithLabelValues("bot/"+b.Name, "WEIGH", asn, asnDesc).Add(1)
|
||||
weight += b.Weight.Adjust
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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")
|
||||
anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty, "info", false)
|
||||
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"); err != nil {
|
||||
if _, err := policy.ParseConfig(t.Context(), fin, fname, 4, "info", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
+2
-10
@@ -55,7 +55,7 @@ type Options struct {
|
||||
DifficultyInJWT bool
|
||||
}
|
||||
|
||||
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string) (*policy.ParsedConfig, error) {
|
||||
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string, subrequestMode bool) (*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)
|
||||
anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty, logLevel, subrequestMode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err)
|
||||
}
|
||||
@@ -190,14 +190,6 @@ 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)
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 (
|
||||
|
||||
+122
-9
@@ -1,24 +1,39 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var (
|
||||
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")
|
||||
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")
|
||||
)
|
||||
|
||||
type Metrics struct {
|
||||
Bind string `json:"bind" yaml:"bind"`
|
||||
Network string `json:"network" yaml:"network"`
|
||||
SocketMode string `json:"socketMode" yaml:"socketMode"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (m *Metrics) Valid() error {
|
||||
@@ -46,9 +61,107 @@ 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
|
||||
}
|
||||
|
||||
@@ -75,6 +75,161 @@ 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
@@ -0,0 +1,12 @@
|
||||
-----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
@@ -0,0 +1,6 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBN8QsHxxHGJpStu8K7
|
||||
D/FmaBBNo6c514KGFSIfqGFuREF5aOL3gN/W11yk2OIibdWhZANiAAS7LgNiyhmx
|
||||
AbkgOC05tSminO/0ApFDrIqV0ePg+f97FgWYgB+4llIB8KceWGX9qpGZ+qEXWvpL
|
||||
NCS00OlYbXkHnZm+5fOmzHgzEfKeC4Nm9/Jf8RK+0tuVH1AyrnV6zgI=
|
||||
-----END PRIVATE KEY-----
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDr9QQo7ZaTgUL6d73G
|
||||
2BG7+YRTFJHAZa0FogRglfc+jYttL1J4/xTig3RmHoqSgrehZANiAASDhijM9Xe0
|
||||
G9Vam6AJMeKC6aWDNSLwrxNVmPxemsY/yJ1urBgnxRd9GFH6YW1ki/B8rS+Xl1UX
|
||||
NnhBrukLaXvgAQQq782/5IUYGsvK5jw8+dSscYVMCQJwGfmQuaNeczQ=
|
||||
-----END PRIVATE KEY-----
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
-----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
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBnzCCAVGgAwIBAgIUK39B3Ft+kU5o81IuISs79O4u1ncwBQYDK2VwMEUxCzAJ
|
||||
BgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5l
|
||||
dCBXaWRnaXRzIFB0eSBMdGQwHhcNMjYwNDIyMTQyNjE4WhcNMjYwNTIyMTQyNjE4
|
||||
WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwY
|
||||
SW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMCowBQYDK2VwAyEAfgpAUpp8MIOOdQpH
|
||||
fxaw3R7mFKQRMR6Kmxzk1Xn/2VujUzBRMB0GA1UdDgQWBBSmkBmzo0RiZ2iocMR8
|
||||
uIIpz9cZyTAfBgNVHSMEGDAWgBSmkBmzo0RiZ2iocMR8uIIpz9cZyTAPBgNVHRMB
|
||||
Af8EBTADAQH/MAUGAytlcANBAG37XXZrVUUzGyy3T9qsPIzvJQAGpGhdjJ7bt9O6
|
||||
sBhzrliTONPrudYuyUggWsHgFb0JlN2xs4/2HhKU+PY7AAQ=
|
||||
-----END CERTIFICATE-----
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIL0HxjjfVlg6zQPB9/zTLq0IBzfp8gEoifEYzQZYIj+T
|
||||
-----END PRIVATE KEY-----
|
||||
+4
-4
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestInvalidChallengeMethod(t *testing.T) {
|
||||
if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4, "info"); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) {
|
||||
if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4, "info", false); !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"); err == nil {
|
||||
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "bad", st.Name()), anubis.DefaultDifficulty, "info", false); 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"); err != nil {
|
||||
if _, err := LoadPoliciesOrDefault(ctx, filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info", false); 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"); err != nil {
|
||||
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
+19
-14
@@ -207,7 +207,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
|
||||
return
|
||||
}
|
||||
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, r := s.getRequestLogger(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,7 +215,10 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
|
||||
return
|
||||
}
|
||||
|
||||
challengesIssued.WithLabelValues("embedded").Add(1)
|
||||
{
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
challengesIssued.WithLabelValues("embedded", asn, asnDesc).Add(1)
|
||||
}
|
||||
chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
|
||||
if err != nil {
|
||||
lg.Error("can't get challenge", "err", err)
|
||||
@@ -306,14 +309,14 @@ func (s *Server) constructRedirectURL(r *http.Request) (string, error) {
|
||||
case "http", "https":
|
||||
// allowed
|
||||
default:
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, _ := s.getRequestLogger(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 := internal.GetRequestLogger(s.logger, r)
|
||||
lg, _ := s.getRequestLogger(r)
|
||||
lg.Debug("domain not allowed", "domain", host)
|
||||
return "", errors.New(localizer.T("redirect_domain_not_allowed"))
|
||||
}
|
||||
@@ -400,14 +403,15 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
|
||||
localizer := localization.GetLocalizer(r)
|
||||
|
||||
redir := r.FormValue("redir")
|
||||
urlParsed, err := url.ParseRequestURI(redir)
|
||||
urlParsed, err := url.Parse(redir)
|
||||
if err != nil {
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// validate URL scheme to prevent javascript:, data:, file:, tel:, etc.
|
||||
@@ -415,7 +419,7 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
|
||||
case "", "http", "https":
|
||||
// allowed: empty scheme means relative URL
|
||||
default:
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, _ := s.getRequestLogger(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
|
||||
@@ -427,7 +431,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 := internal.GetRequestLogger(s.logger, r)
|
||||
lg, _ := s.getRequestLogger(r)
|
||||
lg.Debug("domain not allowed", "domain", urlParsed.Host)
|
||||
s.respondWithStatus(w, r, localizer.T("redirect_domain_not_allowed"), makeCode(err), http.StatusBadRequest)
|
||||
return
|
||||
@@ -442,7 +446,8 @@ 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 {
|
||||
requestsProxied.WithLabelValues(r.Host).Inc()
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
requestsProxied.WithLabelValues(r.Host, asn, asnDesc).Inc()
|
||||
r = s.stripBasePrefixFromRequest(r)
|
||||
s.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -223,3 +223,17 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+62
-9
@@ -2,11 +2,14 @@ package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis/internal"
|
||||
@@ -20,16 +23,26 @@ type Server struct {
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
func (s *Server) Run(ctx context.Context, done func()) error {
|
||||
func (s *Server) Run(ctx context.Context, done func()) {
|
||||
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()
|
||||
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)
|
||||
|
||||
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.Handle("/metrics", promhttp.Handler())
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := internal.GetHealth("anubis")
|
||||
@@ -55,13 +68,46 @@ func (s *Server) Run(ctx context.Context, done func()) error {
|
||||
ErrorLog: internal.GetFilteredHTTPLogger(),
|
||||
}
|
||||
|
||||
ln, metricsURL, err := internal.SetupListener(s.Config.Bind, s.Config.Network, s.Config.SocketMode)
|
||||
ln, metricsURL, err := internal.SetupListener(s.Config.Network, s.Config.Bind, 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() {
|
||||
@@ -73,8 +119,15 @@ func (s *Server) Run(ctx context.Context, done func()) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("can't serve metrics server: %w", err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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
|
||||
}
|
||||
@@ -13,11 +13,12 @@ import (
|
||||
)
|
||||
|
||||
type CELChecker struct {
|
||||
program cel.Program
|
||||
src string
|
||||
program cel.Program
|
||||
src string
|
||||
subRequestMode bool
|
||||
}
|
||||
|
||||
func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns) (*CELChecker, error) {
|
||||
func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns, subRequestMode bool) (*CELChecker, error) {
|
||||
env, err := expressions.BotEnvironment(dnsObj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -29,8 +30,9 @@ func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns) (*CELChecker,
|
||||
}
|
||||
|
||||
return &CELChecker{
|
||||
src: cfg.String(),
|
||||
program: program,
|
||||
src: cfg.String(),
|
||||
program: program,
|
||||
subRequestMode: subRequestMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -39,7 +41,7 @@ func (cc *CELChecker) Hash() string {
|
||||
}
|
||||
|
||||
func (cc *CELChecker) Check(r *http.Request) (bool, error) {
|
||||
result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r})
|
||||
result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r, cc.subRequestMode})
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -54,6 +56,7 @@ func (cc *CELChecker) Check(r *http.Request) (bool, error) {
|
||||
|
||||
type CELRequest struct {
|
||||
*http.Request
|
||||
subRequestMode bool
|
||||
}
|
||||
|
||||
func (cr *CELRequest) Parent() cel.Activation { return nil }
|
||||
@@ -71,6 +74,14 @@ 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
|
||||
|
||||
@@ -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))
|
||||
checker, err := NewCELChecker(cfg, newTestDNS(t), false)
|
||||
if err != nil {
|
||||
t.Fatalf("creating CEL checker failed: %v", err)
|
||||
}
|
||||
@@ -42,3 +42,77 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+18
-8
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -94,23 +95,32 @@ func (hmc *HeaderMatchesChecker) Hash() string {
|
||||
}
|
||||
|
||||
type PathChecker struct {
|
||||
regexp *regexp.Regexp
|
||||
hash string
|
||||
regexp *regexp.Regexp
|
||||
hash string
|
||||
subRequestMode bool
|
||||
}
|
||||
|
||||
func NewPathChecker(rexStr string) (checker.Impl, error) {
|
||||
func NewPathChecker(rexStr string, subrequestMode bool) (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)}, nil
|
||||
return &PathChecker{rex, internal.FastHash(rexStr), subrequestMode}, nil
|
||||
}
|
||||
|
||||
func (pc *PathChecker) Check(r *http.Request) (bool, error) {
|
||||
originalUrl := r.Header.Get("X-Original-URI")
|
||||
if originalUrl != "" {
|
||||
if pc.regexp.MatchString(originalUrl) {
|
||||
return true, nil
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+223
-2
@@ -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
|
||||
pc, err := NewPathChecker(tt.regex)
|
||||
// Create the PathChecker in subrequest mode so X-Original-URI is honored.
|
||||
pc, err := NewPathChecker(tt.regex, true)
|
||||
if err != nil {
|
||||
if !tt.expectError {
|
||||
t.Fatalf("NewPathChecker() unexpected error: %v", err)
|
||||
@@ -305,3 +305,224 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +222,16 @@ func New(opts ...cel.EnvOption) (*cel.Env, error) {
|
||||
return types.ValOrErr(val, "value is not an integer, but is %T", val)
|
||||
}
|
||||
|
||||
return types.Int(rand.IntN(int(n)))
|
||||
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))
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
)
|
||||
@@ -688,6 +689,14 @@ 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`,
|
||||
@@ -750,3 +759,65 @@ 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ var (
|
||||
)
|
||||
|
||||
func init() {
|
||||
globalLoadAvg = &loadAvg{}
|
||||
globalLoadAvg = &loadAvg{data: &load.AvgStat{}}
|
||||
go globalLoadAvg.updateThread(context.Background())
|
||||
}
|
||||
|
||||
|
||||
+14
-4
@@ -27,7 +27,7 @@ var (
|
||||
Applications = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_policy_results",
|
||||
Help: "The results of each policy rule",
|
||||
}, []string{"rule", "action"})
|
||||
}, []string{"rule", "action", "asn", "asn_description"})
|
||||
|
||||
ErrChallengeRuleHasWrongAlgorithm = errors.New("config.Bot.ChallengeRules: algorithm is invalid")
|
||||
warnedAboutThresholds = &atomic.Bool{}
|
||||
@@ -47,6 +47,8 @@ type ParsedConfig struct {
|
||||
Dns *dns.Dns
|
||||
Logger *slog.Logger
|
||||
Metrics *config.Metrics
|
||||
ThothClient *thoth.Client
|
||||
LogASN bool
|
||||
}
|
||||
|
||||
func newParsedConfig(orig *config.Config) *ParsedConfig {
|
||||
@@ -58,7 +60,7 @@ func newParsedConfig(orig *config.Config) *ParsedConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int, logLevel string) (*ParsedConfig, error) {
|
||||
func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int, logLevel string, subrequestMode bool) (*ParsedConfig, error) {
|
||||
c, err := config.Load(fin, fname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -70,6 +72,10 @@ 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()
|
||||
@@ -94,6 +100,10 @@ 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:
|
||||
@@ -142,7 +152,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
|
||||
}
|
||||
|
||||
if b.PathRegex != nil {
|
||||
c, err := NewPathChecker(*b.PathRegex)
|
||||
c, err := NewPathChecker(*b.PathRegex, subrequestMode)
|
||||
if err != nil {
|
||||
validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s path regex: %w", b.Name, err))
|
||||
} else {
|
||||
@@ -160,7 +170,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
|
||||
}
|
||||
|
||||
if b.Expression != nil {
|
||||
c, err := NewCELChecker(b.Expression, result.Dns)
|
||||
c, err := NewCELChecker(b.Expression, result.Dns, subrequestMode)
|
||||
if err != nil {
|
||||
validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s expressions: %w", b.Name, err))
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -19,7 +21,7 @@ func TestDefaultPolicyMustParse(t *testing.T) {
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty, "info"); err != nil {
|
||||
if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty, "info", false); err != nil {
|
||||
t.Fatalf("can't parse config: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +43,7 @@ func TestGoodConfigs(t *testing.T) {
|
||||
defer fin.Close()
|
||||
|
||||
ctx := thothmock.WithMockThoth(t)
|
||||
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info"); err != nil {
|
||||
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
@@ -53,7 +55,7 @@ func TestGoodConfigs(t *testing.T) {
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, "info"); err != nil {
|
||||
if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
@@ -77,7 +79,7 @@ func TestBadConfigs(t *testing.T) {
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info"); err == nil {
|
||||
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err == nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
t.Log(err)
|
||||
@@ -85,3 +87,27 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[cc] = struct{}{}
|
||||
countryMap[strings.ToLower(cc)] = struct{}{}
|
||||
fmt.Fprintln(&sb, cc)
|
||||
}
|
||||
|
||||
|
||||
Generated
+324
-357
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -20,11 +20,11 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"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.1"
|
||||
"prettier": "^3.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "^5.2.0",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
@@ -0,0 +1,27 @@
|
||||
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
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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);
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,8 @@
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
|
||||
providers:
|
||||
file:
|
||||
directory: /config
|
||||
watch: false
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -54,6 +54,7 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user