Skip to content

Commit 0e0847c

Browse files
authored
feat: add 'proof of React' challenge (#1038)
* feat: add 'proof of React' challenge Signed-off-by: Xe Iaso <[email protected]> * fix(challenge/preact): use JSX fragments Signed-off-by: Xe Iaso <[email protected]> * fix(challenge/preact): ensure that the client waits as long as it needs to Signed-off-by: Xe Iaso <[email protected]> * docs: fix spelling Signed-off-by: Xe Iaso <[email protected]> * fix(challenges/xeact): add noscript warning Signed-off-by: Xe Iaso <[email protected]> * fix(challenges/xeact): add default loading message Signed-off-by: Xe Iaso <[email protected]> * fix(challenges/xeact): make a UI render without JS Signed-off-by: Xe Iaso <[email protected]> * fix(challenges/xeact): use %s here, not %w Signed-off-by: Xe Iaso <[email protected]> * fix(test/healthcheck): run asset build Signed-off-by: Xe Iaso <[email protected]> * fix(challenge/preact): fix build in ci Signed-off-by: Xe Iaso <[email protected]> --------- Signed-off-by: Xe Iaso <[email protected]> Signed-off-by: Xe Iaso <[email protected]>
1 parent 00afa72 commit 0e0847c

File tree

17 files changed

+518
-4
lines changed

17 files changed

+518
-4
lines changed

.github/actions/spelling/allow.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ ssh
44
ubuntu
55
workarounds
66
rjack
7-
msgbox
7+
msgbox
8+
xeact

data/botPolicies.yaml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,14 +211,29 @@ thresholds:
211211
- weight >= 10
212212
- weight < 20
213213
action: CHALLENGE
214+
challenge:
215+
# https://anubis.techaro.lol/docs/admin/configuration/challenges/preact
216+
#
217+
# This challenge proves the client can run a webapp written with Preact.
218+
# The preact webapp simply loads, calculates the SHA-256 checksum of the
219+
# challenge data, and forwards that to the client.
220+
algorithm: preact
221+
difficulty: 1
222+
report_as: 1
223+
- name: mild-proof-of-work
224+
expression:
225+
all:
226+
- weight >= 20
227+
- weight < 30
228+
action: CHALLENGE
214229
challenge:
215230
# https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work
216231
algorithm: fast
217232
difficulty: 2 # two leading zeros, very fast for most clients
218233
report_as: 2
219234
# For clients that are browser like and have gained many points from custom rules
220235
- name: extreme-suspicion
221-
expression: weight >= 20
236+
expression: weight >= 30
222237
action: CHALLENGE
223238
challenge:
224239
# https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work

docs/docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
<!-- This changes the project to: -->
1515

16+
- Add a "proof of React" challenge to prove that the client is able to run a simple JSX app.
1617
- Added possibility to disable HTTP keep-alive to support backends not properly
1718
handling it
1819
- Added a missing link to the Caddy installation environment in the installation documentation.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Preact
2+
3+
The `preact` challenge sends the browser a simple challenge that makes it run very lightweight JavaScript that proves the client is able to execute client-side JavaScript. It uses [Preact](https://www.npmjs.com/package/preact) (a lightweight client side web framework in the vein of React) to do this.
4+
5+
To use it in your Anubis configuration:
6+
7+
```yaml
8+
# Generic catchall rule
9+
- name: generic-browser
10+
user_agent_regex: >-
11+
Mozilla|Opera
12+
action: CHALLENGE
13+
challenge:
14+
difficulty: 1 # Number of seconds to wait before refreshing the page
15+
report_as: 4 # Unused by this challenge method
16+
algorithm: preact
17+
```
18+
19+
This is the default challenge method for most clients.

lib/anubis.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636

3737
// challenge implementations
3838
_ "github.com/TecharoHQ/anubis/lib/challenge/metarefresh"
39+
_ "github.com/TecharoHQ/anubis/lib/challenge/preact"
3940
_ "github.com/TecharoHQ/anubis/lib/challenge/proofofwork"
4041
)
4142

lib/challenge/preact/build.sh

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
cd "$(dirname "$0")"
6+
7+
LICENSE='/*
8+
@licstart The following is the entire license notice for the
9+
JavaScript code in this page.
10+
11+
Copyright (c) 2025 Xe Iaso <[email protected]>
12+
13+
Permission is hereby granted, free of charge, to any person obtaining a copy
14+
of this software and associated documentation files (the "Software"), to deal
15+
in the Software without restriction, including without limitation the rights
16+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17+
copies of the Software, and to permit persons to whom the Software is
18+
furnished to do so, subject to the following conditions:
19+
20+
The above copyright notice and this permission notice shall be included in
21+
all copies or substantial portions of the Software.
22+
23+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
29+
THE SOFTWARE.
30+
31+
Includes code from https://www.npmjs.com/package/preact which is used under
32+
the terms of the MIT license.
33+
34+
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
35+
used under the terms of the Apache 2 license.
36+
37+
@licend The above is the entire license notice
38+
for the JavaScript code in this page.
39+
*/'
40+
41+
mkdir -p static/js
42+
43+
for file in js/*.jsx; do
44+
filename="${file##*/}" # Extracts "app.jsx" from "./js/app.jsx"
45+
output="${filename%.jsx}.js" # Changes "app.jsx" to "app.js"
46+
echo $output
47+
48+
esbuild "${file}" --minify --bundle --outfile=static/"${output}" --banner:js="${LICENSE}"
49+
done

