Compare commits

...

11 Commits

Author SHA1 Message Date
Jason Cameron e66b45bd34 Merge branch 'main' into prepare-caddy-embedding-core
Signed-off-by: Jason Cameron <git@jasoncameron.dev>
2026-05-25 14:31:48 -04:00
dependabot[bot] 63d517c34d build(deps): bump the npm group across 1 directory with 6 updates (#1646)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jason Cameron <git@jasoncameron.dev>
2026-05-25 01:40:55 -04:00
Xe Iaso b57508afcd fix(honeypot/naive): apply robot9001 style delays (#1632)
Currently the honeypotting feature has no limits or delays anywhere and
uses that to feed an internal greylist of IP networks. This can cause
issues such as in #1613 where Claude's crawler seemed to pick up on it
and egress data at over one megabit per second until the administrator
noticed and blocked the address range.

This takes a different approach by inspiration of how the classic #xkcd
IRC bot Robot9000 works. The first time a given IPv4 /24 or IPv6 /48
visits a honepot page, Anubis sleeps for 1 millisecond. The second it
sleeps for two milliseconds. The third is four milliseconds and so on.
The goal of this is to make the scraping inherently self-limiting such
that the scrapers go off in their own corner where they won't really
hurt anyone.

Let's see if this works out according to keikaku.

Ref: https://github.com/TecharoHQ/anubis/issues/1613

Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-05-15 17:56:37 -04:00
Xe Iaso 276b537776 fix(policy): correctly wire subrequest mode through CEL/path checkers (#1630)
* fix(policy): correctly wire subrequest mode through CEL/path checkers

Previously Anubis only checked for the X-Original-Url when using
subrequest mode. This header is used by the example nginx config to pass
the request path through from the original client request to Anubis in
order to do path-based filtering.

According to facts and circumstances, Traefik hardcodes its own
headers[1]:

```text
httpdebug-1  | GET /.within.website/x/cmd/anubis/api/check
httpdebug-1  | X-Forwarded-Method: GET
httpdebug-1  | X-Forwarded-Proto: http
httpdebug-1  | X-Forwarded-Server: b9a5d299c929
httpdebug-1  | X-Forwarded-Port: 8080
httpdebug-1  | X-Forwarded-Uri: /
httpdebug-1  | X-Real-Ip: 172.18.0.1
httpdebug-1  | Accept-Encoding: gzip
httpdebug-1  | User-Agent: curl/8.20.0
httpdebug-1  | Accept: */*
httpdebug-1  | X-Forwarded-For: 172.18.0.1
httpdebug-1  | X-Forwarded-Host: localhost:8080
```

As a result, this means that path-based filtering did not work.

This commit fixes this issue by amending how path based checking logic
works:

* For CEL based checks, this pipes through the `subrequestMode` flag from
  main and alters the behaviour if either `X-Original-Url` or
  `X-Forwarded-Url` are found. These values are currently hardcoded for
  convenience but probably need to be made configurable in the policy
  file at a future date.
* For path-based checks, this uses the existing `subrequestMode` flag
  from main and adds `X-Forwarded-Url` to the list of headers it checks.

A smoke test was added to make sure that traefik in this mode continues
to work. Thank you https://github.com/flifloo for filing a detailed
issue with the relevant configuration fragments. Those configuration
fragments formed the core of this smoke test.

[1]: https://doc.traefik.io/traefik/v3.4/middlewares/http/forwardauth/

Closes: https://github.com/TecharoHQ/anubis/issues/1628
Signed-off-by: Xe Iaso <me@xeiaso.net>
Co-Authored-By: flifloo <flifloo@gmail.com>

* chore: spelling

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

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
Co-authored-by: flifloo <flifloo@gmail.com>
2026-05-14 21:37:02 -04:00
dependabot[bot] 9f479f578a build(deps): bump github.com/go-jose/go-jose/v3 from 3.0.4 to 3.0.5 (#1629)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-14 14:07:24 -04:00
Jason Cameron cca247c25b refactor(web): pass render URL state explicitly
Signed-off-by: Jason Cameron <jason.cameron@stanwith.me>
2026-05-14 01:33:27 -04:00
Jason Cameron c129cafc3a docs: update changelog for server URL state
Signed-off-by: Jason Cameron <jason.cameron@stanwith.me>
2026-05-14 01:20:38 -04:00
Jason Cameron b250fea00b refactor(lib): keep server URL state local
Signed-off-by: Jason Cameron <jason.cameron@stanwith.me>
2026-05-14 01:19:58 -04:00
dependabot[bot] c184028d42 build(deps-dev): bump the npm group across 1 directory with 6 updates (#1621)
Bumps the npm group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `20.5.0` | `20.5.3` |
| [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) | `20.5.0` | `20.5.3` |
| [baseline-browser-mapping](https://github.com/web-platform-dx/baseline-browser-mapping) | `2.10.15` | `2.10.27` |
| [cssnano](https://github.com/cssnano/cssnano) | `7.1.4` | `7.1.8` |
| [cssnano-preset-advanced](https://github.com/cssnano/cssnano) | `7.0.12` | `7.0.16` |
| [prettier](https://github.com/prettier/prettier) | `3.8.1` | `3.8.3` |



Updates `@commitlint/cli` from 20.5.0 to 20.5.3
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v20.5.3/@commitlint/cli)

Updates `@commitlint/config-conventional` from 20.5.0 to 20.5.3
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v20.5.3/@commitlint/config-conventional)

Updates `baseline-browser-mapping` from 2.10.15 to 2.10.27
- [Release notes](https://github.com/web-platform-dx/baseline-browser-mapping/releases)
- [Commits](https://github.com/web-platform-dx/baseline-browser-mapping/compare/v2.10.15...v2.10.27)

Updates `cssnano` from 7.1.4 to 7.1.8
- [Release notes](https://github.com/cssnano/cssnano/releases)
- [Commits](https://github.com/cssnano/cssnano/compare/cssnano@7.1.4...cssnano@7.1.8)

Updates `cssnano-preset-advanced` from 7.0.12 to 7.0.16
- [Release notes](https://github.com/cssnano/cssnano/releases)
- [Commits](https://github.com/cssnano/cssnano/compare/cssnano-preset-advanced@7.0.12...cssnano-preset-advanced@7.0.16)

Updates `prettier` from 3.8.1 to 3.8.3
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.3)

---
updated-dependencies:
- dependency-name: "@commitlint/cli"
  dependency-version: 20.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 20.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: baseline-browser-mapping
  dependency-version: 2.10.27
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: cssnano
  dependency-version: 7.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: cssnano-preset-advanced
  dependency-version: 7.0.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jason Cameron <git@jasoncameron.dev>
2026-05-12 16:32:01 -04:00
Xe Iaso 0491f1fac2 fix: patch GHSA-6wcg-mqvh-fcvg (#1616)
* fix: patch 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.

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

* chore: spelling

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

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-05-08 19:17:25 -04:00
Timon de Groot d3a00da448 feat: Log weight when issuing challenge (#1611)
This can come in handy when analyzing the logs

Signed-off-by: Timon de Groot <tdegroot96@gmail.com>
2026-05-05 16:57:45 +00:00
43 changed files with 1437 additions and 719 deletions
+2
View File
@@ -39,3 +39,5 @@ wenet
qwertiko
setuplistener
mba
xfu
xou
+3
View File
@@ -120,6 +120,7 @@ fahedouch
fastcgi
FCr
fcrdns
fcvg
fediverse
ffprobe
fhdr
@@ -238,6 +239,7 @@ mnt
Mojeek
mojeekbot
mozilla
mqvh
myclient
mymaster
mypass
@@ -387,6 +389,7 @@ vnd
VPS
Vultr
WAIFU
wcg
weblate
webmaster
webpage
+1
View File
@@ -27,6 +27,7 @@ jobs:
- palemoon/amd64
#- palemoon/i386
- robots_txt
- traefik
runs-on: ubuntu-latest
steps:
- name: Checkout code
+1 -1
View File
@@ -259,7 +259,7 @@ func main() {
}
lg.Info("loading policy file", "fname", *policyFname)
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty, *slogLevel)
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty, *slogLevel, strings.TrimSpace(*target) == "")
if err != nil {
log.Fatalf("can't parse policy file: %v", err)
}
+12 -7
View File
@@ -13,19 +13,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- This changes the project to: -->
- Move metrics server configuration to [the policy file](./admin/policies.mdx#metrics-server).
- 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)
- Instruct reverse proxies to not cache error pages.
- Instruct reverse proxies to not cache error pages
- 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.
- 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
- Keep Anubis server URL state local to each `lib.Server` instance to make embedded use safer
- 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))
## v1.25.0: Necron
+1 -1
View File
@@ -106,7 +106,7 @@ require (
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.16.2 // indirect
github.com/go-jose/go-jose/v3 v3.0.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
+2 -2
View File
@@ -189,8 +189,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-jose/go-jose/v3 v3.0.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=
+4
View File
@@ -5,6 +5,7 @@ import (
_ "embed"
"fmt"
"log/slog"
"math"
"math/rand/v2"
"net/http"
"net/netip"
@@ -168,6 +169,9 @@ func (i *Impl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
millisecondAmount := math.Pow(float64(networkCount), 2)
time.Sleep(time.Duration(millisecondAmount) * time.Millisecond)
spins := i.makeSpins()
affirmations := i.makeAffirmations()
title := i.makeTitle()
+1 -1
View File
@@ -595,7 +595,7 @@ func spawnAnubisWithOptions(t *testing.T, basePrefix string) string {
fmt.Fprintf(w, "<html><body><span id=anubis-test>%d</span></body></html>", time.Now().Unix())
})
policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), "", anubis.DefaultDifficulty, "info")
policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), "", anubis.DefaultDifficulty, "info", false)
if err != nil {
t.Fatal(err)
}
+43 -16
View File
@@ -33,6 +33,7 @@ import (
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/store"
"github.com/TecharoHQ/anubis/web"
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
// challenge implementations
@@ -97,6 +98,8 @@ type Server struct {
OGTags *ogtags.OGTagCache
logger *slog.Logger
opts Options
basePrefix string
publicURL string
ed25519Priv ed25519.PrivateKey
hs512Secret []byte
}
@@ -123,6 +126,42 @@ func (s *Server) getRequestLogger(r *http.Request) (*slog.Logger, *http.Request)
return lg, r
}
func (s *Server) configuredBasePrefix() string {
if s.basePrefix != "" {
return s.basePrefix
}
return strings.TrimRight(s.opts.BasePrefix, "/")
}
func (s *Server) configuredPublicURL() string {
if s.publicURL != "" {
return s.publicURL
}
return s.opts.PublicUrl
}
func (s *Server) prefixedPath(path string) string {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return s.configuredBasePrefix() + path
}
func (s *Server) cookiePath() string {
basePrefix := s.configuredBasePrefix()
if basePrefix == "" {
return "/"
}
return basePrefix + "/"
}
func (s *Server) renderOptions() web.Options {
return web.Options{
BasePrefix: s.configuredBasePrefix(),
PublicURL: s.configuredPublicURL(),
}
}
func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
// return ED25519 key if HS512 is not set
if len(s.hs512Secret) == 0 {
@@ -186,7 +225,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
}
@@ -246,11 +285,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
return
}
// Adjust cookie path if base prefix is not empty
cookiePath := "/"
if anubis.BasePrefix != "" {
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
}
cookiePath := s.cookiePath()
cr, rule, err := s.check(r, lg)
if err != nil {
@@ -344,11 +379,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
}
func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, lg *slog.Logger, rule *policy.Bot) bool {
// Adjust cookie path if base prefix is not empty
cookiePath := "/"
if anubis.BasePrefix != "" {
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
}
cookiePath := s.cookiePath()
localizer := localization.GetLocalizer(r)
@@ -511,11 +542,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
return
}
// Adjust cookie path if base prefix is not empty
cookiePath := "/"
if anubis.BasePrefix != "" {
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
}
cookiePath := s.cookiePath()
if _, err := r.Cookie(anubis.TestCookieName); errors.Is(err, http.ErrNoCookie) {
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
+94 -4
View File
@@ -2,6 +2,7 @@ package lib
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
@@ -58,7 +59,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 +251,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)
}
})
@@ -501,8 +502,11 @@ func TestBasePrefix(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Reset the global BasePrefix before each test
anubis.BasePrefix = ""
originalBasePrefix := anubis.BasePrefix
anubis.BasePrefix = "/not-this-server"
t.Cleanup(func() {
anubis.BasePrefix = originalBasePrefix
})
pol := loadPolicies(t, "", 4)
@@ -631,10 +635,96 @@ func TestBasePrefix(t *testing.T) {
if ckie.Path != expectedPath {
t.Errorf("cookie path is wrong, wanted %s, got: %s", expectedPath, ckie.Path)
}
if anubis.BasePrefix != "/not-this-server" {
t.Errorf("New should not overwrite anubis.BasePrefix, got %q", anubis.BasePrefix)
}
})
}
}
func TestBasePrefixUsesServerConfigNotPackageGlobal(t *testing.T) {
originalBasePrefix := anubis.BasePrefix
anubis.BasePrefix = "/global"
t.Cleanup(func() {
anubis.BasePrefix = originalBasePrefix
})
pol := loadPolicies(t, "", 4)
srv := spawnAnubis(t, Options{
Next: http.NewServeMux(),
Policy: pol,
BasePrefix: "/local/",
})
if anubis.BasePrefix != "/global" {
t.Fatalf("New should leave anubis.BasePrefix alone, got %q", anubis.BasePrefix)
}
req := httptest.NewRequest(http.MethodPost, "/local/.within.website/x/cmd/anubis/api/make-challenge?redir=/", nil)
req.Header.Set("X-Real-Ip", "127.0.0.1")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected local server prefix route to work, got status %d body %q", rr.Code, rr.Body.String())
}
}
func TestRenderIndexUsesServerBasePrefixNotPackageGlobal(t *testing.T) {
originalBasePrefix := anubis.BasePrefix
anubis.BasePrefix = "/global"
t.Cleanup(func() {
anubis.BasePrefix = originalBasePrefix
})
pol := loadPolicies(t, "", 4)
srv := spawnAnubis(t, Options{
Next: http.NewServeMux(),
Policy: pol,
BasePrefix: "/local/",
})
req := httptest.NewRequest(http.MethodGet, "/local/protected", nil)
req.Header.Set("X-Real-Ip", "127.0.0.1")
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Accept-Encoding", "gzip")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
resp := rr.Result()
defer resp.Body.Close()
var body strings.Builder
reader := io.Reader(resp.Body)
if resp.Header.Get("Content-Encoding") == "gzip" {
gzipReader, err := gzip.NewReader(resp.Body)
if err != nil {
t.Fatalf("opening gzip response should not fail: %v", err)
}
defer gzipReader.Close()
reader = gzipReader
}
if _, err := io.Copy(&body, reader); err != nil {
t.Fatalf("reading challenge response should not fail: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected challenge status %d, got %d body %q", http.StatusOK, resp.StatusCode, body.String())
}
if !strings.Contains(body.String(), "/local/.within.website/x/cmd/anubis/static/js/main.mjs") {
t.Fatalf("expected challenge assets to use server base prefix, body %q", body.String())
}
if strings.Contains(body.String(), "/global/.within.website") {
t.Fatalf("challenge body used package global base prefix: %q", body.String())
}
if anubis.BasePrefix != "/global" {
t.Fatalf("rendering should leave anubis.BasePrefix alone, got %q", anubis.BasePrefix)
}
}
func TestCustomStatusCodes(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Log(r.UserAgent())
+6 -5
View File
@@ -44,11 +44,12 @@ func Methods() []string {
}
type IssueInput struct {
Impressum *config.Impressum
Rule *policy.Bot
Challenge *Challenge
OGTags map[string]string
Store store.Interface
BasePrefix string
Impressum *config.Impressum
Rule *policy.Bot
Challenge *Challenge
OGTags map[string]string
Store store.Interface
}
func (in *IssueInput) Valid() error {
+2 -3
View File
@@ -7,7 +7,6 @@ import (
"net/http"
"time"
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/a-h/templ"
@@ -28,7 +27,7 @@ func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in
return nil, err
}
u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge")
u, err := r.URL.Parse(in.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge")
if err != nil {
return nil, fmt.Errorf("can't render page: %w", err)
}
@@ -47,7 +46,7 @@ func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in
loc := localization.GetLocalizer(r)
result := page(u.String(), in.Rule.Challenge.Difficulty, showMeta, loc)
result := page(in.BasePrefix, u.String(), in.Rule.Challenge.Difficulty, showMeta, loc)
return result, nil
}
+3 -3
View File
@@ -7,10 +7,10 @@ import (
"github.com/TecharoHQ/anubis/lib/localization"
)
templ page(redir string, difficulty int, showMeta bool, loc *localization.SimpleLocalizer) {
templ page(basePrefix, redir string, difficulty int, showMeta bool, loc *localization.SimpleLocalizer) {
<div class="centered-div">
<img id="image" style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version }/>
<img style="display:none;" style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version }/>
<img id="image" style="width:100%;max-width:256px;" src={ basePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version }/>
<img style="display:none;" style="width:100%;max-width:256px;" src={ basePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version }/>
<p id="status">{ loc.T("loading") }</p>
<p>{ loc.T("connection_security") }</p>
if showMeta {
+5 -5
View File
@@ -15,7 +15,7 @@ import (
"github.com/TecharoHQ/anubis/lib/localization"
)
func page(redir string, difficulty int, showMeta bool, loc *localization.SimpleLocalizer) templ.Component {
func page(basePrefix, redir string, difficulty int, showMeta bool, loc *localization.SimpleLocalizer) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -41,9 +41,9 @@ func page(redir string, difficulty int, showMeta bool, loc *localization.SimpleL
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(basePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 12, Col: 165}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 12, Col: 158}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -54,9 +54,9 @@ func page(redir string, difficulty int, showMeta bool, loc *localization.SimpleL
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(basePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 13, Col: 174}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 13, Col: 167}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
+2 -3
View File
@@ -10,7 +10,6 @@ import (
"net/http"
"time"
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/localization"
@@ -43,7 +42,7 @@ func (i *impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in
return nil, err
}
u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge")
u, err := r.URL.Parse(in.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge")
if err != nil {
return nil, fmt.Errorf("can't render page: %w", err)
}
@@ -55,7 +54,7 @@ func (i *impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in
loc := localization.GetLocalizer(r)
result := page(u.String(), in.Challenge.RandomData, in.Rule.Challenge.Difficulty, loc)
result := page(in.BasePrefix, u.String(), in.Challenge.RandomData, in.Rule.Challenge.Difficulty, loc)
return result, nil
}
+3 -3
View File
@@ -5,10 +5,10 @@ import (
"github.com/TecharoHQ/anubis/lib/localization"
)
templ page(redir, challenge string, difficulty int, loc *localization.SimpleLocalizer) {
templ page(basePrefix, redir, challenge string, difficulty int, loc *localization.SimpleLocalizer) {
<div class="centered-div">
<div id="app">
<img id="image" style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version }/>
<img id="image" style="width:100%;max-width:256px;" src={ basePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version }/>
<p id="status">{ loc.T("loading") }</p>
<p>{ loc.T("connection_security") }</p>
</div>
@@ -18,7 +18,7 @@ templ page(redir, challenge string, difficulty int, loc *localization.SimpleLoca
"difficulty": difficulty,
"connection_security_message": loc.T("connection_security"),
"loading_message": loc.T("loading"),
"pensive_url": anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version,
"pensive_url": basePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version,
})
@templ.ComponentFunc(renderAppJS)
<noscript>
+4 -4
View File
@@ -13,7 +13,7 @@ import (
"github.com/TecharoHQ/anubis/lib/localization"
)
func page(redir, challenge string, difficulty int, loc *localization.SimpleLocalizer) templ.Component {
func page(basePrefix, redir, challenge string, difficulty int, loc *localization.SimpleLocalizer) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -39,9 +39,9 @@ func page(redir, challenge string, difficulty int, loc *localization.SimpleLocal
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(basePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `preact.templ`, Line: 11, Col: 166}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `preact.templ`, Line: 11, Col: 159}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -83,7 +83,7 @@ func page(redir, challenge string, difficulty int, loc *localization.SimpleLocal
"difficulty": difficulty,
"connection_security_message": loc.T("connection_security"),
"loading_message": loc.T("loading"),
"pensive_url": anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version,
"pensive_url": basePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version,
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
+1 -1
View File
@@ -29,7 +29,7 @@ func (i *Impl) Setup(mux *http.ServeMux) {}
func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) {
loc := localization.GetLocalizer(r)
return page(loc), nil
return page(in.BasePrefix, loc), nil
}
func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInput) error {
+4 -4
View File
@@ -5,12 +5,12 @@ import (
"github.com/TecharoHQ/anubis/lib/localization"
)
templ page(localizer *localization.SimpleLocalizer) {
templ page(basePrefix string, localizer *localization.SimpleLocalizer) {
<div class="centered-div">
<img id="image" style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version }/>
<img style="display:none;" style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version }/>
<img id="image" style="width:100%;max-width:256px;" src={ basePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version }/>
<img style="display:none;" style="width:100%;max-width:256px;" src={ basePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version }/>
<p id="status">{ localizer.T("loading") }</p>
<script async type="module" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=" + anubis.Version }></script>
<script async type="module" src={ basePrefix + "/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=" + anubis.Version }></script>
<div id="progress" role="progressbar" aria-labelledby="status">
<div class="bar-inner"></div>
</div>
+7 -7
View File
@@ -13,7 +13,7 @@ import (
"github.com/TecharoHQ/anubis/lib/localization"
)
func page(localizer *localization.SimpleLocalizer) templ.Component {
func page(basePrefix string, localizer *localization.SimpleLocalizer) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -39,9 +39,9 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(basePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 10, Col: 165}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 10, Col: 158}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -52,9 +52,9 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(basePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 11, Col: 174}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 11, Col: 167}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -78,9 +78,9 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(basePrefix + "/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 13, Col: 136}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 13, Col: 129}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
+14 -20
View File
@@ -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)
}
@@ -112,8 +112,7 @@ func New(opts Options) (*Server, error) {
opts.ED25519PrivateKey = priv
}
anubis.BasePrefix = strings.TrimRight(opts.BasePrefix, "/")
anubis.PublicUrl = opts.PublicUrl
opts.BasePrefix = strings.TrimRight(opts.BasePrefix, "/")
result := &Server{
next: opts.Next,
@@ -121,6 +120,8 @@ func New(opts Options) (*Server, error) {
hs512Secret: opts.HS512Secret,
policy: opts.Policy,
opts: opts,
basePrefix: opts.BasePrefix,
publicURL: opts.PublicUrl,
OGTags: ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store, ogtags.TargetOptions{
Host: opts.TargetHost,
SNI: opts.TargetSNI,
@@ -131,28 +132,20 @@ func New(opts Options) (*Server, error) {
}
mux := http.NewServeMux()
xess.Mount(mux)
xessPrefix := result.prefixedPath(xess.BasePrefix)
mux.Handle(xessPrefix, internal.UnchangingCache(http.StripPrefix(xessPrefix, http.FileServerFS(xess.Static))))
// Helper to add global prefix
// Helper to add the server-local base prefix.
registerWithPrefix := func(pattern string, handler http.Handler, method string) {
if method != "" {
method = method + " " // methods must end with a space to register with them
}
// Ensure there's no double slash when concatenating BasePrefix and pattern
basePrefix := strings.TrimSuffix(anubis.BasePrefix, "/")
prefix := method + basePrefix
// If pattern doesn't start with a slash, add one
if !strings.HasPrefix(pattern, "/") {
pattern = "/" + pattern
}
mux.Handle(prefix+pattern, handler)
mux.Handle(method+result.prefixedPath(pattern), handler)
}
// Ensure there's no double slash when concatenating BasePrefix and StaticPath
stripPrefix := strings.TrimSuffix(anubis.BasePrefix, "/") + anubis.StaticPath
stripPrefix := result.prefixedPath(anubis.StaticPath)
registerWithPrefix(anubis.StaticPath, internal.UnchangingCache(internal.NoBrowsing(http.StripPrefix(stripPrefix, http.FileServerFS(web.Static)))), "")
if opts.ServeRobotsTXT {
@@ -166,9 +159,10 @@ func New(opts Options) (*Server, error) {
if opts.Policy.Impressum != nil {
registerWithPrefix(anubis.APIPrefix+"imprint", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
templ.Handler(
web.Base(opts.Policy.Impressum.Page.Title, opts.Policy.Impressum.Page, opts.Policy.Impressum, localization.GetLocalizer(r)),
).ServeHTTP(w, r)
handler := templ.Handler(
web.BaseWithOptions(result.renderOptions(), opts.Policy.Impressum.Page.Title, opts.Policy.Impressum.Page, opts.Policy.Impressum, localization.GetLocalizer(r)),
)
handler.ServeHTTP(w, r)
}), "GET")
}
+4 -4
View File
@@ -12,7 +12,7 @@ import (
)
func TestInvalidChallengeMethod(t *testing.T) {
if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4, "info"); !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)
}
})
+37 -21
View File
@@ -193,7 +193,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
localizer := localization.GetLocalizer(r)
if returnHTTPStatusOnly {
if s.opts.PublicUrl == "" {
if s.configuredPublicURL() == "" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(localizer.T("authorization_required")))
} else {
@@ -263,11 +263,12 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
}
in := &challenge.IssueInput{
Impressum: s.policy.Impressum,
Rule: rule,
Challenge: chall,
OGTags: ogTags,
Store: s.store,
BasePrefix: s.configuredBasePrefix(),
Impressum: s.policy.Impressum,
Rule: rule,
Challenge: chall,
OGTags: ogTags,
Store: s.store,
}
component, err := impl.Issue(w, r, lg, in)
@@ -277,7 +278,8 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
return
}
page := web.BaseWithChallengeAndOGTags(
page := web.BaseWithChallengeAndOGTagsWithOptions(
s.renderOptions(),
localizer.T("making_sure_not_bot"),
component,
s.policy.Impressum,
@@ -323,15 +325,17 @@ func (s *Server) constructRedirectURL(r *http.Request) (string, error) {
redir := proto + "://" + host + uri
escapedURL := url.QueryEscape(redir)
return fmt.Sprintf("%s/.within.website/?redir=%s", s.opts.PublicUrl, escapedURL), nil
return fmt.Sprintf("%s/.within.website/?redir=%s", s.configuredPublicURL(), escapedURL), nil
}
func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) {
localizer := localization.GetLocalizer(r)
opts := s.renderOptions()
templ.Handler(
web.Base(localizer.T("benchmarking_anubis"), web.Bench(localizer), s.policy.Impressum, localizer),
).ServeHTTP(w, r)
handler := templ.Handler(
web.BaseWithOptions(opts, localizer.T("benchmarking_anubis"), web.BenchWithOptions(opts, localizer), s.policy.Impressum, localizer),
)
handler.ServeHTTP(w, r)
}
func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, message, code string) {
@@ -340,10 +344,12 @@ func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, messag
func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg, code string, status int) {
localizer := localization.GetLocalizer(r)
opts := s.renderOptions()
component := web.Base(
component := web.BaseWithOptions(
opts,
localizer.T("oh_noes"),
web.ErrorPage(msg, s.opts.WebmasterEmail, code, localizer),
web.ErrorPageWithOptions(opts, msg, s.opts.WebmasterEmail, code, localizer),
s.policy.Impressum,
localizer,
)
@@ -352,17 +358,25 @@ func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg,
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+anubis.StaticPath) {
if strings.HasPrefix(r.URL.Path, s.prefixedPath(anubis.StaticPath)) {
s.mux.ServeHTTP(w, r)
return
} else if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+xess.BasePrefix) {
} else if strings.HasPrefix(r.URL.Path, s.prefixedPath(xess.BasePrefix)) {
s.mux.ServeHTTP(w, r)
return
}
// Forward robots.txt requests to mux when ServeRobotsTXT is enabled
if s.opts.ServeRobotsTXT {
path := strings.TrimPrefix(r.URL.Path, anubis.BasePrefix)
path := r.URL.Path
basePrefix := s.configuredBasePrefix()
if basePrefix != "" {
if !strings.HasPrefix(path, basePrefix) {
s.maybeReverseProxyOrPage(w, r)
return
}
path = strings.TrimPrefix(path, basePrefix)
}
if path == "/robots.txt" || path == "/.well-known/robots.txt" {
s.mux.ServeHTTP(w, r)
return
@@ -373,11 +387,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) stripBasePrefixFromRequest(r *http.Request) *http.Request {
if !s.opts.StripBasePrefix || s.opts.BasePrefix == "" {
basePrefix := s.configuredBasePrefix()
if !s.opts.StripBasePrefix || basePrefix == "" {
return r
}
basePrefix := strings.TrimSuffix(s.opts.BasePrefix, "/")
path := r.URL.Path
if !strings.HasPrefix(path, basePrefix) {
@@ -441,9 +455,11 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
return
}
templ.Handler(
web.Base(localizer.T("you_are_not_a_bot"), web.StaticHappy(localizer), s.policy.Impressum, localizer),
).ServeHTTP(w, r)
opts := s.renderOptions()
handler := templ.Handler(
web.BaseWithOptions(opts, localizer.T("you_are_not_a_bot"), web.StaticHappyWithOptions(opts, localizer), s.policy.Impressum, localizer),
)
handler.ServeHTTP(w, r)
} else {
asn, asnDesc := asnFromContext(r.Context())
requestsProxied.WithLabelValues(r.Host, asn, asnDesc).Inc()
+17 -6
View File
@@ -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
+75 -1
View File
@@ -23,7 +23,7 @@ func TestCELChecker_MapIterationWrappers(t *testing.T) {
Expression: `headers.exists(k, k == "Accept") && query.exists(k, k == "format")`,
}
checker, err := NewCELChecker(cfg, newTestDNS(t))
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)
}
})
}
}
+14 -8
View File
@@ -94,23 +94,29 @@ 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 pc.regexp.MatchString(originalUrl) {
return true, nil
}
}
}
+223 -2
View File
@@ -272,8 +272,8 @@ func TestPathChecker_XOriginalURI(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create the PathChecker
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)
}
})
}
}
+3 -3
View File
@@ -60,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
@@ -152,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 {
@@ -170,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 {
+4 -4
View File
@@ -19,7 +19,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 +41,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 +53,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 +77,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)
+622 -537
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -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": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1",
"baseline-browser-mapping": "^2.10.30",
"cssnano": "^8.0.1",
"cssnano-preset-advanced": "^8.0.1",
"esbuild": "^0.28.0",
"husky": "^9.1.7",
"playwright": "^1.52.0",
@@ -32,11 +32,11 @@
"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",
"preact": "^10.29.1"
"preact": "^10.29.2"
},
"commitlint": {
"extends": [
+16
View File
@@ -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
+27
View File
@@ -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
+30
View File
@@ -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
+33
View File
@@ -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);
+22
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
entryPoints:
web:
address: ":80"
providers:
file:
directory: /config
watch: false
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+46 -4
View File
@@ -7,17 +7,43 @@ import (
"github.com/a-h/templ"
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/localization"
)
// Options carries per-server render state for Anubis pages. Embedders can
// render multiple Server instances in one process without mutating package
// globals.
type Options struct {
BasePrefix string
PublicURL string
}
// DefaultOptions preserves the legacy package-global behavior for callers that
// render web components directly.
func DefaultOptions() Options {
return Options{
BasePrefix: anubis.BasePrefix,
PublicURL: anubis.PublicUrl,
}
}
func Base(title string, body templ.Component, impressum *config.Impressum, localizer *localization.SimpleLocalizer) templ.Component {
return base(title, body, impressum, nil, nil, localizer)
return BaseWithOptions(DefaultOptions(), title, body, impressum, localizer)
}
func BaseWithOptions(opts Options, title string, body templ.Component, impressum *config.Impressum, localizer *localization.SimpleLocalizer) templ.Component {
return base(opts, title, body, impressum, nil, nil, localizer)
}
func BaseWithChallengeAndOGTags(title string, body templ.Component, impressum *config.Impressum, challenge *challenge.Challenge, rules *config.ChallengeRules, ogTags map[string]string, localizer *localization.SimpleLocalizer) templ.Component {
return base(title, body, impressum, struct {
return BaseWithChallengeAndOGTagsWithOptions(DefaultOptions(), title, body, impressum, challenge, rules, ogTags, localizer)
}
func BaseWithChallengeAndOGTagsWithOptions(opts Options, title string, body templ.Component, impressum *config.Impressum, challenge *challenge.Challenge, rules *config.ChallengeRules, ogTags map[string]string, localizer *localization.SimpleLocalizer) templ.Component {
return base(opts, title, body, impressum, struct {
Rules *config.ChallengeRules `json:"rules"`
Challenge any `json:"challenge"`
}{
@@ -27,11 +53,27 @@ func BaseWithChallengeAndOGTags(title string, body templ.Component, impressum *c
}
func ErrorPage(msg, mail, code string, localizer *localization.SimpleLocalizer) templ.Component {
return errorPage(msg, mail, code, localizer)
return ErrorPageWithOptions(DefaultOptions(), msg, mail, code, localizer)
}
func ErrorPageWithOptions(opts Options, msg, mail, code string, localizer *localization.SimpleLocalizer) templ.Component {
return errorPage(opts, msg, mail, code, localizer)
}
func StaticHappy(localizer *localization.SimpleLocalizer) templ.Component {
return StaticHappyWithOptions(DefaultOptions(), localizer)
}
func StaticHappyWithOptions(opts Options, localizer *localization.SimpleLocalizer) templ.Component {
return staticHappy(opts, localizer)
}
func Bench(localizer *localization.SimpleLocalizer) templ.Component {
return bench(localizer)
return BenchWithOptions(DefaultOptions(), localizer)
}
func BenchWithOptions(opts Options, localizer *localization.SimpleLocalizer) templ.Component {
return bench(opts, localizer)
}
func honeypotLink(href string) templ.Component {
+13 -13
View File
@@ -9,12 +9,12 @@ import (
"github.com/google/uuid"
)
templ base(title string, body templ.Component, impressum *config.Impressum, challenge any, ogTags map[string]string, localizer *localization.SimpleLocalizer) {
templ base(opts Options, title string, body templ.Component, impressum *config.Impressum, challenge any, ogTags map[string]string, localizer *localization.SimpleLocalizer) {
<!DOCTYPE html>
<html lang={ localizer.GetLang() }>
<head>
<title>{ title }</title>
<link rel="stylesheet" href={ anubis.BasePrefix + xess.URL }/>
<link rel="stylesheet" href={ opts.BasePrefix + xess.URL }/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="robots" content="noindex,nofollow"/>
for key, value := range ogTags {
@@ -60,11 +60,11 @@ templ base(title string, body templ.Component, impressum *config.Impressum, chal
</style>
@templ.JSONScript("anubis_version", anubis.Version)
@templ.JSONScript("anubis_challenge", challenge)
@templ.JSONScript("anubis_base_prefix", anubis.BasePrefix)
@templ.JSONScript("anubis_public_url", anubis.PublicUrl)
@templ.JSONScript("anubis_base_prefix", opts.BasePrefix)
@templ.JSONScript("anubis_public_url", opts.PublicURL)
</head>
<body id="top">
@honeypotLink(anubis.BasePrefix + fmt.Sprintf("%shoneypot/%s/init", anubis.APIPrefix, uuid.NewString()))
@honeypotLink(opts.BasePrefix + fmt.Sprintf("%shoneypot/%s/init", anubis.APIPrefix, uuid.NewString()))
<main>
<h1 id="title" class="centered-div">{ title }</h1>
@body
@@ -79,7 +79,7 @@ templ base(title string, body templ.Component, impressum *config.Impressum, chal
if impressum != nil {
<p>
@templ.Raw(impressum.Footer)
-- <a href={ templ.SafeURL(anubis.BasePrefix + fmt.Sprintf("%simprint", anubis.APIPrefix)) }>Imprint</a>
-- <a href={ templ.SafeURL(opts.BasePrefix + fmt.Sprintf("%simprint", anubis.APIPrefix)) }>Imprint</a>
</p>
}
<p>{ localizer.T("version_info") } <code>{ anubis.Version }</code>.</p>
@@ -90,9 +90,9 @@ templ base(title string, body templ.Component, impressum *config.Impressum, chal
</html>
}
templ errorPage(message, mail, code string, localizer *localization.SimpleLocalizer) {
templ errorPage(opts Options, message, mail, code string, localizer *localization.SimpleLocalizer) {
<div class="centered-div">
<img id="image" alt="Sad Anubis" style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/reject.webp?cacheBuster=" + anubis.Version }/>
<img id="image" alt="Sad Anubis" style="width:100%;max-width:256px;" src={ opts.BasePrefix + "/.within.website/x/cmd/anubis/static/img/reject.webp?cacheBuster=" + anubis.Version }/>
<p>{ message }.</p>
if code != "" {
<code><pre>{ code }</pre></code>
@@ -110,19 +110,19 @@ templ errorPage(message, mail, code string, localizer *localization.SimpleLocali
</div>
}
templ StaticHappy(localizer *localization.SimpleLocalizer) {
templ staticHappy(opts Options, localizer *localization.SimpleLocalizer) {
<div class="centered-div">
<img
style="display:none;"
style="width:100%;max-width:256px;"
src={ "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" +
src={ opts.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" +
anubis.Version }
/>
<p>{ localizer.T("static_check_endpoint") }</p>
</div>
}
templ bench(localizer *localization.SimpleLocalizer) {
templ bench(opts Options, localizer *localization.SimpleLocalizer) {
<div style="height:20rem;display:flex">
<table style="margin-top:1rem;display:grid;grid-template:auto 1fr/auto auto;gap:0 0.5rem">
<thead
@@ -145,9 +145,9 @@ templ bench(localizer *localization.SimpleLocalizer) {
></tbody>
</table>
<div class="centered-div">
<img id="image" style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version }/>
<img id="image" style="width:100%;max-width:256px;" src={ opts.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version }/>
<p id="status" style="max-width:256px">{ localizer.T("loading") }</p>
<script async type="module" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/js/bench.mjs?cacheBuster=" + anubis.Version }></script>
<script async type="module" src={ opts.BasePrefix + "/.within.website/x/cmd/anubis/static/js/bench.mjs?cacheBuster=" + anubis.Version }></script>
<div id="sparkline"></div>
<noscript>
<p>{ localizer.T("benchmark_requires_js") }</p>
+18 -18
View File
@@ -17,7 +17,7 @@ import (
"github.com/google/uuid"
)
func base(title string, body templ.Component, impressum *config.Impressum, challenge any, ogTags map[string]string, localizer *localization.SimpleLocalizer) templ.Component {
func base(opts Options, title string, body templ.Component, impressum *config.Impressum, challenge any, ogTags map[string]string, localizer *localization.SimpleLocalizer) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -69,9 +69,9 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(anubis.BasePrefix + xess.URL)
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(opts.BasePrefix + xess.URL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 17, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 17, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -125,11 +125,11 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.JSONScript("anubis_base_prefix", anubis.BasePrefix).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = templ.JSONScript("anubis_base_prefix", opts.BasePrefix).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.JSONScript("anubis_public_url", anubis.PublicUrl).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = templ.JSONScript("anubis_public_url", opts.PublicURL).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -137,7 +137,7 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = honeypotLink(anubis.BasePrefix+fmt.Sprintf("%shoneypot/%s/init", anubis.APIPrefix, uuid.NewString())).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = honeypotLink(opts.BasePrefix+fmt.Sprintf("%shoneypot/%s/init", anubis.APIPrefix, uuid.NewString())).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -245,9 +245,9 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(anubis.BasePrefix + fmt.Sprintf("%simprint", anubis.APIPrefix)))
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(opts.BasePrefix + fmt.Sprintf("%simprint", anubis.APIPrefix)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 82, Col: 98}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 82, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -292,7 +292,7 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
})
}
func errorPage(message, mail, code string, localizer *localization.SimpleLocalizer) templ.Component {
func errorPage(opts Options, message, mail, code string, localizer *localization.SimpleLocalizer) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -318,9 +318,9 @@ func errorPage(message, mail, code string, localizer *localization.SimpleLocaliz
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/reject.webp?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opts.BasePrefix + "/.within.website/x/cmd/anubis/static/img/reject.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 95, Col: 181}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 95, Col: 179}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -446,7 +446,7 @@ func errorPage(message, mail, code string, localizer *localization.SimpleLocaliz
})
}
func StaticHappy(localizer *localization.SimpleLocalizer) templ.Component {
func staticHappy(opts Options, localizer *localization.SimpleLocalizer) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -472,7 +472,7 @@ func StaticHappy(localizer *localization.SimpleLocalizer) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs("/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" +
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(opts.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" +
anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 119, Col: 18}
@@ -502,7 +502,7 @@ func StaticHappy(localizer *localization.SimpleLocalizer) templ.Component {
})
}
func bench(localizer *localization.SimpleLocalizer) templ.Component {
func bench(opts Options, localizer *localization.SimpleLocalizer) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -606,9 +606,9 @@ func bench(localizer *localization.SimpleLocalizer) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(opts.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 148, Col: 166}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 148, Col: 164}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
@@ -632,9 +632,9 @@ func bench(localizer *localization.SimpleLocalizer) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/js/bench.mjs?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(opts.BasePrefix + "/.within.website/x/cmd/anubis/static/js/bench.mjs?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 150, Col: 138}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 150, Col: 136}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
+1 -1
View File
@@ -59,7 +59,7 @@ func TestBasePrefixInLinks(t *testing.T) {
// Render the base template to a buffer
var buf strings.Builder
component := base(tt.name, templ.NopComponent, impressum, nil, nil, localizer)
component := base(Options{BasePrefix: tt.basePrefix}, tt.name, templ.NopComponent, impressum, nil, nil, localizer)
err := component.Render(context.Background(), &buf)
if err != nil {
t.Fatalf("failed to render template: %v", err)