Repository: TecharoHQ/anubis
Pull request #607 (2025-06-02 22:56:13)

feat: implement challenge registry
Refactor to move around the challenge presentation/solving logic.

Checklist:

- [x] Added a description of the changes to the `[Unreleased]` section of docs/docs/CHANGELOG.md
- [x] Added test cases to [the relevant parts of the codebase](https://anubis.techaro.lol/docs/developer/code-quality)
- [x] Ran integration tests `npm run test:integration` (unsupported on Windows, please use WSL)



Patch:
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)
 
-	calcString := fmt.Sprintf("%s%d", challenge, nonce)
-	calculated := internal.SHA256sum(calcString)
-
-	if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
-		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()
+	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
 	}
 
-	// compare the leading zeroes
-	if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) {
+	challengeStr := s.challengeFor(r, rule.Challenge.Difficulty)
+
+	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("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty)
-		s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
-		failedValidations.Inc()
-		return
+		lg.Debug("challenge validate call failed", "err", err)
+
+		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/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/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/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/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
 			}
 		}
 


Test patch:
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/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_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/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/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