Skip to content
Merged
7 changes: 6 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,13 @@ jobs:
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/go.sum') }}

- name: install playwright browsers
run: |
npx --yes playwright@1.50.1 install --with-deps
npx --yes playwright@1.50.1 run-server --port 3000 &

- name: Build
run: go build ./...

- name: Test
run: go test ./...
run: go test -v ./...
12 changes: 8 additions & 4 deletions cmd/anubis/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ var (
bind = flag.String("bind", ":8923", "network address to bind HTTP to")
bindNetwork = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
challengeDifficulty = flag.Int("difficulty", anubis.DefaultDifficulty, "difficulty of the challenge")
cookieDomain = flag.String("cookie-domain", "", "if set, the top-level domain that the Anubis cookie will be valid for")
cookiePartitioned = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support")
ed25519PrivateKeyHex = flag.String("ed25519-private-key-hex", "", "private key used to sign JWTs, if not set a random one will be assigned")
metricsBind = flag.String("metrics-bind", ":9090", "network address to bind metrics to")
metricsBindNetwork = flag.String("metrics-bind-network", "tcp", "network family for the metrics server to bind to")
Expand Down Expand Up @@ -189,10 +191,12 @@ func main() {
}

s, err := libanubis.New(libanubis.Options{
Next: rp,
Policy: policy,
ServeRobotsTXT: *robotsTxt,
PrivateKey: priv,
Next: rp,
Policy: policy,
ServeRobotsTXT: *robotsTxt,
PrivateKey: priv,
CookieDomain: *cookieDomain,
CookiePartitioned: *cookiePartitioned,
})
if err != nil {
log.Fatalf("can't construct libanubis.Server: %v", err)
Expand Down
3 changes: 3 additions & 0 deletions docs/docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix default difficulty setting that was broken in a refactor
- Linting fixes
- Make dark mode diff lines readable in the documentation
- Add the ability to set the cookie domain with the envvar `COOKIE_DOMAIN=techaro.lol` for all domains under `techaro.lol`
- Add the ability to set the cookie partitioned flag with the envvar `COOKIE_PARTITIONED=true`
- Fix CI based browser smoke test

## v1.14.2

Expand Down
2 changes: 2 additions & 0 deletions docs/docs/admin/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Anubis uses these environment variables for configuration:
| :------------------------ | :---------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BIND` | `:8923` | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock` |
| `BIND_NETWORK` | `tcp` | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports. |
| `COOKIE_DOMAIN` | unset | The domain the Anubis challenge pass cookie should be set to. This should be set to the domain you bought from your registrar (EG: `techaro.lol` if your webapp is running on `anubis.techaro.lol`). See [here](https://stackoverflow.com/a/1063760) for more information. |
| `COOKIE_PARTITIONED` | `false` | If set to `true`, enables the [partitioned (CHIPS) flag](https://developers.google.com/privacy-sandbox/cookies/chips), meaning that Anubis inside an iframe has a different set of cookies than the domain hosting the iframe. |
| `DIFFICULTY` | `5` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |
| `ED25519_PRIVATE_KEY_HEX` | | The hex-encoded ed25519 private key used to sign Anubis responses. If this is not set, Anubis will generate one for you. This should be exactly 64 characters long. See below for details. |
| `METRICS_BIND` | `:9090` | The network address that Anubis serves Prometheus metrics on. See `BIND` for more information. |
Expand Down
40 changes: 22 additions & 18 deletions internal/test/playwright_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,6 @@ func startPlaywright(t *testing.T) {
}

func TestPlaywrightBrowser(t *testing.T) {
if os.Getenv("CI") == "true" {
t.Skip("XXX(Xe): This is broken in CI, will fix later")
}

if os.Getenv("DONT_USE_NETWORK") != "" {
t.Skip("test requires network egress")
return
Expand Down Expand Up @@ -225,12 +221,20 @@ func TestPlaywrightBrowser(t *testing.T) {
t.Skip("skipping hard challenge with deadline")
}

perfomedAction := executeTestCase(t, tc, typ, anubisURL)

var perfomedAction action
var err error
for i := 0; i < 5; i++ {
perfomedAction, err = executeTestCase(t, tc, typ, anubisURL)
if perfomedAction == tc.action {
Comment thread
Xe marked this conversation as resolved.
break
}
time.Sleep(time.Duration(i+1) * 250 * time.Millisecond)
}
if perfomedAction != tc.action {
t.Errorf("unexpected test result, expected %s, got %s", tc.action, perfomedAction)
} else {
t.Logf("test passed")
}
if err != nil {
t.Fatalf("test error: %v", err)
}
})
}
Expand All @@ -247,14 +251,14 @@ func buildBrowserConnect(name string) string {
return u.String()
}

func executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType, anubisURL string) action {
func executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType, anubisURL string) (action, error) {
deadline, _ := t.Deadline()

browser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{
ExposeNetwork: playwright.String("<loopback>"),
})
if err != nil {
t.Fatalf("could not connect to remote browser: %v", err)
return "", fmt.Errorf("could not connect to remote browser: %w", err)
}
defer browser.Close()

Expand All @@ -266,13 +270,13 @@ func executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType, anub
UserAgent: playwright.String(tc.userAgent),
})
if err != nil {
t.Fatalf("could not create context: %v", err)
return "", fmt.Errorf("could not create context: %w", err)
}
defer ctx.Close()

