From def6f2dc9054358eb86fafef2f5c9345f433a311 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 2 Jul 2025 23:56:15 +0000 Subject: [PATCH] feat(lib): use new challenge creation flow Previously Anubis constructed challenge strings from request metadata. This was a good idea in spirit, but has turned out to be a very bad idea in practice. This new flow reuses the Store facility to dynamically create challenge values with completely random data. This is a fairly big rewrite of how Anubis processes challenges. Right now it defaults to using the in-memory storage backend, but on-disk (boltdb) and valkey-based adaptors will come soon. Signed-off-by: Xe Iaso --- lib/anubis.go | 98 ++++++++++++++----- lib/challenge/challenge.go | 68 ++----------- lib/challenge/challengetest/challengetest.go | 23 +++++ .../challengetest/challengetest_test.go | 7 ++ lib/challenge/interface.go | 68 +++++++++++++ lib/challenge/metarefresh/metarefresh.go | 10 +- lib/challenge/metarefresh/metarefresh.templ | 2 +- .../metarefresh/metarefresh_templ.go | 4 +- lib/challenge/proofofwork/proofofwork.go | 7 +- lib/challenge/proofofwork/proofofwork_test.go | 13 ++- lib/config.go | 1 + lib/http.go | 12 ++- lib/store/decaymap.go | 61 ------------ web/index_templ.go | 2 +- 14 files changed, 211 insertions(+), 165 deletions(-) create mode 100644 lib/challenge/challengetest/challengetest.go create mode 100644 lib/challenge/challengetest/challengetest_test.go create mode 100644 lib/challenge/interface.go delete mode 100644 lib/store/decaymap.go diff --git a/lib/anubis.go b/lib/anubis.go index f19ffd6b..9957a8d0 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -1,8 +1,9 @@ package lib import ( + "context" "crypto/ed25519" - "crypto/sha256" + "crypto/rand" "encoding/json" "errors" "fmt" @@ -16,6 +17,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/cel-go/common/types" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -30,6 +32,7 @@ import ( "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/policy/checker" "github.com/TecharoHQ/anubis/lib/policy/config" + "github.com/TecharoHQ/anubis/lib/store" // challenge implementations _ "github.com/TecharoHQ/anubis/lib/challenge/metarefresh" @@ -72,6 +75,7 @@ type Server struct { ed25519Priv ed25519.PrivateKey hs512Secret []byte opts Options + store store.Interface } func (s *Server) getTokenKeyfunc() jwt.Keyfunc { @@ -87,23 +91,50 @@ func (s *Server) getTokenKeyfunc() jwt.Keyfunc { } } -func (s *Server) challengeFor(r *http.Request, difficulty int) string { - var fp [32]byte - if len(s.hs512Secret) == 0 { - fp = sha256.Sum256(s.ed25519Priv.Public().(ed25519.PublicKey)[:]) - } else { - fp = sha256.Sum256(s.hs512Secret) +func (s *Server) challengeFor(r *http.Request) (*challenge.Challenge, error) { + ckies := r.CookiesNamed(anubis.TestCookieName) + + j := store.JSON[challenge.Challenge]{Underlying: s.store} + + for _, ckie := range ckies { + chall, err := j.Get(r.Context(), "challenge:"+ckie.Value) + if err != nil { + return nil, err + } + + return &chall, nil } - challengeData := fmt.Sprintf( - "X-Real-IP=%s,User-Agent=%s,WeekTime=%s,Fingerprint=%x,Difficulty=%d", - r.Header.Get("X-Real-Ip"), - r.UserAgent(), - time.Now().UTC().Round(24*7*time.Hour).Format(time.RFC3339), - fp, - difficulty, - ) - return internal.FastHash(challengeData) + return s.issueChallenge(r.Context(), r) +} + +func (s *Server) issueChallenge(ctx context.Context, r *http.Request) (*challenge.Challenge, error) { + id, err := uuid.NewV7() + if err != nil { + return nil, err + } + + var randomData = make([]byte, 256) + if _, err := rand.Read(randomData); err != nil { + return nil, err + } + + chall := challenge.Challenge{ + ID: id.String(), + RandomData: fmt.Sprintf("%x", randomData), + IssuedAt: time.Now(), + Metadata: map[string]string{ + "User-Agent": r.Header.Get("User-Agent"), + "X-Real-Ip": r.Header.Get("X-Real-Ip"), + }, + } + + j := store.JSON[challenge.Challenge]{Underlying: s.store} + if err := j.Set(ctx, "challenge:"+id.String(), chall, 5*time.Minute); err != nil { + return nil, err + } + + return &chall, err } func (s *Server) maybeReverseProxyHttpStatusOnly(w http.ResponseWriter, r *http.Request) { @@ -309,15 +340,30 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) { return } lg = lg.With("check_result", cr) - chal := s.challengeFor(r, rule.Challenge.Difficulty) - s.SetCookie(w, CookieOpts{Host: r.Host, Name: anubis.TestCookieName, Value: chal}) + chall, err := s.challengeFor(r) + if err != nil { + lg.Error("failed to fetch or issue challenge", "err", err) + w.WriteHeader(http.StatusInternalServerError) + err := encoder.Encode(struct { + Error string `json:"error"` + }{ + Error: fmt.Sprintf("%s \"makeChallenge\"", localizer.T("internal_server_error")), + }) + if err != nil { + lg.Error("failed to encode error response", "err", err) + w.WriteHeader(http.StatusInternalServerError) + } + return + } + + s.SetCookie(w, CookieOpts{Host: r.Host, Name: anubis.TestCookieName, Value: chall.ID}) err = encoder.Encode(struct { Rules *config.ChallengeRules `json:"rules"` Challenge string `json:"challenge"` }{ - Challenge: chal, + Challenge: chall.RandomData, Rules: rule.Challenge, }) if err != nil { @@ -325,7 +371,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - lg.Debug("made challenge", "challenge", chal, "rules", rule.Challenge, "cr", cr) + lg.Debug("made challenge", "challenge", chall, "rules", rule.Challenge, "cr", cr) challengesIssued.WithLabelValues("api").Inc() } @@ -384,10 +430,16 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { return } - challengeStr := s.challengeFor(r, rule.Challenge.Difficulty) + chall, err := s.challengeFor(r) + 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)) + } + in := &challenge.ValidateInput{ - Challenge: challengeStr, + Challenge: chall, Rule: rule, + Store: s.store, } if err := impl.Validate(r, lg, in); err != nil { @@ -409,7 +461,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { // generate JWT cookie tokenString, err := s.signJWT(jwt.MapClaims{ - "challenge": challengeStr, + "challenge": chall.ID, "method": rule.Challenge.Algorithm, "policyRule": rule.Hash(), "action": string(cr.Rule), diff --git a/lib/challenge/challenge.go b/lib/challenge/challenge.go index 3f427a2b..4c975c8e 100644 --- a/lib/challenge/challenge.go +++ b/lib/challenge/challenge.go @@ -1,65 +1,11 @@ package challenge -import ( - "log/slog" - "net/http" - "sort" - "sync" +import "time" - "github.com/TecharoHQ/anubis/lib/policy" - "github.com/TecharoHQ/anubis/lib/policy/config" - "github.com/a-h/templ" -) - -var ( - registry map[string]Impl = map[string]Impl{} - regLock sync.RWMutex -) - -func Register(name string, impl Impl) { - regLock.Lock() - defer regLock.Unlock() - - registry[name] = impl -} - -func Get(name string) (Impl, bool) { - regLock.RLock() - defer regLock.RUnlock() - result, ok := registry[name] - return result, ok -} - -func Methods() []string { - regLock.RLock() - defer regLock.RUnlock() - var result []string - for method := range registry { - result = append(result, method) - } - sort.Strings(result) - return result -} - -type IssueInput struct { - Impressum *config.Impressum - Rule *policy.Bot - Challenge string - OGTags map[string]string -} - -type ValidateInput struct { - Rule *policy.Bot - Challenge string -} - -type Impl interface { - // Setup registers any additional routes with the Impl for assets or API routes. - Setup(mux *http.ServeMux) - - // Issue a new challenge to the user, called by the Anubis. - Issue(r *http.Request, lg *slog.Logger, in *IssueInput) (templ.Component, error) - - // Validate a challenge, making sure that it passes muster. - Validate(r *http.Request, lg *slog.Logger, in *ValidateInput) error +// Challenge is the metadata about a single challenge issuance. +type Challenge struct { + ID string `json:"id"` // UUID identifying the challenge + RandomData string `json:"randomData"` // The random data the client processes + 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 } diff --git a/lib/challenge/challengetest/challengetest.go b/lib/challenge/challengetest/challengetest.go new file mode 100644 index 00000000..ba3d982a --- /dev/null +++ b/lib/challenge/challengetest/challengetest.go @@ -0,0 +1,23 @@ +package challengetest + +import ( + "testing" + "time" + + "github.com/TecharoHQ/anubis/internal" + "github.com/TecharoHQ/anubis/lib/challenge" + "github.com/google/uuid" +) + +func New(t *testing.T) *challenge.Challenge { + t.Helper() + + id := uuid.Must(uuid.NewV7()) + randomData := internal.SHA256sum(time.Now().String()) + + return &challenge.Challenge{ + ID: id.String(), + RandomData: randomData, + IssuedAt: time.Now(), + } +} diff --git a/lib/challenge/challengetest/challengetest_test.go b/lib/challenge/challengetest/challengetest_test.go new file mode 100644 index 00000000..8fb58509 --- /dev/null +++ b/lib/challenge/challengetest/challengetest_test.go @@ -0,0 +1,7 @@ +package challengetest + +import "testing" + +func TestNew(t *testing.T) { + _ = New(t) +} diff --git a/lib/challenge/interface.go b/lib/challenge/interface.go new file mode 100644 index 00000000..963d6ca1 --- /dev/null +++ b/lib/challenge/interface.go @@ -0,0 +1,68 @@ +package challenge + +import ( + "log/slog" + "net/http" + "sort" + "sync" + + "github.com/TecharoHQ/anubis/lib/policy" + "github.com/TecharoHQ/anubis/lib/policy/config" + "github.com/TecharoHQ/anubis/lib/store" + "github.com/a-h/templ" +) + +var ( + registry map[string]Impl = map[string]Impl{} + regLock sync.RWMutex +) + +func Register(name string, impl Impl) { + regLock.Lock() + defer regLock.Unlock() + + registry[name] = impl +} + +func Get(name string) (Impl, bool) { + regLock.RLock() + defer regLock.RUnlock() + result, ok := registry[name] + return result, ok +} + +func Methods() []string { + regLock.RLock() + defer regLock.RUnlock() + var result []string + for method := range registry { + result = append(result, method) + } + sort.Strings(result) + return result +} + +type IssueInput struct { + Impressum *config.Impressum + Rule *policy.Bot + Challenge *Challenge + OGTags map[string]string + Store store.Interface +} + +type ValidateInput struct { + Rule *policy.Bot + Challenge *Challenge + Store store.Interface +} + +type Impl interface { + // Setup registers any additional routes with the Impl for assets or API routes. + Setup(mux *http.ServeMux) + + // Issue a new challenge to the user, called by the Anubis. + Issue(r *http.Request, lg *slog.Logger, in *IssueInput) (templ.Component, error) + + // Validate a challenge, making sure that it passes muster. + Validate(r *http.Request, lg *slog.Logger, in *ValidateInput) error +} diff --git a/lib/challenge/metarefresh/metarefresh.go b/lib/challenge/metarefresh/metarefresh.go index 3cad0a66..db6fcc6c 100644 --- a/lib/challenge/metarefresh/metarefresh.go +++ b/lib/challenge/metarefresh/metarefresh.go @@ -31,11 +31,11 @@ func (i *Impl) Issue(r *http.Request, lg *slog.Logger, in *challenge.IssueInput) q := u.Query() q.Set("redir", r.URL.String()) - q.Set("challenge", in.Challenge) + q.Set("challenge", in.Challenge.RandomData) u.RawQuery = q.Encode() loc := localization.GetLocalizer(r) - component, err := web.BaseWithChallengeAndOGTags(loc.T("making_sure_not_bot"), page(in.Challenge, u.String(), in.Rule.Challenge.Difficulty, loc), in.Impressum, in.Challenge, in.Rule.Challenge, in.OGTags, loc) + component, err := web.BaseWithChallengeAndOGTags(loc.T("making_sure_not_bot"), page(u.String(), in.Rule.Challenge.Difficulty, loc), in.Impressum, in.Challenge.RandomData, in.Rule.Challenge, in.OGTags, loc) if err != nil { return nil, fmt.Errorf("can't render page: %w", err) @@ -45,12 +45,10 @@ func (i *Impl) Issue(r *http.Request, lg *slog.Logger, in *challenge.IssueInput) } func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error { - wantChallenge := in.Challenge - gotChallenge := r.FormValue("challenge") - if subtle.ConstantTimeCompare([]byte(wantChallenge), []byte(gotChallenge)) != 1 { - return challenge.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", challenge.ErrFailed, wantChallenge, gotChallenge)) + if subtle.ConstantTimeCompare([]byte(in.Challenge.RandomData), []byte(gotChallenge)) != 1 { + return challenge.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", challenge.ErrFailed, in.Challenge.RandomData, gotChallenge)) } return nil diff --git a/lib/challenge/metarefresh/metarefresh.templ b/lib/challenge/metarefresh/metarefresh.templ index e4549b6d..adb3cb14 100644 --- a/lib/challenge/metarefresh/metarefresh.templ +++ b/lib/challenge/metarefresh/metarefresh.templ @@ -7,7 +7,7 @@ import ( "github.com/TecharoHQ/anubis/lib/localization" ) -templ page(challenge, redir string, difficulty int, loc *localization.SimpleLocalizer) { +templ page(redir string, difficulty int, loc *localization.SimpleLocalizer) {