Repository: TecharoHQ/anubis

Issue:
Double the challenge difficulty if the browser has "bot" in its user agent string


Discussion:
@Xe: Maybe also downgrade to the old slow PoW algo for clients with "bot" in the UA string?@zutto: Maybe the difficulty could be a configurable value in the bot policy?

Solution patch:
diff --git a/cmd/anubis/botPolicies.json b/cmd/anubis/botPolicies.json
index 4a6321b..b602ef7 100644
--- a/cmd/anubis/botPolicies.json
+++ b/cmd/anubis/botPolicies.json
@@ -65,6 +65,16 @@
       "user_agent_regex": "HeadlessChromium",
       "action": "DENY"
     },
+    {
+      "name": "generic-bot-catchall",
+      "user_agent_regex": "(?i:bot|crawler)",
+      "action": "CHALLENGE",
+      "challenge": {
+        "difficulty": 16,
+        "report_as": 4,
+        "algorithm": "slow"
+      }
+    },
     {
       "name": "generic-browser",
       "user_agent_regex": "Mozilla",
@@ -72,4 +82,4 @@
     }
   ],
   "dnsbl": true
-}
+}
\ No newline at end of file
diff --git a/cmd/anubis/internal/config/config.go b/cmd/anubis/internal/config/config.go
index ad338ef..efd8496 100644
--- a/cmd/anubis/internal/config/config.go
+++ b/cmd/anubis/internal/config/config.go
@@ -9,17 +9,26 @@ import (
 type Rule string
 
 const (
-	RuleUnknown   = ""
-	RuleAllow     = "ALLOW"
-	RuleDeny      = "DENY"
-	RuleChallenge = "CHALLENGE"
+	RuleUnknown   Rule = ""
+	RuleAllow     Rule = "ALLOW"
+	RuleDeny      Rule = "DENY"
+	RuleChallenge Rule = "CHALLENGE"
+)
+
+type Algorithm string
+
+const (
+	AlgorithmUnknown Algorithm = ""
+	AlgorithmFast    Algorithm = "fast"
+	AlgorithmSlow    Algorithm = "slow"
 )
 
 type Bot struct {
-	Name           string  `json:"name"`
-	UserAgentRegex *string `json:"user_agent_regex"`
-	PathRegex      *string `json:"path_regex"`
-	Action         Rule    `json:"action"`
+	Name           string          `json:"name"`
+	UserAgentRegex *string         `json:"user_agent_regex"`
+	PathRegex      *string         `json:"path_regex"`
+	Action         Rule            `json:"action"`
+	Challenge      *ChallengeRules `json:"challenge,omitempty"`
 }
 
 var (
@@ -66,6 +75,12 @@ func (b Bot) Valid() error {
 		errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownAction, b.Action))
 	}
 
