Repository: TecharoHQ/anubis (go)

Issue:
Error: can't get challenge
Hi,

When user first time open server, its crashed with 500 error with message what anubis is not configured properly.
If refresh page then it goes as it should

Logs:
```
{
  "time": "2025-07-20T19:37:48",
  "level": "ERROR",
  "source": {
    "function": "github.com/TecharoHQ/anubis/lib.(*Server).RenderIndex",
    "file": "/Users/cadey/Code/TecharoHQ/anubis-release/lib/http.go",
    "line": 133
  },
  "msg": "can't get challenge",
  "user_agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36",
  "accept_language": "en-US,en;q=0.9,ru;q=0.8",
  "priority": "",
  "x-forwarded-for": "",
  "x-real-ip": "{{REDACTED}}",
  "err": "err"
}
```
Happen with every user

Also impossible to restart service without killing the process, its just stuck

Additional information:
 - app v1.21.0
 - VPS
 - aarch64
 - systemd 252 (252.38-1~deb12u1)
 - service name: anubis@gitea.service

Pull request:
fix(lib): fix challenge issuance logic
Fixes #869

v1.21.0 changed the core challenge flow to maintain information about challenges on the server side instead of only doing them via stateless idempotent generation functions and relying on details to not change. There was a subtle bug introduced in this change: if a client has an unknown challenge ID set in its test cookie, Anubis will clear that cookie and then throw an HTTP 500 error.

This has been fixed by making Anubis throw a new challenge page instead.

<!--
delete me and describe your change here, give enough context for a maintainer to understand what and why

See https://anubis.techaro.lol/docs/developer/code-quality for more information
-->

Checklist:

- [x] Added a description of the changes to the `[Unreleased]` section of docs/docs/CHANGELOG.md
- [x] Added test cases to [the relevant parts of the codebase](https://anubis.techaro.lol/docs/developer/code-quality)
- [ ] Ran integration tests `npm run test:integration` (unsupported on Windows, please use WSL)


Patch:
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index c4779e4..c6eb1b8 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -26,6 +26,12 @@ Anubis now supports the [`missingHeader`](./admin/configuration/expressions.mdx#
 
 ### Fixes
 
+#### Fix ["error: can't get challenge"](https://github.com/TecharoHQ/anubis/issues/869) when details about a challenge can't be found in the server side state
+
+v1.21.0 changed the core challenge flow to maintain information about challenges on the server side instead of only doing them via stateless idempotent generation functions and relying on details to not change. There was a subtle bug introduced in this change: if a client has an unknown challenge ID set in its test cookie, Anubis will clear that cookie and then throw an HTTP 500 error.
+
+This has been fixed by making Anubis throw a new challenge page instead.
+
 #### Fix event loop thrashing when solving a proof of work challenge
 
 Previously the "fast" proof of work solver had a fragment of JavaScript that attempted to only post an update about proof of work progress to the main browser window every 1024 iterations. This fragment of JavaScript was subtly incorrect in a way that passed review but actually made the workers send an update back to the main thread every iteration. This caused a pileup of unhandled async calls (similar to a socket accept() backlog pileup in Unix) that caused stack space exhaustion.
diff --git a/lib/anubis.go b/lib/anubis.go
index e9435f9..2a6e520 100644
--- a/lib/anubis.go
+++ b/lib/anubis.go
@@ -102,6 +102,10 @@ func (s *Server) challengeFor(r *http.Request) (*challenge.Challenge, error) {
 	ckie := ckies[0]
 	chall, err := j.Get(r.Context(), "challenge:"+ckie.Value)
 	if err != nil {
+		if errors.Is(err, store.ErrNotFound) {
+			return s.issueChallenge(r.Context(), r)
+		}
+
 		return nil, err
 	}
 


Test patch:
diff --git a/lib/anubis_test.go b/lib/anubis_test.go
index 7ed0426..056793a 100644
--- a/lib/anubis_test.go
+++ b/lib/anubis_test.go
@@ -3,6 +3,7 @@ package lib
 import (
 	"encoding/json"
 	"fmt"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
@@ -736,3 +737,67 @@ func TestStripBasePrefixFromRequest(t *testing.T) {
 		})
 	}
 }
+
+// TestChallengeFor_ErrNotFound makes sure that users with invalid challenge IDs
+// in the test cookie don't get rejected by the database lookup failing.
+func TestChallengeFor_ErrNotFound(t *testing.T) {
+	pol := loadPolicies(t, "testdata/aggressive_403.yaml", 0)
+	ckieExpiration := 10 * time.Minute
+	const wrongCookie = "wrong cookie"
+
+	srv := spawnAnubis(t, Options{
+		Next:   http.NewServeMux(),
+		Policy: pol,
+
+		CookieDomain:     "127.0.0.1",
+		CookieExpiration: ckieExpiration,
+	})
+
+	req := httptest.NewRequest("GET", "http://example.com/", nil)
+	req.Header.Set("X-Real-IP", "127.0.0.1")
+	req.Header.Set("User-Agent", "CHALLENGE")
+	req.AddCookie(&http.Cookie{Name: anubis.TestCookieName, Value: wrongCookie})
+
+	w := httptest.NewRecorder()
+	srv.maybeReverseProxyOrPage(w, req)
+
+	resp := w.Result()
+	defer resp.Body.Close()
+
+	body := new(strings.Builder)
+	_, err := io.Copy(body, resp.Body)
+	if err != nil {
+		t.Fatalf("reading body should not fail: %v", err)
+	}
+
+	t.Run("make sure challenge page is issued", func(t *testing.T) {
+		if !strings.Contains(body.String(), "anubis_challenge") {
+			t.Error("should get a challenge page")
+		}
+
+		if resp.StatusCode != http.StatusUnauthorized {
+			t.Errorf("should get a 401 Unauthorized, got: %d", resp.StatusCode)
+		}
+	})
+
+	t.Run("make sure that the body is not an error page", func(t *testing.T) {
+		if strings.Contains(body.String(), "reject.webp") {
+			t.Error("should not get an internal server error")
+		}
+	})
+
+	t.Run("make sure new test cookie is issued", func(t *testing.T) {
+		found := false
+		for _, cookie := range resp.Cookies() {
+			if cookie.Name == anubis.TestCookieName {
+				if cookie.Value == wrongCookie {
+					t.Error("a new challenge cookie should be issued")
+				}
+				found = true
+			}
+		}
+		if !found {
+			t.Error("a new test cookie should be set")
+		}
+	})
+}