diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index d022da2..994683c 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Refactor challenge presentation logic to use a challenge registry + ## v1.19.1: Jenomis cen Lexentale - Echo 1 Return `data/bots/ai-robots-txt.yaml` to avoid breaking configs [#599](https://github.com/TecharoHQ/anubis/issues/599) diff --git a/lib/anubis.go b/lib/anubis.go index 343dab4..06e780b 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -3,16 +3,14 @@ package lib import ( "crypto/ed25519" "crypto/sha256" - "crypto/subtle" "encoding/json" + "errors" "fmt" "log/slog" - "math" "net" "net/http" "net/url" "slices" - "strconv" "strings" "time" @@ -26,8 +24,12 @@ import ( "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal/dnsbl" "github.com/TecharoHQ/anubis/internal/ogtags" + "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/policy/config" + + // challenge implementations + _ "github.com/TecharoHQ/anubis/lib/challenge/proofofwork" ) var ( @@ -36,26 +38,20 @@ var ( Help: "The total number of challenges issued", }, []string{"method"}) - challengesValidated = promauto.NewCounter(prometheus.CounterOpts{ + challengesValidated = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "anubis_challenges_validated", Help: "The total number of challenges validated", - }) + }, []string{"method"}) droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "anubis_dronebl_hits", Help: "The total number of hits from DroneBL", }, []string{"status"}) - failedValidations = promauto.NewCounter(prometheus.CounterOpts{ + failedValidations = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "anubis_failed_validations", Help: "The total number of failed validations", - }) - - timeTaken = promauto.NewHistogram(prometheus.HistogramOpts{ - Name: "anubis_time_taken", - Help: "The time taken for a browser to generate a response (milliseconds)", - Buckets: prometheus.ExponentialBucketsRange(1, math.Pow(2, 18), 19), - }) + }, []string{"method"}) requestsProxied = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "anubis_proxied_requests_total", @@ -320,6 +316,14 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/" } + if _, err := r.Cookie(anubis.TestCookieName); err == http.ErrNoCookie { + s.ClearCookie(w, s.cookieName, cookiePath) + s.ClearCookie(w, anubis.TestCookieName, "/") + lg.Warn("user has cookies disabled, this is not an anubis bug") + s.respondWithError(w, r, "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain") + return + } + s.ClearCookie(w, anubis.TestCookieName, "/") redir := r.FormValue("redir") @@ -332,42 +336,6 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { // used by the path checker rule r.URL = redirURL - cr, rule, err := s.check(r) - if err != nil { - lg.Error("check failed", "err", err) - s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".") - return - } - lg = lg.With("check_result", cr) - - nonceStr := r.FormValue("nonce") - if nonceStr == "" { - s.ClearCookie(w, s.cookieName, cookiePath) - lg.Debug("no nonce") - s.respondWithError(w, r, "missing nonce") - return - } - - elapsedTimeStr := r.FormValue("elapsedTime") - if elapsedTimeStr == "" { - s.ClearCookie(w, s.cookieName, cookiePath) - lg.Debug("no elapsedTime") - s.respondWithError(w, r, "missing elapsedTime") - return - } - - elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64) - if err != nil { - s.ClearCookie(w, s.cookieName, cookiePath) - lg.Debug("elapsedTime doesn't parse", "err", err) - s.respondWithError(w, r, "invalid elapsedTime") - return - } - - lg.Info("challenge took", "elapsedTime", elapsedTime) - timeTaken.Observe(elapsedTime) - - response := r.FormValue("response") urlParsed, err := r.URL.Parse(redir) if err != nil { s.respondWithError(w, r, "Redirect URL not parseable") @@ -378,49 +346,44 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { return } - challenge := s.challengeFor(r, rule.Challenge.Difficulty) - - if _, err := r.Cookie(anubis.TestCookieName); err == http.ErrNoCookie { - s.ClearCookie(w, s.cookieName, cookiePath) - s.ClearCookie(w, anubis.TestCookieName, cookiePath) - lg.Warn("user has cookies disabled, this is not an anubis bug") - s.respondWithError(w, r, "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain") - return - } - - nonce, err := strconv.Atoi(nonceStr) + cr, rule, err := s.check(r) if err != nil { - s.ClearCookie(w, s.cookieName, cookiePath) - lg.Debug("nonce doesn't parse", "err", err) - s.respondWithError(w, r, "invalid response") + lg.Error("check failed", "err", err) + s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\"") + return + } + lg = lg.With("check_result", cr) + + impl, ok := challenge.Get(rule.Challenge.Algorithm) + if !ok { + lg.Error("check failed", "err", err) + s.respondWithError(w, r, fmt.Sprintf("Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to file a bug as Anubis is trying to use challenge method %s but it does not exist in the challenge registry", rule.Challenge.Algorithm)) return } - calcString := fmt.Sprintf("%s%d", challenge, nonce) - calculated := internal.SHA256sum(calcString) + challengeStr := s.challengeFor(r, rule.Challenge.Difficulty) - if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 { + if err := impl.Validate(r, lg, rule, challengeStr); err != nil { + failedValidations.WithLabelValues(string(rule.Challenge.Algorithm)).Inc() + var cerr *challenge.Error s.ClearCookie(w, s.cookieName, cookiePath) - lg.Debug("hash does not match", "got", response, "want", calculated) - s.respondWithStatus(w, r, "invalid response", http.StatusForbidden) - failedValidations.Inc() - return - } + lg.Debug("challenge validate call failed", "err", err) - // compare the leading zeroes - if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) { - s.ClearCookie(w, s.cookieName, cookiePath) - lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty) - s.respondWithStatus(w, r, "invalid response", http.StatusForbidden) - failedValidations.Inc() - return + switch { + case errors.As(err, &cerr): + switch { + case errors.Is(err, challenge.ErrFailed): + s.respondWithStatus(w, r, cerr.PublicReason, cerr.StatusCode) + case errors.Is(err, challenge.ErrInvalidFormat), errors.Is(err, challenge.ErrMissingField): + s.respondWithError(w, r, cerr.PublicReason) + } + } } // generate JWT cookie tokenString, err := s.signJWT(jwt.MapClaims{ - "challenge": challenge, - "nonce": nonceStr, - "response": response, + "challenge": challengeStr, + "method": rule.Challenge.Algorithm, "policyRule": rule.Hash(), "action": string(cr.Rule), }) @@ -433,7 +396,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { s.SetCookie(w, s.cookieName, tokenString, cookiePath) - challengesValidated.Inc() + challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc() lg.Debug("challenge passed, redirecting to app") http.Redirect(w, r, redir, http.StatusFound) } @@ -477,7 +440,7 @@ func (s *Server) check(r *http.Request) (policy.CheckResult, *policy.Bot, error) Challenge: &config.ChallengeRules{ Difficulty: s.policy.DefaultDifficulty, ReportAs: s.policy.DefaultDifficulty, - Algorithm: config.AlgorithmFast, + Algorithm: config.DefaultAlgorithm, }, Rules: &policy.CheckerList{}, }, nil diff --git a/lib/anubis_test.go b/lib/anubis_test.go index 2b963c5..45724c5 100644 --- a/lib/anubis_test.go +++ b/lib/anubis_test.go @@ -45,11 +45,11 @@ func spawnAnubis(t *testing.T, opts Options) *Server { return s } -type challenge struct { +type challengeResp struct { Challenge string `json:"challenge"` } -func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challenge { +func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challengeResp { t.Helper() req, err := http.NewRequest(http.MethodPost, ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", nil) @@ -67,7 +67,7 @@ func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challeng } defer resp.Body.Close() - var chall challenge + var chall challengeResp if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil { t.Fatalf("can't read challenge response body: %v", err) } @@ -75,7 +75,7 @@ func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challeng return chall } -func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.Client, chall challenge) *http.Response { +func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.Client, chall challengeResp) *http.Response { t.Helper() nonce := 0 @@ -420,7 +420,7 @@ func TestBasePrefix(t *testing.T) { t.Errorf("expected status code %d, got: %d", http.StatusOK, resp.StatusCode) } - var chall challenge + var chall challengeResp if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil { t.Fatalf("can't read challenge response body: %v", err) } diff --git a/lib/challenge/challenge.go b/lib/challenge/challenge.go new file mode 100644 index 0000000..25f3fd9 --- /dev/null +++ b/lib/challenge/challenge.go @@ -0,0 +1,47 @@ +package challenge + +import ( + "log/slog" + "net/http" + "sort" + "sync" + + "github.com/TecharoHQ/anubis/lib/policy" + "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 Impl interface { + Fail(w http.ResponseWriter, r *http.Request) error + Issue(r *http.Request, lg *slog.Logger, rule *policy.Bot, challenge string, ogTags map[string]string) (templ.Component, error) + Validate(r *http.Request, lg *slog.Logger, rule *policy.Bot, challenge string) error +} diff --git a/lib/challenge/error.go b/lib/challenge/error.go new file mode 100644 index 0000000..f968178 --- /dev/null +++ b/lib/challenge/error.go @@ -0,0 +1,37 @@ +package challenge + +import ( + "errors" + "fmt" + "net/http" +) + +var ( + ErrFailed = errors.New("challenge: user failed challenge") + ErrMissingField = errors.New("challenge: missing field") + ErrInvalidFormat = errors.New("challenge: field has invalid format") +) + +func NewError(verb, publicReason string, privateReason error) *Error { + return &Error{ + Verb: verb, + PublicReason: publicReason, + PrivateReason: privateReason, + StatusCode: http.StatusForbidden, + } +} + +type Error struct { + Verb string + PublicReason string + PrivateReason error + StatusCode int +} + +func (e *Error) Error() string { + return fmt.Sprintf("challenge: error when processing challenge: %s: %v", e.Verb, e.PrivateReason) +} + +func (e *Error) Unwrap() error { + return e.PrivateReason +} diff --git a/lib/challenge/metrics.go b/lib/challenge/metrics.go new file mode 100644 index 0000000..72b6574 --- /dev/null +++ b/lib/challenge/metrics.go @@ -0,0 +1,14 @@ +package challenge + +import ( + "math" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var TimeTaken = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "anubis_time_taken", + Help: "The time taken for a browser to generate a response (milliseconds)", + Buckets: prometheus.ExponentialBucketsRange(1, math.Pow(2, 20), 20), +}, []string{"method"}) diff --git a/lib/challenge/proofofwork/proofofwork.go b/lib/challenge/proofofwork/proofofwork.go new file mode 100644 index 0000000..c895e58 --- /dev/null +++ b/lib/challenge/proofofwork/proofofwork.go @@ -0,0 +1,83 @@ +package proofofwork + +import ( + "crypto/subtle" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/TecharoHQ/anubis/internal" + chall "github.com/TecharoHQ/anubis/lib/challenge" + "github.com/TecharoHQ/anubis/lib/policy" + "github.com/TecharoHQ/anubis/web" + "github.com/a-h/templ" +) + +func init() { + chall.Register("fast", &Impl{Algorithm: "fast"}) + chall.Register("slow", &Impl{Algorithm: "slow"}) +} + +type Impl struct { + Algorithm string +} + +func (i *Impl) Fail(w http.ResponseWriter, r *http.Request) error { + return nil +} + +func (i *Impl) Issue(r *http.Request, lg *slog.Logger, rule *policy.Bot, challenge string, ogTags map[string]string) (templ.Component, error) { + component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags) + if err != nil { + return nil, fmt.Errorf("can't render page: %w", err) + } + + return component, nil +} + +func (i *Impl) Validate(r *http.Request, lg *slog.Logger, rule *policy.Bot, challenge string) error { + nonceStr := r.FormValue("nonce") + if nonceStr == "" { + return chall.NewError("validate", "invalid response", fmt.Errorf("%w nonce", chall.ErrMissingField)) + } + + nonce, err := strconv.Atoi(nonceStr) + if err != nil { + return chall.NewError("validate", "invalid response", fmt.Errorf("%w: nonce: %w", chall.ErrInvalidFormat, err)) + + } + + elapsedTimeStr := r.FormValue("elapsedTime") + if elapsedTimeStr == "" { + return chall.NewError("validate", "invalid response", fmt.Errorf("%w elapsedTime", chall.ErrMissingField)) + } + + elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64) + if err != nil { + return chall.NewError("validate", "invalid response", fmt.Errorf("%w: elapsedTime: %w", chall.ErrInvalidFormat, err)) + } + + response := r.FormValue("response") + if response == "" { + return chall.NewError("validate", "invalid response", fmt.Errorf("%w response", chall.ErrMissingField)) + } + + calcString := fmt.Sprintf("%s%d", challenge, nonce) + calculated := internal.SHA256sum(calcString) + + if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 { + return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", chall.ErrFailed, calculated, response)) + } + + // compare the leading zeroes + if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) { + return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted %d leading zeros but got %s", chall.ErrFailed, rule.Challenge.Difficulty, response)) + } + + lg.Debug("challenge took", "elapsedTime", elapsedTime) + chall.TimeTaken.WithLabelValues(i.Algorithm).Observe(elapsedTime) + + return nil +} diff --git a/lib/challenge/proofofwork/proofofwork_test.go b/lib/challenge/proofofwork/proofofwork_test.go new file mode 100644 index 0000000..f1ecf0f --- /dev/null +++ b/lib/challenge/proofofwork/proofofwork_test.go @@ -0,0 +1,136 @@ +package proofofwork + +import ( + "errors" + "log/slog" + "net/http" + "testing" + + "github.com/TecharoHQ/anubis/lib/challenge" + "github.com/TecharoHQ/anubis/lib/policy" + "github.com/TecharoHQ/anubis/lib/policy/config" +) + +func mkRequest(t *testing.T, values map[string]string) *http.Request { + t.Helper() + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + if err != nil { + t.Fatal(err) + } + + q := req.URL.Query() + + for k, v := range values { + q.Set(k, v) + } + + req.URL.RawQuery = q.Encode() + + return req +} + +func TestBasic(t *testing.T) { + i := &Impl{Algorithm: "fast"} + bot := &policy.Bot{ + Challenge: &config.ChallengeRules{ + Algorithm: "fast", + Difficulty: 0, + ReportAs: 0, + }, + } + const challengeStr = "hunter" + const response = "2652bdba8fb4d2ab39ef28d8534d7694c557a4ae146c1e9237bd8d950280500e" + + for _, cs := range []struct { + name string + req *http.Request + err error + challengeStr string + }{ + { + name: "allgood", + req: mkRequest(t, map[string]string{ + "nonce": "0", + "elapsedTime": "69", + "response": response, + }), + err: nil, + challengeStr: challengeStr, + }, + { + name: "no-params", + req: mkRequest(t, map[string]string{}), + err: challenge.ErrMissingField, + challengeStr: challengeStr, + }, + { + name: "missing-nonce", + req: mkRequest(t, map[string]string{ + "elapsedTime": "69", + "response": response, + }), + err: challenge.ErrMissingField, + challengeStr: challengeStr, + }, + { + name: "missing-elapsedTime", + req: mkRequest(t, map[string]string{ + "nonce": "0", + "response": response, + }), + err: challenge.ErrMissingField, + challengeStr: challengeStr, + }, + { + name: "missing-response", + req: mkRequest(t, map[string]string{ + "nonce": "0", + "elapsedTime": "69", + }), + err: challenge.ErrMissingField, + challengeStr: challengeStr, + }, + { + name: "wrong-nonce-format", + req: mkRequest(t, map[string]string{ + "nonce": "taco", + "elapsedTime": "69", + "response": response, + }), + err: challenge.ErrInvalidFormat, + challengeStr: challengeStr, + }, + { + name: "wrong-elapsedTime-format", + req: mkRequest(t, map[string]string{ + "nonce": "0", + "elapsedTime": "taco", + "response": response, + }), + err: challenge.ErrInvalidFormat, + challengeStr: challengeStr, + }, + { + name: "invalid-response", + req: mkRequest(t, map[string]string{ + "nonce": "0", + "elapsedTime": "69", + "response": response, + }), + err: challenge.ErrFailed, + challengeStr: "Tacos are tasty", + }, + } { + t.Run(cs.name, func(t *testing.T) { + lg := slog.With() + + if _, err := i.Issue(cs.req, lg, bot, cs.challengeStr, nil); err != nil { + t.Errorf("can't issue challenge: %v", err) + } + + if err := i.Validate(cs.req, lg, bot, cs.challengeStr); !errors.Is(err, cs.err) { + t.Errorf("got wrong error from Validate, got %v but wanted %v", err, cs.err) + } + }) + } +} diff --git a/lib/config.go b/lib/config.go index 7d08e20..e64cb62 100644 --- a/lib/config.go +++ b/lib/config.go @@ -3,6 +3,7 @@ package lib import ( "crypto/ed25519" "crypto/rand" + "errors" "fmt" "io" "log/slog" @@ -17,6 +18,7 @@ import ( "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal/dnsbl" "github.com/TecharoHQ/anubis/internal/ogtags" + "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/web" "github.com/TecharoHQ/anubis/xess" @@ -65,6 +67,17 @@ func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedC }(fin) anubisPolicy, err := policy.ParseConfig(fin, fname, defaultDifficulty) + var validationErrs []error + + for _, b := range anubisPolicy.Bots { + if _, ok := challenge.Get(b.Challenge.Algorithm); !ok { + validationErrs = append(validationErrs, fmt.Errorf("%w %s", policy.ErrChallengeRuleHasWrongAlgorithm, b.Challenge.Algorithm)) + } + } + + if len(validationErrs) != 0 { + return nil, fmt.Errorf("can't do final validation of Anubis config: %w", errors.Join(validationErrs...)) + } return anubisPolicy, err } diff --git a/lib/config_test.go b/lib/config_test.go new file mode 100644 index 0000000..2927051 --- /dev/null +++ b/lib/config_test.go @@ -0,0 +1,51 @@ +package lib + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/TecharoHQ/anubis" + "github.com/TecharoHQ/anubis/lib/policy" +) + +func TestInvalidChallengeMethod(t *testing.T) { + if _, err := LoadPoliciesOrDefault("testdata/invalid-challenge-method.yaml", 4); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) { + t.Fatalf("wanted error %v but got %v", policy.ErrChallengeRuleHasWrongAlgorithm, err) + } +} + +func TestBadConfigs(t *testing.T) { + finfos, err := os.ReadDir("policy/config/testdata/bad") + if err != nil { + t.Fatal(err) + } + + for _, st := range finfos { + st := st + t.Run(st.Name(), func(t *testing.T) { + if _, err := LoadPoliciesOrDefault(filepath.Join("policy", "config", "testdata", "good", st.Name()), anubis.DefaultDifficulty); err == nil { + t.Fatal(err) + } else { + t.Log(err) + } + }) + } +} + +func TestGoodConfigs(t *testing.T) { + finfos, err := os.ReadDir("policy/config/testdata/good") + if err != nil { + t.Fatal(err) + } + + for _, st := range finfos { + st := st + t.Run(st.Name(), func(t *testing.T) { + if _, err := LoadPoliciesOrDefault(filepath.Join("policy", "config", "testdata", "good", st.Name()), anubis.DefaultDifficulty); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/lib/http.go b/lib/http.go index 401f67a..e2400a6 100644 --- a/lib/http.go +++ b/lib/http.go @@ -1,6 +1,7 @@ package lib import ( + "fmt" "math/rand" "net/http" "slices" @@ -9,6 +10,7 @@ import ( "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/internal" + "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/web" "github.com/a-h/templ" @@ -75,7 +77,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic } challengesIssued.WithLabelValues("embedded").Add(1) - challenge := s.challengeFor(r, rule.Challenge.Difficulty) + challengeStr := s.challengeFor(r, rule.Challenge.Difficulty) var ogTags map[string]string = nil if s.opts.OGPassthrough { @@ -88,14 +90,21 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic http.SetCookie(w, &http.Cookie{ Name: anubis.TestCookieName, - Value: challenge, + Value: challengeStr, Expires: time.Now().Add(30 * time.Minute), Path: "/", }) - component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags) + impl, ok := challenge.Get(rule.Challenge.Algorithm) + if !ok { + lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm) + s.respondWithError(w, r, fmt.Sprintf("Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to file a bug as Anubis is trying to use challenge method %s but it does not exist in the challenge registry", rule.Challenge.Algorithm)) + return + } + + component, err := impl.Issue(r, lg, rule, challengeStr, ogTags) if err != nil { - lg.Error("render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this. + lg.Error("[unexpected] render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this. s.respondWithError(w, r, "Internal Server Error: please contact the administrator and ask them to look for the logs around \"RenderIndex\"") return } diff --git a/lib/policy/config/config.go b/lib/policy/config/config.go index 7daa0b4..0bf46f7 100644 --- a/lib/policy/config/config.go +++ b/lib/policy/config/config.go @@ -42,13 +42,7 @@ const ( RuleBenchmark Rule = "DEBUG_BENCHMARK" ) -type Algorithm string - -const ( - AlgorithmUnknown Algorithm = "" - AlgorithmFast Algorithm = "fast" - AlgorithmSlow Algorithm = "slow" -) +const DefaultAlgorithm = "fast" type BotConfig struct { UserAgentRegex *string `json:"user_agent_regex"` @@ -170,15 +164,14 @@ func (b BotConfig) Valid() error { } type ChallengeRules struct { - Algorithm Algorithm `json:"algorithm"` - Difficulty int `json:"difficulty"` - ReportAs int `json:"report_as"` + Algorithm string `json:"algorithm"` + Difficulty int `json:"difficulty"` + ReportAs int `json:"report_as"` } var ( - ErrChallengeRuleHasWrongAlgorithm = errors.New("config.Bot.ChallengeRules: algorithm is invalid") - ErrChallengeDifficultyTooLow = errors.New("config.Bot.ChallengeRules: difficulty is too low (must be >= 1)") - ErrChallengeDifficultyTooHigh = errors.New("config.Bot.ChallengeRules: difficulty is too high (must be <= 64)") + ErrChallengeDifficultyTooLow = errors.New("config.Bot.ChallengeRules: difficulty is too low (must be >= 1)") + ErrChallengeDifficultyTooHigh = errors.New("config.Bot.ChallengeRules: difficulty is too high (must be <= 64)") ) func (cr ChallengeRules) Valid() error { @@ -192,13 +185,6 @@ func (cr ChallengeRules) Valid() error { errs = append(errs, fmt.Errorf("%w, got: %d", ErrChallengeDifficultyTooHigh, cr.Difficulty)) } - switch cr.Algorithm { - case AlgorithmFast, AlgorithmSlow, AlgorithmUnknown: - // do nothing, it's all good - default: - errs = append(errs, fmt.Errorf("%w: %q", ErrChallengeRuleHasWrongAlgorithm, cr.Algorithm)) - } - if len(errs) != 0 { return fmt.Errorf("config: challenge rules entry is not valid:\n%w", errors.Join(errs...)) } diff --git a/lib/policy/config/config_test.go b/lib/policy/config/config_test.go index 05515cd..be603b0 100644 --- a/lib/policy/config/config_test.go +++ b/lib/policy/config/config_test.go @@ -130,20 +130,6 @@ func TestBotValid(t *testing.T) { }, err: ErrChallengeDifficultyTooHigh, }, - { - name: "challenge wrong algorithm", - bot: BotConfig{ - Name: "mozilla-ua", - Action: RuleChallenge, - PathRegex: p("Mozilla"), - Challenge: &ChallengeRules{ - Difficulty: 420, - ReportAs: 4, - Algorithm: "high quality rips", - }, - }, - err: ErrChallengeRuleHasWrongAlgorithm, - }, { name: "invalid cidr range", bot: BotConfig{ @@ -361,7 +347,7 @@ func TestBotConfigZero(t *testing.T) { b.Challenge = &ChallengeRules{ Difficulty: 4, ReportAs: 4, - Algorithm: AlgorithmFast, + Algorithm: DefaultAlgorithm, } if b.Zero() { t.Error("BotConfig with challenge rules is zero value") diff --git a/lib/policy/policy.go b/lib/policy/policy.go index 1dfeafb..7183d63 100644 --- a/lib/policy/policy.go +++ b/lib/policy/policy.go @@ -5,10 +5,9 @@ import ( "fmt" "io" + "github.com/TecharoHQ/anubis/lib/policy/config" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - - "github.com/TecharoHQ/anubis/lib/policy/config" ) var ( @@ -16,6 +15,8 @@ var ( Name: "anubis_policy_results", Help: "The results of each policy rule", }, []string{"rule", "action"}) + + ErrChallengeRuleHasWrongAlgorithm = errors.New("config.Bot.ChallengeRules: algorithm is invalid") ) type ParsedConfig struct { @@ -107,12 +108,12 @@ func ParseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedCon parsedBot.Challenge = &config.ChallengeRules{ Difficulty: defaultDifficulty, ReportAs: defaultDifficulty, - Algorithm: config.AlgorithmFast, + Algorithm: "fast", } } else { parsedBot.Challenge = b.Challenge - if parsedBot.Challenge.Algorithm == config.AlgorithmUnknown { - parsedBot.Challenge.Algorithm = config.AlgorithmFast + if parsedBot.Challenge.Algorithm == "" { + parsedBot.Challenge.Algorithm = config.DefaultAlgorithm } } diff --git a/lib/testdata/hack-test.json b/lib/testdata/hack-test.json new file mode 100644 index 0000000..652dcd8 --- /dev/null +++ b/lib/testdata/hack-test.json @@ -0,0 +1,9 @@ +[ + { + "name": "ipv6-ula", + "action": "ALLOW", + "remote_addresses": [ + "fc00::/7" + ] + } +] \ No newline at end of file diff --git a/lib/testdata/hack-test.yaml b/lib/testdata/hack-test.yaml new file mode 100644 index 0000000..cd4d7d0 --- /dev/null +++ b/lib/testdata/hack-test.yaml @@ -0,0 +1,3 @@ +- name: well-known + path_regex: ^/.well-known/.*$ + action: ALLOW \ No newline at end of file diff --git a/lib/testdata/invalid-challenge-method.yaml b/lib/testdata/invalid-challenge-method.yaml new file mode 100644 index 0000000..24eccf7 --- /dev/null +++ b/lib/testdata/invalid-challenge-method.yaml @@ -0,0 +1,8 @@ +bots: + - name: generic-bot-catchall + user_agent_regex: (?i:bot|crawler) + action: CHALLENGE + challenge: + difficulty: 16 + report_as: 4 + algorithm: hunter2 # invalid algorithm