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 <me@xeiaso.net>
This commit is contained in:
Xe Iaso
2025-07-21 22:39:30 +00:00
parent 31184ccd5f
commit 49b5d6e04c
3 changed files with 74 additions and 0 deletions

View File

@@ -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
}

View File

@@ -3,6 +3,7 @@ package lib
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
@@ -736,3 +737,66 @@ 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
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: "foogoblin"})
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 == "foogoblin" {
t.Error("a new challenge cookie should be issued")
}
found = true
}
}
if !found {
t.Error("a new test cookie should be set")
}
})
}