From 24e3746b0b95cb24886d067d9039a0e17f4fea6d Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Mon, 21 Jul 2025 18:53:59 -0400 Subject: [PATCH] fix(lib): fix challenge issuance logic (#881) * 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. Signed-off-by: Xe Iaso * test(lib): you win this time spell check Signed-off-by: Xe Iaso --------- Signed-off-by: Xe Iaso --- docs/docs/CHANGELOG.md | 6 ++++ lib/anubis.go | 4 +++ lib/anubis_test.go | 65 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) 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 } 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") + } + }) +}