fix(anubis): store the challenge method in the store (#924)

* fix(lib): reduce challenge string size

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(internal): add host, method, and path to request logs

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(anubis): log when challenges explicitly fail

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(lib): make challenge validation fully deterministic

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(anubis): nuke challengeFor function

Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: update changelog

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
Xe Iaso 2025-07-28 10:57:50 -04:00 committed by GitHub
parent 8feacc78fc
commit 4a4031450c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 46 additions and 32 deletions

View File

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The [Thoth client](https://anubis.techaro.lol/docs/admin/thoth) is now public in the repo instead of being an internal package. - The [Thoth client](https://anubis.techaro.lol/docs/admin/thoth) is now public in the repo instead of being an internal package.
- [Custom-AsyncHttpClient](https://github.com/AsyncHttpClient/async-http-client)'s default User-Agent has an increased weight by default ([#852](https://github.com/TecharoHQ/anubis/issues/852)). - [Custom-AsyncHttpClient](https://github.com/AsyncHttpClient/async-http-client)'s default User-Agent has an increased weight by default ([#852](https://github.com/TecharoHQ/anubis/issues/852)).
- The [`segments`](./admin/configuration/expressions.mdx#segments) function was added for splitting a path into its slash-separated segments. - The [`segments`](./admin/configuration/expressions.mdx#segments) function was added for splitting a path into its slash-separated segments.
- When issuing a challenge, Anubis stores information about that challenge into the store. That stored information is later used to validate challenge responses. This works around nondeterminism in bot rules. ([#917](https://github.com/TecharoHQ/anubis/issues/917))
## v1.21.3: Minfilia Warde - Echo 3 ## v1.21.3: Minfilia Warde - Echo 3

View File

@ -28,6 +28,9 @@ func InitSlog(level string) {
func GetRequestLogger(r *http.Request) *slog.Logger { func GetRequestLogger(r *http.Request) *slog.Logger {
return slog.With( return slog.With(
"host", r.Host,
"method", r.Method,
"path", r.URL.Path,
"user_agent", r.UserAgent(), "user_agent", r.UserAgent(),
"accept_language", r.Header.Get("Accept-Language"), "accept_language", r.Header.Get("Accept-Language"),
"priority", r.Header.Get("Priority"), "priority", r.Header.Get("Priority"),

View File

@ -90,41 +90,39 @@ func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
} }
} }
func (s *Server) challengeFor(r *http.Request) (*challenge.Challenge, error) { func (s *Server) getChallenge(r *http.Request) (*challenge.Challenge, error) {
ckies := r.CookiesNamed(anubis.TestCookieName) ckies := r.CookiesNamed(anubis.TestCookieName)
if len(ckies) == 0 { if len(ckies) == 0 {
return s.issueChallenge(r.Context(), r) return nil, store.ErrNotFound
} }
j := store.JSON[challenge.Challenge]{Underlying: s.store} j := store.JSON[challenge.Challenge]{Underlying: s.store}
ckie := ckies[0] ckie := ckies[0]
chall, err := j.Get(r.Context(), "challenge:"+ckie.Value) 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 return &chall, err
}
return &chall, nil
} }
func (s *Server) issueChallenge(ctx context.Context, r *http.Request) (*challenge.Challenge, error) { func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.Logger, cr policy.CheckResult, rule *policy.Bot) (*challenge.Challenge, error) {
if cr.Rule != config.RuleChallenge {
slog.Error("this should be impossible, asked to issue a challenge but the rule is not a challenge rule", "cr", cr, "rule", rule)
//return nil, errors.New("[unexpected] this codepath should be impossible, asked to issue a challenge for a non-challenge rule")
}
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var randomData = make([]byte, 256) var randomData = make([]byte, 64)
if _, err := rand.Read(randomData); err != nil { if _, err := rand.Read(randomData); err != nil {
return nil, err return nil, err
} }
chall := challenge.Challenge{ chall := challenge.Challenge{
ID: id.String(), ID: id.String(),
Method: rule.Challenge.Algorithm,
RandomData: fmt.Sprintf("%x", randomData), RandomData: fmt.Sprintf("%x", randomData),
IssuedAt: time.Now(), IssuedAt: time.Now(),
Metadata: map[string]string{ Metadata: map[string]string{
@ -138,6 +136,8 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request) (*challeng
return nil, err return nil, err
} }
lg.Info("new challenge issued", "challenge", id.String())
return &chall, err return &chall, err
} }
@ -185,21 +185,21 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
if err != nil { if err != nil {
lg.Debug("cookie not found", "path", r.URL.Path) lg.Debug("cookie not found", "path", r.URL.Path)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
if err := ckie.Valid(); err != nil { if err := ckie.Valid(); err != nil {
lg.Debug("cookie is invalid", "err", err) lg.Debug("cookie is invalid", "err", err)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() { if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
lg.Debug("cookie expired", "path", r.URL.Path) lg.Debug("cookie expired", "path", r.URL.Path)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
@ -208,7 +208,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
if err != nil || !token.Valid { if err != nil || !token.Valid {
lg.Debug("invalid token", "path", r.URL.Path, "err", err) lg.Debug("invalid token", "path", r.URL.Path, "err", err)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
@ -216,7 +216,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
if !ok { if !ok {
lg.Debug("invalid token claims type", "path", r.URL.Path) lg.Debug("invalid token claims type", "path", r.URL.Path)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
@ -224,14 +224,14 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
if !ok { if !ok {
lg.Debug("policyRule claim is not a string") lg.Debug("policyRule claim is not a string")
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
if policyRule != rule.Hash() { if policyRule != rule.Hash() {
lg.Debug("user originally passed with a different rule, issuing new challenge", "old", policyRule, "new", rule.Name) lg.Debug("user originally passed with a different rule, issuing new challenge", "old", policyRule, "new", rule.Name)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return return
} }
@ -346,7 +346,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
} }
lg = lg.With("check_result", cr) lg = lg.With("check_result", cr)
chall, err := s.challengeFor(r) chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
if err != nil { if err != nil {
lg.Error("failed to fetch or issue challenge", "err", err) lg.Error("failed to fetch or issue challenge", "err", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@ -436,19 +436,21 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
} }
lg = lg.With("check_result", cr) lg = lg.With("check_result", cr)
impl, ok := challenge.Get(rule.Challenge.Algorithm) chall, err := s.getChallenge(r)
if err != nil {
lg.Error("getChallenge failed", "err", err)
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
return
}
impl, ok := challenge.Get(chall.Method)
if !ok { if !ok {
lg.Error("check failed", "err", err) lg.Error("check failed", "err", err)
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm)) s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
return return
} }
chall, err := s.challengeFor(r) lg = lg.With("challenge", chall.ID)
if err != nil {
lg.Error("check failed", "err", err)
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
return
}
in := &challenge.ValidateInput{ in := &challenge.ValidateInput{
Challenge: chall, Challenge: chall,
@ -466,9 +468,13 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
case errors.As(err, &cerr): case errors.As(err, &cerr):
switch { switch {
case errors.Is(err, challenge.ErrFailed): case errors.Is(err, challenge.ErrFailed):
lg.Error("challenge failed", "err", err)
s.respondWithStatus(w, r, cerr.PublicReason, cerr.StatusCode) s.respondWithStatus(w, r, cerr.PublicReason, cerr.StatusCode)
return
case errors.Is(err, challenge.ErrInvalidFormat), errors.Is(err, challenge.ErrMissingField): case errors.Is(err, challenge.ErrInvalidFormat), errors.Is(err, challenge.ErrMissingField):
lg.Error("invalid challenge format", "err", err)
s.respondWithError(w, r, cerr.PublicReason) s.respondWithError(w, r, cerr.PublicReason)
return
} }
} }
} }

View File

@ -5,6 +5,7 @@ import "time"
// Challenge is the metadata about a single challenge issuance. // Challenge is the metadata about a single challenge issuance.
type Challenge struct { type Challenge struct {
ID string `json:"id"` // UUID identifying the challenge ID string `json:"id"` // UUID identifying the challenge
Method string `json:"method"` // Challenge method
RandomData string `json:"randomData"` // The random data the client processes RandomData string `json:"randomData"` // The random data the client processes
IssuedAt time.Time `json:"issuedAt"` // When the challenge was issued IssuedAt time.Time `json:"issuedAt"` // When the challenge was issued
Metadata map[string]string `json:"metadata"` // Challenge metadata such as IP address and user agent Metadata map[string]string `json:"metadata"` // Challenge metadata such as IP address and user agent

View File

@ -111,7 +111,7 @@ func randomChance(n int) bool {
return rand.Intn(n) == 0 return rand.Intn(n) == 0
} }
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) { func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, rule *policy.Bot, returnHTTPStatusOnly bool) {
localizer := localization.GetLocalizer(r) localizer := localization.GetLocalizer(r)
if returnHTTPStatusOnly { if returnHTTPStatusOnly {
@ -125,17 +125,20 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) { if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) {
lg.Error("client was given a challenge but does not in fact support gzip compression") lg.Error("client was given a challenge but does not in fact support gzip compression")
s.respondWithError(w, r, localizer.T("client_error_browser")) s.respondWithError(w, r, localizer.T("client_error_browser"))
return
} }
challengesIssued.WithLabelValues("embedded").Add(1) challengesIssued.WithLabelValues("embedded").Add(1)
chall, err := s.challengeFor(r) chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
if err != nil { if err != nil {
lg.Error("can't get challenge", "err", "err") lg.Error("can't get challenge", "err", err)
s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host}) s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm)) s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
return return
} }
lg = lg.With("challenge", chall.ID)
var ogTags map[string]string = nil var ogTags map[string]string = nil
if s.opts.OpenGraph.Enabled { if s.opts.OpenGraph.Enabled {
var err error var err error
@ -153,7 +156,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
Expiry: 30 * time.Minute, Expiry: 30 * time.Minute,
}) })
impl, ok := challenge.Get(rule.Challenge.Algorithm) impl, ok := challenge.Get(chall.Method)
if !ok { if !ok {
lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm) lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm)
s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host}) s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})