lib/challenge/preact/js/app.jsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { render, h, Fragment } from 'preact';
2+
import { useState, useEffect } from 'preact/hooks';
3+
import { g, j, u, x } from "./xeact.js";
4+
import { Sha256 } from '@aws-crypto/sha256-js';
5+
6+
/** @jsx h */
7+
/** @jsxFrag Fragment */
8+
9+
function toHexString(arr) {
10+
return Array.from(arr)
11+
.map((c) => c.toString(16).padStart(2, "0"))
12+
.join("");
13+
}
14+
15+
const App = () => {
16+
const [state, setState] = useState(null);
17+
const [imageURL, setImageURL] = useState(null);
18+
const [passed, setPassed] = useState(false);
19+
const [challenge, setChallenge] = useState(null);
20+
21+
useEffect(() => {
22+
setState(j("preact_info"));
23+
});
24+
25+
useEffect(() => {
26+
setImageURL(state.pensive_url);
27+
const hash = new Sha256('');
28+
hash.update(state.challenge);
29+
setChallenge(toHexString(hash.digestSync()));
30+
}, [state]);
31+
32+
useEffect(() => {
33+
const timer = setTimeout(() => {
34+
setPassed(true);
35+
}, state.difficulty * 100);
36+
37+
return () => clearTimeout(timer);
38+
}, [challenge]);
39+
40+
useEffect(() => {
41+
window.location.href = u(state.redir, {
42+
result: challenge,
43+
});
44+
}, [passed]);
45+
46+
return (
47+
<>
48+
{imageURL !== null && (
49+
<img src={imageURL} style="width:100%;max-width:256px;" />
50+
)}
51+
{state !== null && (
52+
<>
53+
<p id="status">{state.loading_message}</p>
54+
<p>{state.connection_security_message}</p>
55+
</>
56+
)}
57+
</>
58+
);
59+
};
60+
61+
x(g("app"));
62+
render(<App />, g("app"));

