mirror of
https://github.com/TecharoHQ/anubis.git
synced 2025-08-04 02:08:59 -04:00
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:
parent
8feacc78fc
commit
4a4031450c
@ -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
|
||||||
|
|
||||||
|
@ -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"),
|
||||||
|
@ -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 &chall, err
|
||||||
return s.issueChallenge(r.Context(), r)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &chall, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) issueChallenge(ctx context.Context, r *http.Request) (*challenge.Challenge, error) {
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
11
lib/http.go
11
lib/http.go
@ -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})
|
||||||
|
Loading…
x
Reference in New Issue
Block a user