From 0e0847cbeb64c94a321c671c5f8f8fcd16f2f6f5 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 29 Aug 2025 16:09:27 -0400 Subject: [PATCH] feat: add 'proof of React' challenge (#1038) * feat: add 'proof of React' challenge Signed-off-by: Xe Iaso * fix(challenge/preact): use JSX fragments Signed-off-by: Xe Iaso * fix(challenge/preact): ensure that the client waits as long as it needs to Signed-off-by: Xe Iaso * docs: fix spelling Signed-off-by: Xe Iaso * fix(challenges/xeact): add noscript warning Signed-off-by: Xe Iaso * fix(challenges/xeact): add default loading message Signed-off-by: Xe Iaso * fix(challenges/xeact): make a UI render without JS Signed-off-by: Xe Iaso * fix(challenges/xeact): use %s here, not %w Signed-off-by: Xe Iaso * fix(test/healthcheck): run asset build Signed-off-by: Xe Iaso * fix(challenge/preact): fix build in ci Signed-off-by: Xe Iaso --------- Signed-off-by: Xe Iaso Signed-off-by: Xe Iaso --- .github/actions/spelling/allow.txt | 3 +- data/botPolicies.yaml | 17 ++- docs/docs/CHANGELOG.md | 1 + .../admin/configuration/challenges/preact.mdx | 19 +++ lib/anubis.go | 1 + lib/challenge/preact/build.sh | 49 +++++++ lib/challenge/preact/js/app.jsx | 62 +++++++++ lib/challenge/preact/js/xeact.js | 129 ++++++++++++++++++ lib/challenge/preact/preact.go | 74 ++++++++++ lib/challenge/preact/preact.templ | 28 ++++ lib/challenge/preact/preact_templ.go | 116 ++++++++++++++++ lib/challenge/preact/static/.gitignore | 1 + package-lock.json | 13 +- package.json | 3 +- test/git-clone/test.sh | 2 + test/git-push/test.sh | 2 + test/healthcheck/test.sh | 2 + 17 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 docs/docs/admin/configuration/challenges/preact.mdx create mode 100755 lib/challenge/preact/build.sh create mode 100644 lib/challenge/preact/js/app.jsx create mode 100644 lib/challenge/preact/js/xeact.js create mode 100644 lib/challenge/preact/preact.go create mode 100644 lib/challenge/preact/preact.templ create mode 100644 lib/challenge/preact/preact_templ.go create mode 100644 lib/challenge/preact/static/.gitignore diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index c220908..f8ebe12 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -4,4 +4,5 @@ ssh ubuntu workarounds rjack -msgbox \ No newline at end of file +msgbox +xeact \ No newline at end of file diff --git a/data/botPolicies.yaml b/data/botPolicies.yaml index dd83801..f62af35 100644 --- a/data/botPolicies.yaml +++ b/data/botPolicies.yaml @@ -211,6 +211,21 @@ thresholds: - weight >= 10 - weight < 20 action: CHALLENGE + challenge: + # https://anubis.techaro.lol/docs/admin/configuration/challenges/preact + # + # This challenge proves the client can run a webapp written with Preact. + # The preact webapp simply loads, calculates the SHA-256 checksum of the + # challenge data, and forwards that to the client. + algorithm: preact + difficulty: 1 + report_as: 1 + - name: mild-proof-of-work + expression: + all: + - weight >= 20 + - weight < 30 + action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast @@ -218,7 +233,7 @@ thresholds: report_as: 2 # For clients that are browser like and have gained many points from custom rules - name: extreme-suspicion - expression: weight >= 20 + expression: weight >= 30 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index 2d0d9a5..612cb6c 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +- Add a "proof of React" challenge to prove that the client is able to run a simple JSX app. - Added possibility to disable HTTP keep-alive to support backends not properly handling it - Added a missing link to the Caddy installation environment in the installation documentation. diff --git a/docs/docs/admin/configuration/challenges/preact.mdx b/docs/docs/admin/configuration/challenges/preact.mdx new file mode 100644 index 0000000..5721490 --- /dev/null +++ b/docs/docs/admin/configuration/challenges/preact.mdx @@ -0,0 +1,19 @@ +# Preact + +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. + +To use it in your Anubis configuration: + +```yaml +# Generic catchall rule +- name: generic-browser + user_agent_regex: >- + Mozilla|Opera + action: CHALLENGE + challenge: + difficulty: 1 # Number of seconds to wait before refreshing the page + report_as: 4 # Unused by this challenge method + algorithm: preact +``` + +This is the default challenge method for most clients. diff --git a/lib/anubis.go b/lib/anubis.go index 0e73b1e..1e2ccca 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -36,6 +36,7 @@ import ( // challenge implementations _ "github.com/TecharoHQ/anubis/lib/challenge/metarefresh" + _ "github.com/TecharoHQ/anubis/lib/challenge/preact" _ "github.com/TecharoHQ/anubis/lib/challenge/proofofwork" ) diff --git a/lib/challenge/preact/build.sh b/lib/challenge/preact/build.sh new file mode 100755 index 0000000..c7f2e3f --- /dev/null +++ b/lib/challenge/preact/build.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")" + +LICENSE='/* +@licstart The following is the entire license notice for the +JavaScript code in this page. + +Copyright (c) 2025 Xe Iaso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +Includes code from https://www.npmjs.com/package/preact which is used under +the terms of the MIT license. + +Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is +used under the terms of the Apache 2 license. + +@licend The above is the entire license notice +for the JavaScript code in this page. +*/' + +mkdir -p static/js + +for file in js/*.jsx; do + filename="${file##*/}" # Extracts "app.jsx" from "./js/app.jsx" + output="${filename%.jsx}.js" # Changes "app.jsx" to "app.js" + echo $output + + esbuild "${file}" --minify --bundle --outfile=static/"${output}" --banner:js="${LICENSE}" +done \ No newline at end of file diff --git a/lib/challenge/preact/js/app.jsx b/lib/challenge/preact/js/app.jsx new file mode 100644 index 0000000..f1321b8 --- /dev/null +++ b/lib/challenge/preact/js/app.jsx @@ -0,0 +1,62 @@ +import { render, h, Fragment } from 'preact'; +import { useState, useEffect } from 'preact/hooks'; +import { g, j, u, x } from "./xeact.js"; +import { Sha256 } from '@aws-crypto/sha256-js'; + +/** @jsx h */ +/** @jsxFrag Fragment */ + +function toHexString(arr) { + return Array.from(arr) + .map((c) => c.toString(16).padStart(2, "0")) + .join(""); +} + +const App = () => { + const [state, setState] = useState(null); + const [imageURL, setImageURL] = useState(null); + const [passed, setPassed] = useState(false); + const [challenge, setChallenge] = useState(null); + + useEffect(() => { + setState(j("preact_info")); + }); + + useEffect(() => { + setImageURL(state.pensive_url); + const hash = new Sha256(''); + hash.update(state.challenge); + setChallenge(toHexString(hash.digestSync())); + }, [state]); + + useEffect(() => { + const timer = setTimeout(() => { + setPassed(true); + }, state.difficulty * 100); + + return () => clearTimeout(timer); + }, [challenge]); + + useEffect(() => { + window.location.href = u(state.redir, { + result: challenge, + }); + }, [passed]); + + return ( + <> + {imageURL !== null && ( + + )} + {state !== null && ( + <> +

{state.loading_message}

+

{state.connection_security_message}

+ + )} + + ); +}; + +x(g("app")); +render(, g("app")); \ No newline at end of file diff --git a/lib/challenge/preact/js/xeact.js b/lib/challenge/preact/js/xeact.js new file mode 100644 index 0000000..cf4a102 --- /dev/null +++ b/lib/challenge/preact/js/xeact.js @@ -0,0 +1,129 @@ +/** + * Creates a DOM element, assigns the properties of `data` to it, and appends all `children`. + * + * @type{function(string|Function, Object=, Node|Array.=)} + */ +const h = (name, data = {}, children = []) => { + const result = + typeof name == "function" ? name(data) : Object.assign(document.createElement(name), data); + if (!Array.isArray(children)) { + children = [children]; + } + result.append(...children); + return result; +}; + +/** + * Create a text node. + * + * Equivalent to `document.createTextNode(text)` + * + * @type{function(string): Text} + */ +const t = (text) => document.createTextNode(text); + +/** + * Remove all child nodes from a DOM element. + * + * @type{function(Node)} + */ +const x = (elem) => { + while (elem.lastChild) { + elem.removeChild(elem.lastChild); + } +}; + +/** + * Get all elements with the given ID. + * + * Equivalent to `document.getElementById(name)` + * + * @type{function(string): HTMLElement} + */ +const g = (name) => document.getElementById(name); + +/** + * Get all elements with the given class name. + * + * Equivalent to `document.getElementsByClassName(name)` + * + * @type{function(string): HTMLCollectionOf.} + */ +const c = (name) => document.getElementsByClassName(name); + +/** @type{function(string): HTMLCollectionOf.} */ +const n = (name) => document.getElementsByName(name); + +/** + * Get all elements matching the given HTML selector. + * + * Matches selectors with `document.querySelectorAll(selector)` + * + * @type{function(string): Array.} + */ +const s = (selector) => Array.from(document.querySelectorAll(selector)); + +/** + * Generate a relative URL from `url`, appending all key-value pairs from `params` as URL-encoded parameters. + * + * @type{function(string=, Object=): string} + */ +const u = (url = "", params = {}) => { + let result = new URL(url, window.location.href); + Object.entries(params).forEach((kv) => { + let [k, v] = kv; + result.searchParams.set(k, v); + }); + return result.toString(); +}; + +/** + * Takes a callback to run when all DOM content is loaded. + * + * Equivalent to `window.addEventListener('DOMContentLoaded', callback)` + * + * @type{function(function())} + */ +const r = (callback) => window.addEventListener("DOMContentLoaded", callback); + +/** + * Allows a stateful value to be tracked by consumers. + * + * This is the Xeact version of the React useState hook. + * + * @type{function(any): [function(): any, function(any): void]} + */ +const useState = (value = undefined) => { + return [ + () => value, + (x) => { + value = x; + }, + ]; +}; + +/** + * Debounce an action for up to ms milliseconds. + * + * @type{function(number): function(function(any): void)} + */ +const d = (ms) => { + let debounceTimer = null; + return (f) => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(f, ms); + }; +}; + +/** + * Parse the contents of a given HTML page element as JSON and + * return the results. + * + * This is useful when using templ to pass complicated data from + * the server to the client via HTML[1]. + * + * [1]: https://templ.guide/syntax-and-usage/script-templates/#pass-server-side-data-to-the-client-in-a-html-attribute + */ +const j = (id) => JSON.parse(g(id).textContent); + +export { h, t, x, g, j, c, n, u, s, r, useState, d }; diff --git a/lib/challenge/preact/preact.go b/lib/challenge/preact/preact.go new file mode 100644 index 0000000..a01ba2e --- /dev/null +++ b/lib/challenge/preact/preact.go @@ -0,0 +1,74 @@ +package preact + +import ( + "context" + "crypto/subtle" + _ "embed" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/TecharoHQ/anubis" + "github.com/TecharoHQ/anubis/internal" + "github.com/TecharoHQ/anubis/lib/challenge" + "github.com/TecharoHQ/anubis/lib/localization" + "github.com/a-h/templ" +) + +//go:generate ./build.sh +//go:generate go tool github.com/a-h/templ/cmd/templ generate + +//go:embed static/app.js +var appJS []byte + +func renderAppJS(ctx context.Context, out io.Writer) error { + fmt.Fprint(out, `") + return nil +} + +func init() { + challenge.Register("preact", &impl{}) +} + +type impl struct{} + +func (i *impl) Setup(mux *http.ServeMux) {} + +func (i *impl) Issue(r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) { + u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge") + if err != nil { + return nil, fmt.Errorf("can't render page: %w", err) + } + + q := u.Query() + q.Set("redir", r.URL.String()) + q.Set("id", in.Challenge.ID) + u.RawQuery = q.Encode() + + loc := localization.GetLocalizer(r) + + result := page(u.String(), in.Challenge.RandomData, in.Rule.Challenge.Difficulty, loc) + + return result, nil +} + +func (i *impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error { + wantTime := in.Challenge.IssuedAt.Add(time.Duration(in.Rule.Challenge.Difficulty) * 95 * time.Millisecond) + + if time.Now().Before(wantTime) { + return challenge.NewError("validate", "insufficent time", fmt.Errorf("%w: wanted user to wait until at least %s", challenge.ErrFailed, wantTime.Format(time.RFC3339))) + } + + got := r.FormValue("result") + want := internal.SHA256sum(in.Challenge.RandomData) + + if subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 { + return challenge.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", challenge.ErrFailed, want, got)) + } + + return nil +} diff --git a/lib/challenge/preact/preact.templ b/lib/challenge/preact/preact.templ new file mode 100644 index 0000000..ee2cb98 --- /dev/null +++ b/lib/challenge/preact/preact.templ @@ -0,0 +1,28 @@ +package preact + +import ( + "github.com/TecharoHQ/anubis" + "github.com/TecharoHQ/anubis/lib/localization" +) + +templ page(redir, challenge string, difficulty int, loc *localization.SimpleLocalizer) { +
+
+ { loc.T("loading") }

+

{ loc.T("connection_security") }

+
+ @templ.JSONScript("preact_info", map[string]any{ + "redir": redir, + "challenge": challenge, + "difficulty": difficulty, + "connection_security_message": loc.T("connection_security"), + "loading_message": loc.T("loading"), + "pensive_url": anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version, + }) + @templ.ComponentFunc(renderAppJS) + +
+} diff --git a/lib/challenge/preact/preact_templ.go b/lib/challenge/preact/preact_templ.go new file mode 100644 index 0000000..aa78c3c --- /dev/null +++ b/lib/challenge/preact/preact_templ.go @@ -0,0 +1,116 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.924 +package preact + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "github.com/TecharoHQ/anubis" + "github.com/TecharoHQ/anubis/lib/localization" +) + +func page(redir, challenge string, difficulty int, loc *localization.SimpleLocalizer) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T("loading")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `preact.templ`, Line: 12, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T("connection_security")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `preact.templ`, Line: 13, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.JSONScript("preact_info", map[string]any{ + "redir": redir, + "challenge": challenge, + "difficulty": difficulty, + "connection_security_message": loc.T("connection_security"), + "loading_message": loc.T("loading"), + "pensive_url": anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version, + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.ComponentFunc(renderAppJS).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/lib/challenge/preact/static/.gitignore b/lib/challenge/preact/static/.gitignore new file mode 100644 index 0000000..f9602e9 --- /dev/null +++ b/lib/challenge/preact/static/.gitignore @@ -0,0 +1 @@ +app.js \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 57a4adc..e6d79b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.22.0-pre1", "license": "ISC", "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0" + "@aws-crypto/sha256-js": "^5.2.0", + "preact": "^10.27.1" }, "devDependencies": { "cssnano": "^7.1.1", @@ -2326,6 +2327,16 @@ "postcss": "^8.4.32" } }, + "node_modules/preact": { + "version": "10.27.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.1.tgz", + "integrity": "sha512-V79raXEWch/rbqoNc7nT9E4ep7lu+mI3+sBmfRD4i1M73R3WLYcCtdI0ibxGVf4eQL8ZIz2nFacqEC+rmnOORQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/pretty-hrtime": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", diff --git a/package.json b/package.json index 34813bc..6b6aff5 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "postcss-url": "^10.1.3" }, "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0" + "@aws-crypto/sha256-js": "^5.2.0", + "preact": "^10.27.1" } } \ No newline at end of file diff --git a/test/git-clone/test.sh b/test/git-clone/test.sh index 4e05574..94ad663 100755 --- a/test/git-clone/test.sh +++ b/test/git-clone/test.sh @@ -9,6 +9,8 @@ set -u ( cd ../.. && \ + npm ci && \ + npm run assets \ ko build --platform=all --base-import-paths --tags="latest" --image-user=1000 --image-annotation="" --image-label="" ./cmd/anubis -L ) diff --git a/test/git-push/test.sh b/test/git-push/test.sh index 132666b..35ed9fe 100755 --- a/test/git-push/test.sh +++ b/test/git-push/test.sh @@ -9,6 +9,8 @@ set -u ( cd ../.. && \ + npm ci && \ + npm run assets \ ko build --platform=all --base-import-paths --tags="latest" --image-user=1000 --image-annotation="" --image-label="" ./cmd/anubis -L ) diff --git a/test/healthcheck/test.sh b/test/healthcheck/test.sh index 2e45c2e..33f0a19 100755 --- a/test/healthcheck/test.sh +++ b/test/healthcheck/test.sh @@ -9,6 +9,8 @@ set -u ( cd ../.. && \ + npm ci && \ + npm run assets \ ko build --platform=all --base-import-paths --tags="latest" --image-user=1000 --image-annotation="" --image-label="" ./cmd/anubis -L )