page, err := ctx.NewPage()
if err != nil {
t.Fatalf("could not create page: %v", err)
return "", fmt.Errorf("could not create page: %w", err)
}
defer page.Close()

Expand All @@ -283,7 +287,7 @@ func executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType, anub
Timeout: pwTimeout(tc, deadline),
})
if err != nil {
pwFail(t, page, "could not navigate to test server: %v", err)
return "", pwFail(t, page, "could not navigate to test server: %v", err)
}

hadChallenge := false
Expand All @@ -294,7 +298,7 @@ func executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType, anub
hadChallenge = true
case actionDeny:
checkImage(t, tc, deadline, page, "#image[src*=sad]")
return actionDeny
return actionDeny, nil
}

// Ensure protected resource was provided.
Expand All @@ -317,9 +321,9 @@ func executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType, anub
}

if hadChallenge {
return actionChallenge
return actionChallenge, nil
} else {
return actionAllow
return actionAllow, nil
}
}

Expand All @@ -342,11 +346,11 @@ func checkImage(t *testing.T, tc testCase, deadline time.Time, page playwright.P
}
}

func pwFail(t *testing.T, page playwright.Page, format string, args ...any) {
func pwFail(t *testing.T, page playwright.Page, format string, args ...any) error {
t.Helper()

saveScreenshot(t, page)
t.Fatalf(format, args...)
return fmt.Errorf(format, args...)
}

func pwTimeout(tc testCase, deadline time.Time) *float64 {
Expand Down
3 changes: 3 additions & 0 deletions internal/test/var/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.png
*.txt
*.html
50 changes: 29 additions & 21 deletions lib/anubis.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ type Options struct {
Policy *policy.ParsedConfig
ServeRobotsTXT bool
PrivateKey ed25519.PrivateKey

CookieDomain string
CookieName string
CookiePartitioned bool
}

func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
Expand Down Expand Up @@ -108,6 +112,7 @@ func New(opts Options) (*Server, error) {
priv: opts.PrivateKey,
pub: opts.PrivateKey.Public().(ed25519.PublicKey),
policy: opts.Policy,
opts: opts,
DNSBLCache: decaymap.New[string, dnsbl.DroneBLResponse](),
}

Expand Down Expand Up @@ -145,6 +150,7 @@ type Server struct {
priv ed25519.PrivateKey
pub ed25519.PublicKey
policy *policy.ParsedConfig
opts Options
DNSBLCache *decaymap.Impl[string, dnsbl.DroneBLResponse]
ChallengeDifficulty int
}
Expand Down Expand Up @@ -217,7 +223,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
s.next.ServeHTTP(w, r)
return
case config.RuleDeny:
ClearCookie(w)
s.ClearCookie(w)
lg.Info("explicit deny")
if rule == nil {
lg.Error("rule is nil, cannot calculate checksum")
Expand All @@ -236,29 +242,29 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
case config.RuleChallenge:
lg.Debug("challenge requested")
default:
ClearCookie(w)
s.ClearCookie(w)
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
return
}

ckie, err := r.Cookie(anubis.CookieName)
if err != nil {
lg.Debug("cookie not found", "path", r.URL.Path)
ClearCookie(w)
s.ClearCookie(w)
s.RenderIndex(w, r)
return
}

if err := ckie.Valid(); err != nil {
lg.Debug("cookie is invalid", "err", err)
ClearCookie(w)
s.ClearCookie(w)
s.RenderIndex(w, r)
return
}

if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
lg.Debug("cookie expired", "path", r.URL.Path)
ClearCookie(w)
s.ClearCookie(w)
s.RenderIndex(w, r)
return
}
Expand All @@ -269,7 +275,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {

if err != nil || !token.Valid {
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
ClearCookie(w)
s.ClearCookie(w)
s.RenderIndex(w, r)
return
}
Expand All @@ -284,15 +290,15 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
lg.Debug("invalid token claims type", "path", r.URL.Path)
ClearCookie(w)
s.ClearCookie(w)
s.RenderIndex(w, r)
return
}
challenge := s.challengeFor(r, rule.Challenge.Difficulty)

if claims["challenge"] != challenge {
lg.Debug("invalid challenge", "path", r.URL.Path)
ClearCookie(w)
s.ClearCookie(w)
s.RenderIndex(w, r)
return
}
Expand All @@ -309,7 +315,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(claims["response"].(string)), []byte(calculated)) != 1 {
lg.Debug("invalid response", "path", r.URL.Path)
failedValidations.Inc()
ClearCookie(w)
s.ClearCookie(w)
s.RenderIndex(w, r)
return
}
Expand Down Expand Up @@ -372,23 +378,23 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {

nonceStr := r.FormValue("nonce")
if nonceStr == "" {
ClearCookie(w)
s.ClearCookie(w)
lg.Debug("no nonce")
templ.Handler(web.Base("Oh noes!", web.ErrorPage("missing nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
return
}

elapsedTimeStr := r.FormValue("elapsedTime")
if elapsedTimeStr == "" {
ClearCookie(w)
s.ClearCookie(w)
lg.Debug("no elapsedTime")
templ.Handler(web.Base("Oh noes!", web.ErrorPage("missing elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
return
}

elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
if err != nil {
ClearCookie(w)
s.ClearCookie(w)
lg.Debug("elapsedTime doesn't parse", "err", err)
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
return
Expand All @@ -404,7 +410,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {

nonce, err := strconv.Atoi(nonceStr)
if err != nil {
ClearCookie(w)
s.ClearCookie(w)
lg.Debug("nonce doesn't parse", "err", err)
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
return
Expand All @@ -414,7 +420,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
calculated := internal.SHA256sum(calcString)

if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
ClearCookie(w)
s.ClearCookie(w)
lg.Debug("hash does not match", "got", response, "want", calculated)
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid response")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r)
failedValidations.Inc()
Expand All @@ -423,7 +429,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {

// compare the leading zeroes
if !strings.HasPrefix(response, strings.Repeat("0", s.ChallengeDifficulty)) {
ClearCookie(w)
s.ClearCookie(w)
lg.Debug("difficulty check failed", "response", response, "difficulty", s.ChallengeDifficulty)
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid response")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r)
failedValidations.Inc()
Expand All @@ -442,17 +448,19 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
tokenString, err := token.SignedString(s.priv)
if err != nil {
lg.Error("failed to sign JWT", "err", err)
ClearCookie(w)
s.ClearCookie(w)
templ.Handler(web.Base("Oh noes!", web.ErrorPage("failed to sign JWT")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
return
}

http.SetCookie(w, &http.Cookie{
Name: anubis.CookieName,
Value: tokenString,
Expires: time.Now().Add(24 * 7 * time.Hour),
SameSite: http.SameSiteLaxMode,
Path: "/",
Name: anubis.CookieName,
Value: tokenString,
Expires: time.Now().Add(24 * 7 * time.Hour),
SameSite: http.SameSiteLaxMode,
Domain: s.opts.CookieDomain,
Partitioned: s.opts.CookiePartitioned,
Path: "/",
})

challengesValidated.Inc()
Expand Down
Loading
Loading