+	if b.Action == RuleChallenge && b.Challenge != nil {
+		if err := b.Challenge.Valid(); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
 	if len(errs) != 0 {
 		return fmt.Errorf("config: bot entry for %q is not valid:\n%w", b.Name, errors.Join(errs...))
 	}
@@ -73,6 +88,43 @@ func (b Bot) Valid() error {
 	return nil
 }
 
+type ChallengeRules struct {
+	Difficulty int       `json:"difficulty"`
+	ReportAs   int       `json:"report_as"`
+	Algorithm  Algorithm `json:"algorithm"`
+}
+
+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)")
+)
+
+func (cr ChallengeRules) Valid() error {
+	var errs []error
+
+	if cr.Difficulty < 1 {
+		errs = append(errs, fmt.Errorf("%w, got: %d", ErrChallengeDifficultyTooLow, cr.Difficulty))
+	}
+
+	if cr.Difficulty > 64 {
+		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...))
+	}
+
+	return nil
+}
+
 type Config struct {
 	Bots  []Bot `json:"bots"`
 	DNSBL bool  `json:"dnsbl"`
diff --git a/cmd/anubis/js/main.mjs b/cmd/anubis/js/main.mjs
index fc85a44..297f16f 100644
--- a/cmd/anubis/js/main.mjs
+++ b/cmd/anubis/js/main.mjs
@@ -1,5 +1,11 @@
-import { process } from './proof-of-work.mjs';
-import { testVideo } from './video.mjs';
+import processFast from "./proof-of-work.mjs";
+import processSlow from "./proof-of-work-slow.mjs";
+import { testVideo } from "./video.mjs";
+
+const algorithms = {
+  "fast": processFast,
+  "slow": processSlow,
+}
 
 // from Xeact
 const u = (url = "", params = {}) => {
@@ -37,7 +43,7 @@ const imageURL = (mood, cacheBuster) =>
 
   status.innerHTML = 'Calculating...';
 
-  const { challenge, difficulty } = await fetch("/.within.website/x/cmd/anubis/api/make-challenge", { method: "POST" })
+  const { challenge, rules } = await fetch("/.within.website/x/cmd/anubis/api/make-challenge", { method: "POST" })
     .then(r => {
       if (!r.ok) {
         throw new Error("Failed to fetch config");
@@ -47,16 +53,26 @@ const imageURL = (mood, cacheBuster) =>
     .catch(err => {
       title.innerHTML = "Oh no!";
       status.innerHTML = `Failed to fetch config: ${err.message}`;
-      image.src = imageURL("sad");
+      image.src = imageURL("sad", anubisVersion);
       spinner.innerHTML = "";
       spinner.style.display = "none";
       throw err;
     });
 
-  status.innerHTML = `Calculating...<br/>Difficulty: ${difficulty}`;
+  const process = algorithms[rules.algorithm];
+  if (!process) {
+    title.innerHTML = "Oh no!";
+    status.innerHTML = `Failed to resolve check algorithm. You may want to reload the page.`;
+    image.src = imageURL("sad", anubisVersion);
+    spinner.innerHTML = "";
+    spinner.style.display = "none";
+    return;
+  }
+
+  status.innerHTML = `Calculating...<br/>Difficulty: ${rules.report_as}`;
 
   const t0 = Date.now();
-  const { hash, nonce } = await process(challenge, difficulty);
+  const { hash, nonce } = await process(challenge, rules.difficulty);
   const t1 = Date.now();
   console.log({ hash, nonce });
 
diff --git a/cmd/anubis/js/proof-of-work-slow.mjs b/cmd/anubis/js/proof-of-work-slow.mjs
new file mode 100644
index 0000000..e30dc21
--- /dev/null
+++ b/cmd/anubis/js/proof-of-work-slow.mjs
@@ -0,0 +1,63 @@
+// https://dev.to/ratmd/simple-proof-of-work-in-javascript-3kgm
+
+export default function process(data, difficulty = 5, _threads = 1) {
+  console.debug("slow algo");
+  return new Promise((resolve, reject) => {
+    let webWorkerURL = URL.createObjectURL(new Blob([
+      '(', processTask(), ')()'
+    ], { type: 'application/javascript' }));
+
+    let worker = new Worker(webWorkerURL);
+
+    worker.onmessage = (event) => {
+      worker.terminate();
+      resolve(event.data);
+    };
+
+    worker.onerror = (event) => {
+      worker.terminate();
+      reject();
+    };
+
+    worker.postMessage({
+      data,
+      difficulty
+    });
+
+    URL.revokeObjectURL(webWorkerURL);
+  });
+}
+
+function processTask() {
+  return function () {
+    const sha256 = (text) => {
+      const encoded = new TextEncoder().encode(text);
+      return crypto.subtle.digest("SHA-256", encoded.buffer)
+        .then((result) =>
+          Array.from(new Uint8Array(result))
+            .map((c) => c.toString(16).padStart(2, "0"))
+            .join(""),
+        );
+    };
+
+    addEventListener('message', async (event) => {
+      let data = event.data.data;
+      let difficulty = event.data.difficulty;
+
+      let hash;
+      let nonce = 0;
+      do {
+        hash = await sha256(data + nonce++);
+      } while (hash.substring(0, difficulty) !== Array(difficulty + 1).join('0'));
+
+      nonce -= 1; // last nonce was post-incremented
+
+      postMessage({
+        hash,
+        data,
+        difficulty,
+        nonce,
+      });
+    });
+  }.toString();
+}
\ No newline at end of file
diff --git a/cmd/anubis/js/proof-of-work.mjs b/cmd/anubis/js/proof-of-work.mjs
index 3125ae6..b4f9c53 100644
--- a/cmd/anubis/js/proof-of-work.mjs
+++ b/cmd/anubis/js/proof-of-work.mjs
@@ -1,6 +1,5 @@
-// https://dev.to/ratmd/simple-proof-of-work-in-javascript-3kgm
-
-export function process(data, difficulty = 5, threads = (navigator.hardwareConcurrency || 1)) {
+export default function process(data, difficulty = 5, threads = (navigator.hardwareConcurrency || 1)) {
+  console.debug("fast algo");
   return new Promise((resolve, reject) => {
     let webWorkerURL = URL.createObjectURL(new Blob([
       '(', processTask(), ')()'
diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go
index f2f7255..b92b591 100644
--- a/cmd/anubis/main.go
+++ b/cmd/anubis/main.go
@@ -44,7 +44,7 @@ import (
 var (
 	bind                = flag.String("bind", ":8923", "network address to bind HTTP to")
 	bindNetwork         = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
-	challengeDifficulty = flag.Int("difficulty", 4, "difficulty of the challenge")
+	challengeDifficulty = flag.Int("difficulty", defaultDifficulty, "difficulty of the challenge")
 	metricsBind         = flag.String("metrics-bind", ":9090", "network address to bind metrics to")
 	metricsBindNetwork  = flag.String("metrics-bind-network", "tcp", "network family for the metrics server to bind to")
 	socketMode          = flag.String("socket-mode", "0770", "socket mode (permissions) for unix domain sockets.")
@@ -85,8 +85,9 @@ var (
 )
 
 const (
-	cookieName = "within.website-x-cmd-anubis-auth"
-	staticPath = "/.within.website/x/cmd/anubis/"
+	cookieName        = "within.website-x-cmd-anubis-auth"
+	staticPath        = "/.within.website/x/cmd/anubis/"
+	defaultDifficulty = 4
 )
 
 //go:generate go tool github.com/a-h/templ/cmd/templ generate
@@ -261,7 +262,7 @@ func sha256sum(text string) (string, error) {
 	return hex.EncodeToString(hash.Sum(nil)), nil
 }
 
-func (s *Server) challengeFor(r *http.Request) string {
+func (s *Server) challengeFor(r *http.Request, difficulty int) string {
 	fp := sha256.Sum256(s.priv.Seed())
 
 	data := fmt.Sprintf(
@@ -271,7 +272,7 @@ func (s *Server) challengeFor(r *http.Request) string {
 		r.UserAgent(),
 		time.Now().UTC().Round(24*7*time.Hour).Format(time.RFC3339),
 		fp,
-		*challengeDifficulty,
+		difficulty,
 	)
 	result, _ := sha256sum(data)
 	return result
@@ -324,7 +325,7 @@ func New(target, policyFname string) (*Server, error) {
 
 	defer fin.Close()
 
-	policy, err := parseConfig(fin, policyFname)
+	policy, err := parseConfig(fin, policyFname, *challengeDifficulty)
 	if err != nil {
 		return nil, err // parseConfig sets a fancy error for us
 	}
@@ -485,7 +486,9 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if claims["challenge"] != s.challengeFor(r) {
+	challenge := s.challengeFor(r, rule.Challenge.Difficulty)
+
+	if claims["challenge"] != challenge {
 		lg.Debug("invalid challenge", "path", r.URL.Path)
 		clearCookie(w)
 		s.renderIndex(w, r)
@@ -498,7 +501,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request) {
 		nonce = int(v)
 	}
 
-	calcString := fmt.Sprintf("%s%d", s.challengeFor(r), nonce)
+	calcString := fmt.Sprintf("%s%d", challenge, nonce)
 	calculated, err := sha256sum(calcString)
 	if err != nil {
 		lg.Error("failed to calculate sha256sum", "path", r.URL.Path, "err", err)
@@ -527,24 +530,32 @@ func (s *Server) renderIndex(w http.ResponseWriter, r *http.Request) {
 }
 
 func (s *Server) makeChallenge(w http.ResponseWriter, r *http.Request) {
-	challenge := s.challengeFor(r)
-	difficulty := *challengeDifficulty
+	cr, rule := s.check(r)
+	challenge := s.challengeFor(r, rule.Challenge.Difficulty)
 
 	lg := slog.With("user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), "priority", r.Header.Get("Priority"), "x-forwarded-for", r.Header.Get("X-Forwarded-For"), "x-real-ip", r.Header.Get("X-Real-Ip"))
 
 	json.NewEncoder(w).Encode(struct {
-		Challenge  string `json:"challenge"`
-		Difficulty int    `json:"difficulty"`
+		Challenge string                 `json:"challenge"`
+		Rules     *config.ChallengeRules `json:"rules"`
 	}{
-		Challenge:  challenge,
-		Difficulty: difficulty,
+		Challenge: challenge,
+		Rules:     rule.Challenge,
 	})
-	lg.Debug("made challenge", "challenge", challenge, "difficulty", difficulty)
+	lg.Debug("made challenge", "challenge", challenge, "rules", rule.Challenge, "cr", cr)
 	challengesIssued.Inc()
 }
 
 func (s *Server) passChallenge(w http.ResponseWriter, r *http.Request) {
-	lg := slog.With("user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), "priority", r.Header.Get("Priority"), "x-forwarded-for", r.Header.Get("X-Forwarded-For"), "x-real-ip", r.Header.Get("X-Real-Ip"))
+	cr, rule := s.check(r)
+	lg := slog.With(
+		"user_agent", r.UserAgent(),
+		"accept_language", r.Header.Get("Accept-Language"),
+		"priority", r.Header.Get("Priority"),
+		"x-forwarded-for", r.Header.Get("X-Forwarded-For"),
+		"x-real-ip", r.Header.Get("X-Real-Ip"),
+		"cr", cr,
+	)
 
 	nonceStr := r.FormValue("nonce")
 	if nonceStr == "" {
@@ -576,7 +587,7 @@ func (s *Server) passChallenge(w http.ResponseWriter, r *http.Request) {
 	response := r.FormValue("response")
 	redir := r.FormValue("redir")
 
-	challenge := s.challengeFor(r)
+	challenge := s.challengeFor(r, rule.Challenge.Difficulty)
 
 	nonce, err := strconv.Atoi(nonceStr)
 	if err != nil {
diff --git a/cmd/anubis/policy.go b/cmd/anubis/policy.go
index f636349..a637f09 100644
--- a/cmd/anubis/policy.go
+++ b/cmd/anubis/policy.go
@@ -32,7 +32,8 @@ type Bot struct {
 	Name      string
 	UserAgent *regexp.Regexp
 	Path      *regexp.Regexp
-	Action    config.Rule `json:"action"`
+	Action    config.Rule
+	Challenge *config.ChallengeRules
 }
 
 func (b Bot) Hash() (string, error) {
@@ -48,7 +49,7 @@ func (b Bot) Hash() (string, error) {
 	return sha256sum(fmt.Sprintf("%s::%s::%s", b.Name, pathRex, userAgentRex))
 }
 
-func parseConfig(fin io.Reader, fname string) (*ParsedConfig, error) {
+func parseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedConfig, error) {
 	var c config.Config
 	if err := json.NewDecoder(fin).Decode(&c); err != nil {
 		return nil, fmt.Errorf("can't parse policy config JSON %s: %w", fname, err)
@@ -96,6 +97,19 @@ func parseConfig(fin io.Reader, fname string) (*ParsedConfig, error) {
 			}
 		}
 
+		if b.Challenge == nil {
+			parsedBot.Challenge = &config.ChallengeRules{
+				Difficulty: defaultDifficulty,
+				ReportAs:   defaultDifficulty,
+				Algorithm:  config.AlgorithmFast,
+			}
+		} else {
+			parsedBot.Challenge = b.Challenge
+			if parsedBot.Challenge.Algorithm == config.AlgorithmUnknown {
+				parsedBot.Challenge.Algorithm = config.AlgorithmFast
+			}
+		}
+
 		result.Bots = append(result.Bots, parsedBot)
 	}
 
@@ -142,5 +156,11 @@ func (s *Server) check(r *http.Request) (CheckResult, *Bot) {
 		}
 	}
 
-	return cr("default/allow", config.RuleAllow), nil
+	return cr("default/allow", config.RuleAllow), &Bot{
+		Challenge: &config.ChallengeRules{
+			Difficulty: defaultDifficulty,
+			ReportAs:   defaultDifficulty,
+			Algorithm:  config.AlgorithmFast,
+		},
+	}
 }
diff --git a/cmd/anubis/static/js/main.mjs b/cmd/anubis/static/js/main.mjs
index d1e1b86..a934289 100644
--- a/cmd/anubis/static/js/main.mjs
+++ b/cmd/anubis/static/js/main.mjs
@@ -1,2 +1,2 @@
-(()=>{function m(n,s=5,e=(navigator.hardwareConcurrency||1)){return new Promise((t,l)=>{let a=URL.createObjectURL(new Blob(["(",g(),")()"],{type:"application/javascript"})),r=[];for(let d=0;d<e;d++){let i=new Worker(a);i.onmessage=c=>{r.forEach(u=>u.terminate()),i.terminate(),t(c.data)},i.onerror=c=>{i.terminate(),l()},i.postMessage({data:n,difficulty:s,nonce:d,threads:e}),r.push(i)}URL.revokeObjectURL(a)})}function g(){return function(){let n=e=>{let t=new TextEncoder().encode(e);return crypto.subtle.digest("SHA-256",t.buffer)};function s(e){return Array.from(e).map(t=>t.toString(16).padStart(2,"0")).join("")}addEventListener("message",async e=>{let t=e.data.data,l=e.data.difficulty,a,r=e.data.nonce,d=e.data.threads;for(;;){let i=await n(t+r),c=new Uint8Array(i),u=!0;for(let o=0;o<l;o++){let f=Math.floor(o/2),p=o%2;if((c[f]>>(p===0?4:0)&15)!==0){u=!1;break}}if(u){a=s(c),console.log(a);break}r+=d}postMessage({hash:a,data:t,difficulty:l,nonce:r})})}.toString()}var w=(n="",s={})=>{let e=new URL(n,window.location.href);return Object.entries(s).forEach(t=>{let[l,a]=t;e.searchParams.set(l,a)}),e.toString()},h=(n,s)=>w(`/.within.website/x/cmd/anubis/static/img/${n}.webp`,{cacheBuster:s});(async()=>{let n=document.getElementById("status"),s=document.getElementById("image"),e=document.getElementById("title"),t=document.getElementById("spinner"),l=JSON.parse(document.getElementById("anubis_version").textContent);n.innerHTML="Calculating...";let{challenge:a,difficulty:r}=await fetch("/.within.website/x/cmd/anubis/api/make-challenge",{method:"POST"}).then(o=>{if(!o.ok)throw new Error("Failed to fetch config");return o.json()}).catch(o=>{throw e.innerHTML="Oh no!",n.innerHTML=`Failed to fetch config: ${o.message}`,s.src=h("sad"),t.innerHTML="",t.style.display="none",o});n.innerHTML=`Calculating...<br/>Difficulty: ${r}`;let d=Date.now(),{hash:i,nonce:c}=await m(a,r),u=Date.now();console.log({hash:i,nonce:c}),e.innerHTML="Success!",n.innerHTML=`Done! Took ${u-d}ms, ${c} iterations`,s.src=h("happy",l),t.innerHTML="",t.style.display="none",setTimeout(()=>{let o=window.location.href;window.location.href=w("/.within.website/x/cmd/anubis/api/pass-challenge",{response:i,nonce:c,redir:o,elapsedTime:u-d})},250)})();})();
+(()=>{function p(r,n=5,t=navigator.hardwareConcurrency||1){return console.debug("fast algo"),new Promise((e,o)=>{let s=URL.createObjectURL(new Blob(["(",y(),")()"],{type:"application/javascript"})),a=[];for(let i=0;i<t;i++){let c=new Worker(s);c.onmessage=d=>{a.forEach(u=>u.terminate()),c.terminate(),e(d.data)},c.onerror=d=>{c.terminate(),o()},c.postMessage({data:r,difficulty:n,nonce:i,threads:t}),a.push(c)}URL.revokeObjectURL(s)})}function y(){return function(){let r=t=>{let e=new TextEncoder().encode(t);return crypto.subtle.digest("SHA-256",e.buffer)};function n(t){return Array.from(t).map(e=>e.toString(16).padStart(2,"0")).join("")}addEventListener("message",async t=>{let e=t.data.data,o=t.data.difficulty,s,a=t.data.nonce,i=t.data.threads;for(;;){let c=await r(e+a),d=new Uint8Array(c),u=!0;for(let m=0;m<o;m++){let l=Math.floor(m/2),g=m%2;if((d[l]>>(g===0?4:0)&15)!==0){u=!1;break}}if(u){s=n(d),console.log(s);break}a+=i}postMessage({hash:s,data:e,difficulty:o,nonce:a})})}.toString()}function f(r,n=5,t=1){return console.debug("slow algo"),new Promise((e,o)=>{let s=URL.createObjectURL(new Blob(["(",b(),")()"],{type:"application/javascript"})),a=new Worker(s);a.onmessage=i=>{a.terminate(),e(i.data)},a.onerror=i=>{a.terminate(),o()},a.postMessage({data:r,difficulty:n}),URL.revokeObjectURL(s)})}function b(){return function(){let r=n=>{let t=new TextEncoder().encode(n);return crypto.subtle.digest("SHA-256",t.buffer).then(e=>Array.from(new Uint8Array(e)).map(o=>o.toString(16).padStart(2,"0")).join(""))};addEventListener("message",async n=>{let t=n.data.data,e=n.data.difficulty,o,s=0;do o=await r(t+s++);while(o.substring(0,e)!==Array(e+1).join("0"));s-=1,postMessage({hash:o,data:t,difficulty:e,nonce:s})})}.toString()}var L={fast:p,slow:f},w=(r="",n={})=>{let t=new URL(r,window.location.href);return Object.entries(n).forEach(e=>{let[o,s]=e;t.searchParams.set(o,s)}),t.toString()},h=(r,n)=>w(`/.within.website/x/cmd/anubis/static/img/${r}.webp`,{cacheBuster:n});(async()=>{let r=document.getElementById("status"),n=document.getElementById("image"),t=document.getElementById("title"),e=document.getElementById("spinner"),o=JSON.parse(document.getElementById("anubis_version").textContent);r.innerHTML="Calculating...";let{challenge:s,rules:a}=await fetch("/.within.website/x/cmd/anubis/api/make-challenge",{method:"POST"}).then(l=>{if(!l.ok)throw new Error("Failed to fetch config");return l.json()}).catch(l=>{throw t.innerHTML="Oh no!",r.innerHTML=`Failed to fetch config: ${l.message}`,n.src=h("sad",o),e.innerHTML="",e.style.display="none",l}),i=L[a.algorithm];if(!i){t.innerHTML="Oh no!",r.innerHTML="Failed to resolve check algorithm. You may want to reload the page.",n.src=h("sad",o),e.innerHTML="",e.style.display="none";return}r.innerHTML=`Calculating...<br/>Difficulty: ${a.report_as}`;let c=Date.now(),{hash:d,nonce:u}=await i(s,a.difficulty),m=Date.now();console.log({hash:d,nonce:u}),t.innerHTML="Success!",r.innerHTML=`Done! Took ${m-c}ms, ${u} iterations`,n.src=h("happy",o),e.innerHTML="",e.style.display="none",setTimeout(()=>{let l=window.location.href;window.location.href=w("/.within.website/x/cmd/anubis/api/pass-challenge",{response:d,nonce:u,redir:l,elapsedTime:m-c})},250)})();})();
 //# sourceMappingURL=main.mjs.map
diff --git a/cmd/anubis/static/js/main.mjs.br b/cmd/anubis/static/js/main.mjs.br
index a7b35e9..6bfd987 100644
Binary files a/cmd/anubis/static/js/main.mjs.br and b/cmd/anubis/static/js/main.mjs.br differ
diff --git a/cmd/anubis/static/js/main.mjs.gz b/cmd/anubis/static/js/main.mjs.gz
index e0b648c..0e8d735 100644
Binary files a/cmd/anubis/static/js/main.mjs.gz and b/cmd/anubis/static/js/main.mjs.gz differ
diff --git a/cmd/anubis/static/js/main.mjs.map b/cmd/anubis/static/js/main.mjs.map
index 3a0d564..7cad28b 100644
--- a/cmd/anubis/static/js/main.mjs.map
+++ b/cmd/anubis/static/js/main.mjs.map
@@ -1,7 +1,7 @@
 {
   "version": 3,
-  "sources": ["../../js/proof-of-work.mjs", "../../js/main.mjs"],
-  "sourcesContent": ["// https://dev.to/ratmd/simple-proof-of-work-in-javascript-3kgm\n\nexport function process(data, difficulty = 5, threads = navigator.hardwareConcurrency) {\n  return new Promise((resolve, reject) => {\n    let webWorkerURL = URL.createObjectURL(new Blob([\n      '(', processTask(), ')()'\n    ], { type: 'application/javascript' }));\n\n    const workers = [];\n\n    for (let i = 0; i < threads; i++) {\n      let worker = new Worker(webWorkerURL);\n\n      worker.onmessage = (event) => {\n        workers.forEach(worker => worker.terminate());\n        worker.terminate();\n        resolve(event.data);\n      };\n\n      worker.onerror = (event) => {\n        worker.terminate();\n        reject();\n      };\n\n      worker.postMessage({\n        data,\n        difficulty,\n        nonce: i,\n        threads,\n      });\n\n      workers.push(worker);\n    }\n\n    URL.revokeObjectURL(webWorkerURL);\n  });\n}\n\nfunction processTask() {\n  return function () {\n    const sha256 = (text) => {\n      const encoded = new TextEncoder().encode(text);\n      return crypto.subtle.digest(\"SHA-256\", encoded.buffer);\n    };\n\n    function uint8ArrayToHexString(arr) {\n      return Array.from(arr)\n        .map((c) => c.toString(16).padStart(2, \"0\"))\n        .join(\"\");\n    }\n\n    addEventListener('message', async (event) => {\n      let data = event.data.data;\n      let difficulty = event.data.difficulty;\n      let hash;\n      let nonce = event.data.nonce;\n      let threads = event.data.threads;\n\n      while (true) {\n        const currentHash = await sha256(data + nonce);\n        const thisHash = new Uint8Array(currentHash);\n        let valid = true;\n\n        for (let j = 0; j < difficulty; j++) {\n          const byteIndex = Math.floor(j / 2); // which byte we are looking at\n          const nibbleIndex = j % 2; // which nibble in the byte we are looking at (0 is high, 1 is low)\n\n          let nibble = (thisHash[byteIndex] >> (nibbleIndex === 0 ? 4 : 0)) & 0x0F; // Get the nibble\n\n          if (nibble !== 0) {\n            valid = false;\n            break;\n          }\n        }\n\n        if (valid) {\n          hash = uint8ArrayToHexString(thisHash);\n          console.log(hash);\n          break;\n        }\n\n        nonce += threads;\n      }\n\n      postMessage({\n        hash,\n        data,\n        difficulty,\n        nonce,\n      });\n    });\n  }.toString();\n}\n\n", "import { process } from './proof-of-work.mjs';\nimport { testVideo } from './video.mjs';\n\n// from Xeact\nconst u = (url = \"\", params = {}) => {\n  let result = new URL(url, window.location.href);\n  Object.entries(params).forEach((kv) => {\n    let [k, v] = kv;\n    result.searchParams.set(k, v);\n  });\n  return result.toString();\n};\n\nconst imageURL = (mood, cacheBuster) =>\n  u(`/.within.website/x/cmd/anubis/static/img/${mood}.webp`, { cacheBuster });\n\n(async () => {\n  const status = document.getElementById('status');\n  const image = document.getElementById('image');\n  const title = document.getElementById('title');\n  const spinner = document.getElementById('spinner');\n  const anubisVersion = JSON.parse(document.getElementById('anubis_version').textContent);\n\n  // const testarea = document.getElementById('testarea');\n\n  // const videoWorks = await testVideo(testarea);\n  // console.log(`videoWorks: ${videoWorks}`);\n\n  // if (!videoWorks) {\n  //   title.innerHTML = \"Oh no!\";\n  //   status.innerHTML = \"Checks failed. Please check your browser's settings and try again.\";\n  //   image.src = imageURL(\"sad\");\n  //   spinner.innerHTML = \"\";\n  //   spinner.style.display = \"none\";\n  //   return;\n  // }\n\n  status.innerHTML = 'Calculating...';\n\n  const { challenge, difficulty } = await fetch(\"/.within.website/x/cmd/anubis/api/make-challenge\", { method: \"POST\" })\n    .then(r => {\n      if (!r.ok) {\n        throw new Error(\"Failed to fetch config\");\n      }\n      return r.json();\n    })\n    .catch(err => {\n      title.innerHTML = \"Oh no!\";\n      status.innerHTML = `Failed to fetch config: ${err.message}`;\n      image.src = imageURL(\"sad\");\n      spinner.innerHTML = \"\";\n      spinner.style.display = \"none\";\n      throw err;\n    });\n\n  status.innerHTML = `Calculating...<br/>Difficulty: ${difficulty}`;\n\n  const t0 = Date.now();\n  const { hash, nonce } = await process(challenge, difficulty);\n  const t1 = Date.now();\n  console.log({ hash, nonce });\n\n  title.innerHTML = \"Success!\";\n  status.innerHTML = `Done! Took ${t1 - t0}ms, ${nonce} iterations`;\n  image.src = imageURL(\"happy\", anubisVersion);\n  spinner.innerHTML = \"\";\n  spinner.style.display = \"none\";\n\n  setTimeout(() => {\n    const redir = window.location.href;\n    window.location.href = u(\"/.within.website/x/cmd/anubis/api/pass-challenge\", { response: hash, nonce, redir, elapsedTime: t1 - t0 });\n  }, 250);\n})();"],
-  "mappings": "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",
-  "names": ["process", "data", "difficulty", "threads", "resolve", "reject", "webWorkerURL", "processTask", "workers", "i", "worker", "event", "sha256", "text", "encoded", "uint8ArrayToHexString", "arr", "c", "hash", "nonce", "currentHash", "thisHash", "valid", "j", "byteIndex", "nibbleIndex", "u", "url", "params", "result", "kv", "k", "v", "imageURL", "mood", "cacheBuster", "status", "image", "title", "spinner", "anubisVersion", "challenge", "difficulty", "r", "err", "t0", "hash", "nonce", "process", "t1", "redir"]
+  "sources": ["../../js/proof-of-work.mjs", "../../js/proof-of-work-slow.mjs", "../../js/main.mjs"],
+  "sourcesContent": ["export default function process(data, difficulty = 5, threads = (navigator.hardwareConcurrency || 1)) {\n  console.debug(\"fast algo\");\n  return new Promise((resolve, reject) => {\n    let webWorkerURL = URL.createObjectURL(new Blob([\n      '(', processTask(), ')()'\n    ], { type: 'application/javascript' }));\n\n    const workers = [];\n\n    for (let i = 0; i < threads; i++) {\n      let worker = new Worker(webWorkerURL);\n\n      worker.onmessage = (event) => {\n        workers.forEach(worker => worker.terminate());\n        worker.terminate();\n        resolve(event.data);\n      };\n\n      worker.onerror = (event) => {\n        worker.terminate();\n        reject();\n      };\n\n      worker.postMessage({\n        data,\n        difficulty,\n        nonce: i,\n        threads,\n      });\n\n      workers.push(worker);\n    }\n\n    URL.revokeObjectURL(webWorkerURL);\n  });\n}\n\nfunction processTask() {\n  return function () {\n    const sha256 = (text) => {\n      const encoded = new TextEncoder().encode(text);\n      return crypto.subtle.digest(\"SHA-256\", encoded.buffer);\n    };\n\n    function uint8ArrayToHexString(arr) {\n      return Array.from(arr)\n        .map((c) => c.toString(16).padStart(2, \"0\"))\n        .join(\"\");\n    }\n\n    addEventListener('message', async (event) => {\n      let data = event.data.data;\n      let difficulty = event.data.difficulty;\n      let hash;\n      let nonce = event.data.nonce;\n      let threads = event.data.threads;\n\n      while (true) {\n        const currentHash = await sha256(data + nonce);\n        const thisHash = new Uint8Array(currentHash);\n        let valid = true;\n\n        for (let j = 0; j < difficulty; j++) {\n          const byteIndex = Math.floor(j / 2); // which byte we are looking at\n          const nibbleIndex = j % 2; // which nibble in the byte we are looking at (0 is high, 1 is low)\n\n          let nibble = (thisHash[byteIndex] >> (nibbleIndex === 0 ? 4 : 0)) & 0x0F; // Get the nibble\n\n          if (nibble !== 0) {\n            valid = false;\n            break;\n          }\n        }\n\n        if (valid) {\n          hash = uint8ArrayToHexString(thisHash);\n          console.log(hash);\n          break;\n        }\n\n        nonce += threads;\n      }\n\n      postMessage({\n        hash,\n        data,\n        difficulty,\n        nonce,\n      });\n    });\n  }.toString();\n}\n\n", "// https://dev.to/ratmd/simple-proof-of-work-in-javascript-3kgm\n\nexport default function process(data, difficulty = 5, _threads = 1) {\n  console.debug(\"slow algo\");\n  return new Promise((resolve, reject) => {\n    let webWorkerURL = URL.createObjectURL(new Blob([\n      '(', processTask(), ')()'\n    ], { type: 'application/javascript' }));\n\n    let worker = new Worker(webWorkerURL);\n\n    worker.onmessage = (event) => {\n      worker.terminate();\n      resolve(event.data);\n    };\n\n    worker.onerror = (event) => {\n      worker.terminate();\n      reject();\n    };\n\n    worker.postMessage({\n      data,\n      difficulty\n    });\n\n    URL.revokeObjectURL(webWorkerURL);\n  });\n}\n\nfunction processTask() {\n  return function () {\n    const sha256 = (text) => {\n      const encoded = new TextEncoder().encode(text);\n      return crypto.subtle.digest(\"SHA-256\", encoded.buffer)\n        .then((result) =>\n          Array.from(new Uint8Array(result))\n            .map((c) => c.toString(16).padStart(2, \"0\"))\n            .join(\"\"),\n        );\n    };\n\n    addEventListener('message', async (event) => {\n      let data = event.data.data;\n      let difficulty = event.data.difficulty;\n\n      let hash;\n      let nonce = 0;\n      do {\n        hash = await sha256(data + nonce++);\n      } while (hash.substring(0, difficulty) !== Array(difficulty + 1).join('0'));\n\n      nonce -= 1; // last nonce was post-incremented\n\n      postMessage({\n        hash,\n        data,\n        difficulty,\n        nonce,\n      });\n    });\n  }.toString();\n}", "import processFast from \"./proof-of-work.mjs\";\nimport processSlow from \"./proof-of-work-slow.mjs\";\nimport { testVideo } from \"./video.mjs\";\n\nconst algorithms = {\n  \"fast\": processFast,\n  \"slow\": processSlow,\n}\n\n// from Xeact\nconst u = (url = \"\", params = {}) => {\n  let result = new URL(url, window.location.href);\n  Object.entries(params).forEach((kv) => {\n    let [k, v] = kv;\n    result.searchParams.set(k, v);\n  });\n  return result.toString();\n};\n\nconst imageURL = (mood, cacheBuster) =>\n  u(`/.within.website/x/cmd/anubis/static/img/${mood}.webp`, { cacheBuster });\n\n(async () => {\n  const status = document.getElementById('status');\n  const image = document.getElementById('image');\n  const title = document.getElementById('title');\n  const spinner = document.getElementById('spinner');\n  const anubisVersion = JSON.parse(document.getElementById('anubis_version').textContent);\n\n  // const testarea = document.getElementById('testarea');\n\n  // const videoWorks = await testVideo(testarea);\n  // console.log(`videoWorks: ${videoWorks}`);\n\n  // if (!videoWorks) {\n  //   title.innerHTML = \"Oh no!\";\n  //   status.innerHTML = \"Checks failed. Please check your browser's settings and try again.\";\n  //   image.src = imageURL(\"sad\");\n  //   spinner.innerHTML = \"\";\n  //   spinner.style.display = \"none\";\n  //   return;\n  // }\n\n  status.innerHTML = 'Calculating...';\n\n  const { challenge, rules } = await fetch(\"/.within.website/x/cmd/anubis/api/make-challenge\", { method: \"POST\" })\n    .then(r => {\n      if (!r.ok) {\n        throw new Error(\"Failed to fetch config\");\n      }\n      return r.json();\n    })\n    .catch(err => {\n      title.innerHTML = \"Oh no!\";\n      status.innerHTML = `Failed to fetch config: ${err.message}`;\n      image.src = imageURL(\"sad\", anubisVersion);\n      spinner.innerHTML = \"\";\n      spinner.style.display = \"none\";\n      throw err;\n    });\n\n  const process = algorithms[rules.algorithm];\n  if (!process) {\n    title.innerHTML = \"Oh no!\";\n    status.innerHTML = `Failed to resolve check algorithm. You may want to reload the page.`;\n    image.src = imageURL(\"sad\", anubisVersion);\n    spinner.innerHTML = \"\";\n    spinner.style.display = \"none\";\n    return;\n  }\n\n  status.innerHTML = `Calculating...<br/>Difficulty: ${rules.report_as}`;\n\n  const t0 = Date.now();\n  const { hash, nonce } = await process(challenge, rules.difficulty);\n  const t1 = Date.now();\n  console.log({ hash, nonce });\n\n  title.innerHTML = \"Success!\";\n  status.innerHTML = `Done! Took ${t1 - t0}ms, ${nonce} iterations`;\n  image.src = imageURL(\"happy\", anubisVersion);\n  spinner.innerHTML = \"\";\n  spinner.style.display = \"none\";\n\n  setTimeout(() => {\n    const redir = window.location.href;\n    window.location.href = u(\"/.within.website/x/cmd/anubis/api/pass-challenge\", { response: hash, nonce, redir, elapsedTime: t1 - t0 });\n  }, 250);\n})();"],
+  "mappings": "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",
+  "names": ["process", "data", "difficulty", "threads", "resolve", "reject", "webWorkerURL", "processTask", "workers", "worker", "event", "sha256", "text", "encoded", "uint8ArrayToHexString", "arr", "c", "hash", "nonce", "currentHash", "thisHash", "valid", "j", "byteIndex", "nibbleIndex", "process", "data", "difficulty", "_threads", "resolve", "reject", "webWorkerURL", "processTask", "worker", "event", "sha256", "text", "encoded", "result", "c", "hash", "nonce", "algorithms", "process", "u", "url", "params", "result", "kv", "k", "v", "imageURL", "mood", "cacheBuster", "status", "image", "title", "spinner", "anubisVersion", "challenge", "rules", "r", "err", "t0", "hash", "nonce", "t1", "redir"]
 }
diff --git a/cmd/anubis/static/js/main.mjs.zst b/cmd/anubis/static/js/main.mjs.zst
index 8903e1e..4ed5ca7 100644
Binary files a/cmd/anubis/static/js/main.mjs.zst and b/cmd/anubis/static/js/main.mjs.zst differ
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index 2b55261..a15af7b 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -11,6 +11,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+- Administrators can now define artificially hard challenges using the "slow" algorithm:
+
+  ```json
+  {
+    "name": "generic-bot-catchall",
+    "user_agent_regex": "(?i:bot|crawler)",
+    "action": "CHALLENGE",
+    "challenge": {
+      "difficulty": 16,
+      "report_as": 4,
+      "algorithm": "slow"
+    }
+  }
+  ```
+
+  This allows administrators to cause particularly malicious clients to use unreasonable amounts of CPU. The UI will also lie to the client about the difficulty.
+
 - Docker images now explicitly call `docker.io/library/<thing>` to increase compatibility with Podman et. al
   [#21](https://github.com/TecharoHQ/anubis/pull/21)
 - Don't overflow the image when browser windows are small (eg. on phones)
diff --git a/docs/docs/admin/algorithm-selection.mdx b/docs/docs/admin/algorithm-selection.mdx
new file mode 100644
index 0000000..e5bf962
--- /dev/null
+++ b/docs/docs/admin/algorithm-selection.mdx
@@ -0,0 +1,12 @@
+---
+title: Proof-of-Work Algorithm Selection
+---
+
+Anubis offers two proof-of-work algorithms:
+
+- `"fast"`: highly optimized JavaScript that will run as fast as your computer lets it
+- `"slow"`: intentionally slow JavaScript that will waste time and memory
+
+The fast algorithm is used by default to limit impacts on users' computers. Administrators may configure individual bot policy rules to use the slow algorithm in order to make known malicious clients waitloop and do nothing useful.
+
+Generally, you should use the fast algorithm unless you have a good reason not to.
diff --git a/docs/docs/admin/policies.md b/docs/docs/admin/policies.md
index bdf8a20..481a455 100644
--- a/docs/docs/admin/policies.md
+++ b/docs/docs/admin/policies.md
@@ -68,6 +68,29 @@ There are three actions that can be returned from a rule:
 
 Name your rules in lower case using kebab-case. Rule names will be exposed in Prometheus metrics.
 
+Rules can also have their own challenge settings. These are customized using the `"challenge"` key. For example, here is a rule that makes challenges artificially hard for connections with the substring "bot" in their user agent:
+
+```json
+{
+  "name": "generic-bot-catchall",
+  "user_agent_regex": "(?i:bot|crawler)",
+  "action": "CHALLENGE",
+  "challenge": {
+    "difficulty": 16,
+    "report_as": 4,
+    "algorithm": "slow"
+  }
+}
+```
+
+Challenges can be configured with these settings:
+
+| Key          | Example  | Description                                                                                                                                                                                    |
+| :----------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `difficulty` | `4`      | The challenge difficulty (number of leading zeros) for proof-of-work. See [Why does Anubis use Proof-of-Work?](/docs/design/why-proof-of-work) for more details.                               |
+| `report_as`  | `4`      | What difficulty the UI should report to the user. Useful for messing with industrial-scale scraping efforts.                                                                                   |
+| `algorithm`  | `"fast"` | The algorithm used on the client to run proof-of-work calculations. This must be set to `"fast"` or `"slow"`. See [Proof-of-Work Algorithm Selection](./algorithm-selection) for more details. |
+
 In case your service needs it for risk calculation reasons, Anubis exposes information about the rules that any requests match using a few headers:
 
 | Header            | Explanation                                          | Example          |



Test patch:
diff --git a/cmd/anubis/internal/config/config_test.go b/cmd/anubis/internal/config/config_test.go
index f362a76..0903fbb 100644
--- a/cmd/anubis/internal/config/config_test.go
+++ b/cmd/anubis/internal/config/config_test.go
@@ -87,6 +87,48 @@ func TestBotValid(t *testing.T) {
 			},
 			err: ErrInvalidPathRegex,
 		},
+		{
+			name: "challenge difficulty too low",
+			bot: Bot{
+				Name:      "mozilla-ua",
+				Action:    RuleChallenge,
+				PathRegex: p("Mozilla"),
+				Challenge: &ChallengeRules{
+					Difficulty: 0,
+					ReportAs:   4,
+					Algorithm:  "fast",
+				},
+			},
+			err: ErrChallengeDifficultyTooLow,
+		},
+		{
+			name: "challenge difficulty too high",
+			bot: Bot{
+				Name:      "mozilla-ua",
+				Action:    RuleChallenge,
+				PathRegex: p("Mozilla"),
+				Challenge: &ChallengeRules{
+					Difficulty: 420,
+					ReportAs:   4,
+					Algorithm:  "fast",
+				},
+			},
+			err: ErrChallengeDifficultyTooHigh,
+		},
+		{
+			name: "challenge wrong algorithm",
+			bot: Bot{
+				Name:      "mozilla-ua",
+				Action:    RuleChallenge,
+				PathRegex: p("Mozilla"),
+				Challenge: &ChallengeRules{
+					Difficulty: 420,
+					ReportAs:   4,
+					Algorithm:  "high quality rips",
+				},
+			},
+			err: ErrChallengeRuleHasWrongAlgorithm,
+		},
 	}
 
 	for _, cs := range tests {
diff --git a/cmd/anubis/policy_test.go b/cmd/anubis/policy_test.go
index 9a5699e..cf8ef2e 100644
--- a/cmd/anubis/policy_test.go
+++ b/cmd/anubis/policy_test.go
@@ -13,7 +13,7 @@ func TestDefaultPolicyMustParse(t *testing.T) {
 	}
 	defer fin.Close()
 
-	if _, err := parseConfig(fin, "botPolicies.json"); err != nil {
+	if _, err := parseConfig(fin, "botPolicies.json", defaultDifficulty); err != nil {
 		t.Fatalf("can't parse config: %v", err)
 	}
 }
@@ -33,7 +33,7 @@ func TestGoodConfigs(t *testing.T) {
 			}
 			defer fin.Close()
 
-			if _, err := parseConfig(fin, fin.Name()); err != nil {
+			if _, err := parseConfig(fin, fin.Name(), defaultDifficulty); err != nil {
 				t.Fatal(err)
 			}
 		})
@@ -55,7 +55,7 @@ func TestBadConfigs(t *testing.T) {
 			}
 			defer fin.Close()
 
-			if _, err := parseConfig(fin, fin.Name()); err == nil {
+			if _, err := parseConfig(fin, fin.Name(), defaultDifficulty); err == nil {
 				t.Fatal(err)
 			} else {
 				t.Log(err)