lib/challenge/preact/js/xeact.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Creates a DOM element, assigns the properties of `data` to it, and appends all `children`.
3+
*
4+
* @type{function(string|Function, Object=, Node|Array.<Node|string>=)}
5+
*/
6+
const h = (name, data = {}, children = []) => {
7+
const result =
8+
typeof name == "function" ? name(data) : Object.assign(document.createElement(name), data);
9+
if (!Array.isArray(children)) {
10+
children = [children];
11+
}
12+
result.append(...children);
13+
return result;
14+
};
15+
16+
/**
17+
* Create a text node.
18+
*
19+
* Equivalent to `document.createTextNode(text)`
20+
*
21+
* @type{function(string): Text}
22+
*/
23+
const t = (text) => document.createTextNode(text);
24+
25+
/**
26+
* Remove all child nodes from a DOM element.
27+
*
28+
* @type{function(Node)}
29+
*/
30+
const x = (elem) => {
31+
while (elem.lastChild) {
32+
elem.removeChild(elem.lastChild);
33+
}
34+
};
35+
36+
/**
37+
* Get all elements with the given ID.
38+
*
39+
* Equivalent to `document.getElementById(name)`
40+
*
41+
* @type{function(string): HTMLElement}
42+
*/
43+
const g = (name) => document.getElementById(name);
44+
45+
/**
46+
* Get all elements with the given class name.
47+
*
48+
* Equivalent to `document.getElementsByClassName(name)`
49+
*
50+
* @type{function(string): HTMLCollectionOf.<Element>}
51+
*/
52+
const c = (name) => document.getElementsByClassName(name);
53+
54+
/** @type{function(string): HTMLCollectionOf.<Element>} */
55+
const n = (name) => document.getElementsByName(name);
56+
57+
/**
58+
* Get all elements matching the given HTML selector.
59+
*
60+
* Matches selectors with `document.querySelectorAll(selector)`
61+
*
62+
* @type{function(string): Array.<HTMLElement>}
63+
*/
64+
const s = (selector) => Array.from(document.querySelectorAll(selector));
65+
66+
/**
67+
* Generate a relative URL from `url`, appending all key-value pairs from `params` as URL-encoded parameters.
68+
*
69+
* @type{function(string=, Object=): string}
70+
*/
71+
const u = (url = "", params = {}) => {
72+
let result = new URL(url, window.location.href);
73+
Object.entries(params).forEach((kv) => {
74+
let [k, v] = kv;
75+
result.searchParams.set(k, v);
76+
});
77+
return result.toString();
78+
};
79+
80+
/**
81+
* Takes a callback to run when all DOM content is loaded.
82+
*
83+
* Equivalent to `window.addEventListener('DOMContentLoaded', callback)`
84+
*
85+
* @type{function(function())}
86+
*/
87+
const r = (callback) => window.addEventListener("DOMContentLoaded", callback);
88+
89+
/**
90+
* Allows a stateful value to be tracked by consumers.
91+
*
92+
* This is the Xeact version of the React useState hook.
93+
*
94+
* @type{function(any): [function(): any, function(any): void]}
95+
*/
96+
const useState = (value = undefined) => {
97+
return [
98+
() => value,
99+
(x) => {
100+
value = x;
101+
},
102+
];
103+
};
104+
105+
/**
106+
* Debounce an action for up to ms milliseconds.
107+
*
108+
* @type{function(number): function(function(any): void)}
109+
*/
110+
const d = (ms) => {
111+
let debounceTimer = null;
112+
return (f) => {
113+
clearTimeout(debounceTimer);
114+
debounceTimer = setTimeout(f, ms);
115+
};
116+
};
117+
118+
/**
119+
* Parse the contents of a given HTML page element as JSON and
120+
* return the results.
121+
*
122+
* This is useful when using templ to pass complicated data from
123+
* the server to the client via HTML[1].
124+
*
125+
* [1]: https://templ.guide/syntax-and-usage/script-templates/#pass-server-side-data-to-the-client-in-a-html-attribute
126+
*/
127+
const j = (id) => JSON.parse(g(id).textContent);
128+
129+
export { h, t, x, g, j, c, n, u, s, r, useState, d };

lib/challenge/preact/preact.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package preact
2+
3+
import (
4+
"context"
5+
"crypto/subtle"
6+
_ "embed"
7+
"fmt"
8+
"io"
9+
"log/slog"
10+
"net/http"
11+
"time"
12+
13+
"github.com/TecharoHQ/anubis"
14+
"github.com/TecharoHQ/anubis/internal"
15+
"github.com/TecharoHQ/anubis/lib/challenge"
16+
"github.com/TecharoHQ/anubis/lib/localization"
17+
"github.com/a-h/templ"
18+
)
19+
20+
//go:generate ./build.sh
21+
//go:generate go tool github.com/a-h/templ/cmd/templ generate
22+
23+
//go:embed static/app.js
24+
var appJS []byte
25+
26+
func renderAppJS(ctx context.Context, out io.Writer) error {
27+
fmt.Fprint(out, `<script type="module">`)
28+
out.Write(appJS)
29+
fmt.Fprint(out, "</script>")
30+
return nil
31+
}
32+
33+
func init() {
34+
challenge.Register("preact", &impl{})
35+
}
36+
37+
type impl struct{}
38+
39+
func (i *impl) Setup(mux *http.ServeMux) {}
40+
41+
func (i *impl) Issue(r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) {
42+
u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge")
43+
if err != nil {
44+
return nil, fmt.Errorf("can't render page: %w", err)
45+
}
46+
47+
q := u.Query()
48+
q.Set("redir", r.URL.String())
49+
q.Set("id", in.Challenge.ID)
50+
u.RawQuery = q.Encode()
51+
52+
loc := localization.GetLocalizer(r)
53+
54+
result := page(u.String(), in.Challenge.RandomData, in.Rule.Challenge.Difficulty, loc)
55+
56+
return result, nil
57+
}
58+
59+
func (i *impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error {
60+
wantTime := in.Challenge.IssuedAt.Add(time.Duration(in.Rule.Challenge.Difficulty) * 95 * time.Millisecond)
61+
62+
if time.Now().Before(wantTime) {
63+
return challenge.NewError("validate", "insufficent time", fmt.Errorf("%w: wanted user to wait until at least %s", challenge.ErrFailed, wantTime.Format(time.RFC3339)))
64+
}
65+
66+
got := r.FormValue("result")
67+
want := internal.SHA256sum(in.Challenge.RandomData)
68+
69+
if subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 {
70+
return challenge.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", challenge.ErrFailed, want, got))
71+
}
72+
73+
return nil
74+
}

0 commit comments

Comments
 (0)