Repository: TecharoHQ/anubis (2025-07-21T22:45:06Z)

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


Discussion:
This is fixed in #881. Sorry about that!



Patch:
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index c4779e481..c6eb1b862 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 e9435f996..2a6e52087 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 7ed0426ff..056793a7f 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")
+		}
+	})
+}