From 45bc67fae4c8967b4192be45a8ec8279289eba73 Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 14:16:51 +1100 Subject: [PATCH 01/15] Move everything under paths like /.basedflare/ instead of putting stuff in paths where it might conflict Move templates to own file instead of in main lua script Rename some stuff from "hcatpcha" to more correct "captcha" and "bot-check" because we no longer only have hcaptcha Clean some code and add a few comments --- haproxy/haproxy.cfg | 26 ++-- haproxy/js/challenge.js | 9 +- haproxy/js/worker.js | 2 +- src/libs/utils.lua | 1 - src/scripts/{hcaptcha.lua => bot-check.lua} | 124 +++----------------- src/scripts/register.lua | 14 +-- src/scripts/templates.lua | 100 ++++++++++++++++ 7 files changed, 143 insertions(+), 133 deletions(-) rename src/scripts/{hcaptcha.lua => bot-check.lua} (73%) create mode 100644 src/scripts/templates.lua diff --git a/haproxy/haproxy.cfg b/haproxy/haproxy.cfg index ceaea01..807e2bd 100644 --- a/haproxy/haproxy.cfg +++ b/haproxy/haproxy.cfg @@ -43,15 +43,15 @@ frontend http-in acl is_existing_vhost hdr(host),lower,map_str(/etc/haproxy/hosts.map) -m found http-request silent-drop unless is_existing_vhost - # debug only, /cdn-cgi/trace - #http-request return status 200 content-type "text/plain; charset=utf-8" lf-file /etc/haproxy/trace.txt if { path /cdn-cgi/trace } + # debug information at /.basedflare/cgi/trace + http-request return status 200 content-type "text/plain; charset=utf-8" lf-file /etc/haproxy/trace.txt if { path /.basedflare/cgi/trace } # acl for blocked IPs/subnets acl blocked_ip_or_subnet src,map_ip(/etc/haproxy/blocked.map) -m found http-request deny deny_status 403 if blocked_ip_or_subnet # ratelimit (and for tor, kill circuit) on POST bot-check. legitimate users shouldn't hit this. - http-request track-sc0 src table bot_check_post_throttle if { path /bot-check } { method POST } + http-request track-sc0 src table bot_check_post_throttle if { path /.basedflare/bot-check } { method POST } http-request lua.kill-tor-circuit if { sc_http_req_rate(0) gt 1 } http-request tarpit if { sc_http_req_rate(0) gt 1 } @@ -65,13 +65,13 @@ frontend http-in acl ddos_mode_enabled base,map(/etc/haproxy/ddos.map) -m bool # serve challenge page scripts directly from haproxy - http-request return file /var/www/js/argon2.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /js/argon2.js } - http-request return file /var/www/js/challenge.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /js/challenge.js } - http-request return file /var/www/js/worker.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /js/worker.js } + http-request return file /var/www/js/argon2.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /.basedflare/js/argon2.js } + http-request return file /var/www/js/challenge.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /.basedflare/js/challenge.js } + http-request return file /var/www/js/worker.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /.basedflare/js/worker.js } # acl for domains in maintenance mode to return maintenance page (after challenge page htp-request return rules, for the footerlogo) acl maintenance_mode hdr(host),lower,map_str(/etc/haproxy/maintenance.map) -m found - http-request return file /var/www/html/maintenance.html status 200 content-type "text/html; charset=utf-8" hdr "cache-control" "private, max-age=30" if maintenance_mode + http-request return lf-file /var/www/html/maintenance.html status 200 content-type "text/html; charset=utf-8" hdr "cache-control" "private, max-age=30" if maintenance_mode # create acl for bools updated by lua acl captcha_passed var(txn.captcha_passed) -m bool @@ -80,14 +80,14 @@ frontend http-in acl validate_pow var(txn.validate_pow) -m bool # check pow/captcha and show page if necessary - acl on_captcha_url path /bot-check - http-request use-service lua.hcaptcha-view if on_captcha_url !is_excluded + acl on_bot_check path /.basedflare/bot-check + http-request use-service lua.bot-check if on_bot_check !is_excluded # challenge decisions, checking, and redirecting to /bot-check - http-request lua.decide-checks-necessary if !is_excluded !on_captcha_url ddos_mode_enabled - http-request lua.hcaptcha-check if !is_excluded !on_captcha_url validate_captcha - http-request lua.pow-check if !is_excluded !on_captcha_url validate_pow OR !is_excluded !on_captcha_url ddos_mode_enabled_override - http-request redirect location /bot-check?%[capture.req.uri] code 302 if validate_captcha !captcha_passed !on_captcha_url ddos_mode_enabled !is_excluded OR validate_pow !pow_passed !on_captcha_url ddos_mode_enabled !is_excluded OR !pow_passed ddos_mode_enabled_override !on_captcha_url !is_excluded + http-request lua.decide-checks-necessary if !is_excluded !on_bot_check ddos_mode_enabled + http-request lua.captcha-check if !is_excluded !on_bot_check validate_captcha + http-request lua.pow-check if !is_excluded !on_bot_check validate_pow OR !is_excluded !on_bot_check ddos_mode_enabled_override + http-request redirect location /.basedflare/bot-check?%[capture.req.uri] code 302 if validate_captcha !captcha_passed !on_bot_check ddos_mode_enabled !is_excluded OR validate_pow !pow_passed !on_bot_check ddos_mode_enabled !is_excluded OR !pow_passed ddos_mode_enabled_override !on_bot_check !is_excluded # X-Cache-Status header (may be sent in some non-cache responses because NOSRV can happen for other reasons, but should always be present in responses served by cache-use) http-response set-header X-Cache-Status HIT if !{ srv_id -m found } diff --git a/haproxy/js/challenge.js b/haproxy/js/challenge.js index ef22c6b..36b6ac7 100644 --- a/haproxy/js/challenge.js +++ b/haproxy/js/challenge.js @@ -25,6 +25,7 @@ const wasmSupported = (() => { return new WebAssembly.Instance(module) instanceof WebAssembly.Instance; } } catch (e) { + console.error(e); } return false; })(); @@ -37,7 +38,7 @@ function postResponse(powResponse, captchaResponse) { body['h-captcha-response'] = captchaResponse; body['g-recaptcha-response'] = captchaResponse; } - fetch('/bot-check', { + fetch('/.basedflare/bot-check', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -52,7 +53,7 @@ function postResponse(powResponse, captchaResponse) { return insertError('server responded with error.'); } finishRedirect(); - }).catch(err => { + }).catch(() => { insertError('failed to send challenge response.'); }); } @@ -74,7 +75,7 @@ const powFinished = new Promise((resolve, reject) => { const eHashes = Math.pow(16, Math.floor(diff/8)) * ((diff%8)*2); const diffString = '0'.repeat(Math.floor(diff/8)); const combined = pow; - const [userkey, challenge, signature] = combined.split("#"); + const [userkey, challenge] = combined.split("#"); const start = Date.now(); if (window.Worker) { const cpuThreads = window.navigator.hardwareConcurrency; @@ -117,7 +118,7 @@ const powFinished = new Promise((resolve, reject) => { } else { console.warn('No webworker support, running in main/UI thread!'); let i = 0; - let start = Date.now(); + const start = Date.now(); while(true) { const hash = await argon2.hash({ pass: challenge + i.toString(), diff --git a/haproxy/js/worker.js b/haproxy/js/worker.js index 6fd4617..1cb163c 100644 --- a/haproxy/js/worker.js +++ b/haproxy/js/worker.js @@ -16,7 +16,7 @@ onmessage = async function(e) { ...argonOpts, }); // This throttle seems to really help some browsers not stop the workers abruptly - i % 10 === 0 && await new Promise(res => setTimeout(res, 10)); + i % 10 === 0 && await new Promise(res => setTimeout(res, 1)); if (hash.hashHex.startsWith(diffString) && ((parseInt(hash.hashHex[diffString.length],16) & 0xff >> (((diffString.length+1)*8)-diff)) === 0)) { diff --git a/src/libs/utils.lua b/src/libs/utils.lua index d1ef1f9..b596f88 100644 --- a/src/libs/utils.lua +++ b/src/libs/utils.lua @@ -66,4 +66,3 @@ function _M.send_tor_control_port(circuit_identifier) end return _M - diff --git a/src/scripts/hcaptcha.lua b/src/scripts/bot-check.lua similarity index 73% rename from src/scripts/hcaptcha.lua rename to src/scripts/bot-check.lua index 657b94f..6f2197a 100644 --- a/src/scripts/hcaptcha.lua +++ b/src/scripts/bot-check.lua @@ -1,11 +1,19 @@ _M = {} +-- Testing only +-- require("socket") +-- require("print_r") + +-- main libs local url = require("url") local utils = require("utils") local cookie = require("cookie") local json = require("json") local sha = require("sha") local randbytes = require("randbytes") +local templates = require("templates") + +-- argon2 POW local argon2 = require("argon2") local pow_difficulty = tonumber(os.getenv("POW_DIFFICULTY") or 18) local pow_kb = tonumber(os.getenv("POW_KB") or 6000) @@ -16,10 +24,7 @@ argon2.parallelism(1) argon2.hash_len(32) argon2.variant(argon2.variants.argon2_id) --- Testing only --- require("socket") --- require("print_r") - +-- environment variables local captcha_secret = os.getenv("HCAPTCHA_SECRET") or os.getenv("RECAPTCHA_SECRET") local captcha_sitekey = os.getenv("HCAPTCHA_SITEKEY") or os.getenv("RECAPTCHA_SITEKEY") local captcha_cookie_secret = os.getenv("CAPTCHA_COOKIE_SECRET") @@ -27,6 +32,7 @@ local pow_cookie_secret = os.getenv("POW_COOKIE_SECRET") local hmac_cookie_secret = os.getenv("HMAC_COOKIE_SECRET") local ray_id = os.getenv("RAY_ID") +-- load captcha map and set hcaptcha/recaptch based off env vars local captcha_map = Map.new("/etc/haproxy/ddos.map", Map._str); local captcha_provider_domain = "" local captcha_classname = "" @@ -47,6 +53,7 @@ else captcha_backend_name = "recaptcha" end +-- setup initial server backends based on hosts.map into backends.map function _M.setup_servers() if pow_difficulty < 8 then error("POW_DIFFICULTY must be > 8. Around 16-32 is better") @@ -75,103 +82,6 @@ function _M.setup_servers() handle:close() end --- main page template -local body_template = [[ - - - - - Hold on... - - - - - - - %s - %s - %s - -
- - - -]] - -local noscript_extra_template = [[ -
- No JavaScript? -
    -
  1. -

    Run this in a linux terminal (requires argon2 package installed):

    - - echo "Q0g9IiQyIjtCPSQocHJpbnRmICcwJS4wcycgJChzZXEgMSAkNCkpO2VjaG8gIldvcmtpbmcuLi4iO0k9MDt3aGlsZSB0cnVlOyBkbyBIPSQoZWNobyAtbiAkQ0gkSSB8IGFyZ29uMiAkMSAtaWQgLXQgJDUgLWsgJDYgLXAgMSAtbCAzMiAtcik7RT0ke0g6MDokNH07W1sgJEUgPT0gJEIgXV0gJiYgZWNobyAiT3V0cHV0OiIgJiYgZWNobyAkMSMkMiMkMyMkSSAmJiBleGl0IDA7KChJKyspKTtkb25lOwo=" | base64 -d | bash -s %s %s %s %s %s %s - -
  2. Paste the script output into the box and submit: -
    - -
    -
    -
-
-]] - --- title with favicon and hostname -local site_name_section_template = [[ -

- icon - %s -

-]] - --- spinner animation for proof of work -local pow_section_template = [[ -

- Checking your browser for robots 🤖 -

-
-
-
-]] - --- message, captcha form and submit button -local captcha_section_template = [[ -

- Please solve the captcha to continue. -

-
-
- -
-]] - -- kill a tor circuit function _M.kill_tor_circuit(txn) local ip = txn.sf:src() @@ -214,7 +124,7 @@ function _M.view(applet) -- check if captcha is enabled, path+domain priority, then just domain, and 0 otherwise local captcha_enabled = false local host = applet.headers['host'][0] - local path = applet.qs; --because on /bot-check?/whatever, .qs (query string) holds the "path" + local path = applet.qs; --because on /.basedflare/bot-check?/whatever, .qs (query string) holds the "path" local captcha_map_lookup = captcha_map:lookup(host..path) or captcha_map:lookup(host) or 0 captcha_map_lookup = tonumber(captcha_map_lookup) @@ -223,18 +133,18 @@ function _M.view(applet) end -- pow at least is always enabled when reaching bot-check page - site_name_body = string.format(site_name_section_template, host) + site_name_body = string.format(templates.site_name_section, host) if captcha_enabled then - captcha_body = string.format(captcha_section_template, captcha_classname, + captcha_body = string.format(templates.captcha_section, captcha_classname, captcha_sitekey, captcha_script_src) else - pow_body = pow_section_template - noscript_extra_body = string.format(noscript_extra_template, user_key, challenge_hash, signature, + pow_body = templates.pow_section + noscript_extra_body = string.format(templates.noscript_extra, user_key, challenge_hash, signature, math.ceil(pow_difficulty/8), pow_time, pow_kb) end -- sub in the body sections - response_body = string.format(body_template, combined_challenge, + response_body = string.format(templates.body, combined_challenge, pow_difficulty, pow_time, pow_kb, site_name_body, pow_body, captcha_body, noscript_extra_body, ray_id) response_status_code = 403 diff --git a/src/scripts/register.lua b/src/scripts/register.lua index ec408ee..6ba84ef 100644 --- a/src/scripts/register.lua +++ b/src/scripts/register.lua @@ -1,10 +1,10 @@ package.path = package.path .. "./?.lua;/etc/haproxy/scripts/?.lua;/etc/haproxy/libs/?.lua" -local hcaptcha = require("hcaptcha") +local bot_check = require("bot-check") -core.register_service("hcaptcha-view", "http", hcaptcha.view) -core.register_action("hcaptcha-check", { 'http-req', }, hcaptcha.check_captcha_status) -core.register_action("pow-check", { 'http-req', }, hcaptcha.check_pow_status) -core.register_action("decide-checks-necessary", { 'http-req', }, hcaptcha.decide_checks_necessary) -core.register_action("kill-tor-circuit", { 'http-req', }, hcaptcha.kill_tor_circuit) -core.register_init(hcaptcha.setup_servers) +core.register_service("bot-check", "http", bot_check.view) +core.register_action("captcha-check", { 'http-req', }, bot_check.check_captcha_status) +core.register_action("pow-check", { 'http-req', }, bot_check.check_pow_status) +core.register_action("decide-checks-necessary", { 'http-req', }, bot_check.decide_checks_necessary) +core.register_action("kill-tor-circuit", { 'http-req', }, bot_check.kill_tor_circuit) +core.register_init(bot_check.setup_servers) diff --git a/src/scripts/templates.lua b/src/scripts/templates.lua new file mode 100644 index 0000000..e223f27 --- /dev/null +++ b/src/scripts/templates.lua @@ -0,0 +1,100 @@ +local _M = {} + +-- main page template +_M.body = [[ + + + + + Hold on... + + + + + + + %s + %s + %s + +
+ + + +]] + +_M.noscript_extra = [[ +
+ No JavaScript? +
    +
  1. +

    Run this in a linux terminal (requires argon2 package installed):

    + + echo "Q0g9IiQyIjtCPSQocHJpbnRmICcwJS4wcycgJChzZXEgMSAkNCkpO2VjaG8gIldvcmtpbmcuLi4iO0k9MDt3aGlsZSB0cnVlOyBkbyBIPSQoZWNobyAtbiAkQ0gkSSB8IGFyZ29uMiAkMSAtaWQgLXQgJDUgLWsgJDYgLXAgMSAtbCAzMiAtcik7RT0ke0g6MDokNH07W1sgJEUgPT0gJEIgXV0gJiYgZWNobyAiT3V0cHV0OiIgJiYgZWNobyAkMSMkMiMkMyMkSSAmJiBleGl0IDA7KChJKyspKTtkb25lOwo=" | base64 -d | bash -s %s %s %s %s %s %s + +
  2. Paste the script output into the box and submit: +
    + +
    +
    +
+
+]] + +-- title with favicon and hostname +_M.site_name_section = [[ +

+ icon + %s +

+]] + +-- spinner animation for proof of work +_M.pow_section = [[ +

+ Checking your browser for robots 🤖 +

+
+
+
+]] + +-- message, captcha form and submit button +_M.captcha_section = [[ +

+ Please solve the captcha to continue. +

+
+
+ +
+]] + +return _M From 16767e0a97b856f599db7bf3d7413a799475247d Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 14:18:36 +1100 Subject: [PATCH 02/15] Add updated maintenance.html with template vars for lf-file --- haproxy/html/maintenance.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/haproxy/html/maintenance.html b/haproxy/html/maintenance.html index 1770108..4108321 100644 --- a/haproxy/html/maintenance.html +++ b/haproxy/html/maintenance.html @@ -7,7 +7,7 @@ :root{--text-color:#c5c8c6;--bg-color:#1d1f21} @media (prefers-color-scheme:light){:root{--text-color:#333;--bg-color:#EEE}} a,a:visited{color:var(--text-color)} -body,html{height:100%} +body,html{height:100%%} body{display:flex;flex-direction:column;background-color:var(--bg-color);color:var(--text-color);font-family:Helvetica,Arial,sans-serif;text-align:center;margin:0} code{background-color:#dfdfdf30;border-radius:3px;padding:0 3px;} img,h3,p{margin:0 0 5px 0} @@ -18,7 +18,7 @@ footer{font-size:x-small;margin-top:auto;margin-bottom:20px}.pt{padding-top:30vh

Under maintenance. Please try again soon!

Security and Performance by haproxy-protection

-

Vey ID: 553d0499f419bd4c66ff48f7d0a27706

+

Node: %[env(RAY_ID)]

From 4637df474554f8341615182a355c2b6fb9c99df4 Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 14:22:34 +1100 Subject: [PATCH 03/15] add missing basedflare paths to worker and challenge includes --- haproxy/js/challenge.js | 2 +- haproxy/js/worker.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/haproxy/js/challenge.js b/haproxy/js/challenge.js index 36b6ac7..18198fb 100644 --- a/haproxy/js/challenge.js +++ b/haproxy/js/challenge.js @@ -107,7 +107,7 @@ const powFinished = new Promise((resolve, reject) => { } const workers = []; for (let i = 0; i < workerThreads; i++) { - const argonWorker = new Worker('/js/worker.js'); + const argonWorker = new Worker('/.basedflare/js/worker.js'); argonWorker.onmessage = messageHandler; workers.push(argonWorker); } diff --git a/haproxy/js/worker.js b/haproxy/js/worker.js index 1cb163c..8a6fcdc 100644 --- a/haproxy/js/worker.js +++ b/haproxy/js/worker.js @@ -1,4 +1,4 @@ -importScripts('/js/argon2.js'); +importScripts('/.basedflare/js/argon2.js'); onmessage = async function(e) { const [userkey, challenge, diff, diffString, argonOpts, id, threads] = e.data; From 0d56079960c2d07caf062eb1c1c94d37ef5c992d Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 14:43:55 +1100 Subject: [PATCH 04/15] Rename POW vars to separately argon_ for argon2 stuff, improve readme and split out INSTALLATION into separate file --- README.MD => INSTALLATION.md | 32 +++--------------------------- README.md | 38 ++++++++++++++++++++++++++++++++++++ src/scripts/bot-check.lua | 21 ++++++++++++-------- 3 files changed, 54 insertions(+), 37 deletions(-) rename README.MD => INSTALLATION.md (63%) create mode 100644 README.md diff --git a/README.MD b/INSTALLATION.md similarity index 63% rename from README.MD rename to INSTALLATION.md index 68afd9c..1245d4d 100644 --- a/README.MD +++ b/INSTALLATION.md @@ -1,24 +1,3 @@ -## haproxy-protection - -A fork and further development of a proof of concept from https://github.com/mora9715/haproxy_ddos_protector, a HAProxy configuration and lua scripts allowing a challenge-response page where users solve a captcha and/or proof-of-work. Intended to stop bots, spam, ddos. - -Integrates with https://gitgud.io/fatchan/haproxy-panel-next to add/remove/edit domains, protection rules, blocked ips, backend server IPs, etc during runtime. - -Improvements in this fork: - -- Add a proof-of-work element, instead of only captcha. -- Supports hcaptcha or recaptcha. -- Support .onion/tor with the HAProxy PROXY protocol, using circuit identifiers as a substitute for IPs. -- Use HAProxy `http-request return` directive to directly serve challenge pages from the edge, with no separate backend. -- Fix multiple security issues that could result in bypassing the captcha. -- Add a bucket duration for cookie validity, so valid cookies don't last forever. -- Choose protection modes "none", "pow" or "pow+captcha" per-domain or per-domain+path, with paths taking priority. -- Provide a bash script that solves the proof-of-work and a form submission box for noscript users. -- Whitelist or blacklist IPs/subnets. -- Maintenance mode page for selected domains. -- Improved the appearance of the challenge page. -- Many bugfixes. - #### Environment variables For docker, these are in docker-compose.yml. For production deployments, add them to `/etc/default/haproxy`. @@ -36,9 +15,9 @@ NOTE: Use either HCAPTCHA_ or RECAPTHCA_, not both. - CHALLENGE_INCLUDES_IP - any value, whether to lock solved challenges to IP or tor circuit - BACKEND_NAME - Optional, name of backend to build from hosts.map - SERVER_PREFIX - Optional, prefix of server names used in server-template -- POW_TIME - argon2 iterations -- POW_KB - argon2 memory usage in KB -- POW_DIFFICULTY - pow "difficulty" (you should use all 3 POW_ parameters to tune the difficulty) +- ARGON_TIME - argon2 iterations +- ARGON_KB - argon2 memory usage in KB +- POW_DIFFICULTY - pow difficulty - TOR_CONTROL_PORT_PASSWORD - the control port password for tor daemon #### Run in docker (for testing/development) @@ -85,8 +64,3 @@ ControlPort 9051 HashedControlPassword xxxxxxxxxxxxxxxxx ``` - Don't forget to restart tor - -#### Screenshots - -![nocaptcha](img/nocaptcha.png "no captcha mode") -![captcha](img/captcha.png "captcha mode (pow done asynchronously in background)") diff --git a/README.md b/README.md new file mode 100644 index 0000000..76db049 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +## haproxy-protection + +A fork and further development of a proof of concept from https://github.com/mora9715/haproxy_ddos_protector, a HAProxy configuration and lua scripts allowing a challenge-response page where users solve a captcha and/or proof-of-work. Intended to stop bots, spam, ddos. + +Integrates with https://gitgud.io/fatchan/haproxy-panel-next to add/remove/edit domains, protection rules, blocked ips, backend server IPs, etc during runtime. + +#### Features / improvements in this fork: + +- Implement a proof-of-work mode, in addition to the existing captcha only mode. +- Supports either hcaptcha or recaptcha. +- Support .onion/tor with the HAProxy PROXY protocol, using circuit identifiers as a substitute for IPs. +- Allow users without javascript to solve the POW by providing a shell script and html form inside `noscript` tags. +- Use HAProxy `http-request return` directive to directly serve files from the edge without a separate backend. +- Adjustable cookie validity lifetime. +- Adjustable "mode" ("none", "pow" or "pow+captcha") per domain or domain+path +- Improved the appearance of the challenge page. +- Add several useful maps & acls to the haproxy config: + - Whitelist or blacklist IPs/subnets. + - Maintenance mode page for selected domains. +- Fix multiple security issues. +- Many bugfixes. + +#### Installation + +See [INSTALLATION.md](INSTALLATION.md) + +#### Screenshots + +![nocaptcha](img/nocaptcha.png "no captcha mode") +![captcha](img/captcha.png "captcha mode (pow done asynchronously in background)") + +## For generous people + +Bitcoin (BTC): [`bc1q4elrlz5puak4m9xy3hfvmpempnpqpu95v8s9m6`](bitcoin:bc1q4elrlz5puak4m9xy3hfvmpempnpqpu95v8s9m6) + +Monero (XMR): [`89J9DXPLUBr5HjNDNZTEo4WYMFTouSsGjUjBnUCCUxJGUirthnii4naZ8JafdnmhPe4NP1nkWsgcK82Uga7X515nNR1isuh`](monero:89J9DXPLUBr5HjNDNZTEo4WYMFTouSsGjUjBnUCCUxJGUirthnii4naZ8JafdnmhPe4NP1nkWsgcK82Uga7X515nNR1isuh) + +Oxen (OXEN): `LBjExqjDKCFT6Tj198CfK8auAzBERJX1ogtcsjuKZ6AYWTFxwEADLgf2zZ8NHvWCa1UW7vrtY8DJmPYFpj3MEE69CryCvN6` diff --git a/src/scripts/bot-check.lua b/src/scripts/bot-check.lua index 6f2197a..5785ee3 100644 --- a/src/scripts/bot-check.lua +++ b/src/scripts/bot-check.lua @@ -13,17 +13,22 @@ local sha = require("sha") local randbytes = require("randbytes") local templates = require("templates") --- argon2 POW -local argon2 = require("argon2") +-- POW local pow_difficulty = tonumber(os.getenv("POW_DIFFICULTY") or 18) -local pow_kb = tonumber(os.getenv("POW_KB") or 6000) -local pow_time = tonumber(os.getenv("POW_TIME") or 1) -argon2.t_cost(pow_time) -argon2.m_cost(pow_kb) + +-- argon2 +local argon2 = require("argon2") +local argon_kb = tonumber(os.getenv("ARGON_KB") or 6000) +local argon_time = tonumber(os.getenv("ARGON_TIME") or 1) +argon2.t_cost(argon_time) +argon2.m_cost(argon_kb) argon2.parallelism(1) argon2.hash_len(32) argon2.variant(argon2.variants.argon2_id) +-- sha2 +-- TODO + -- environment variables local captcha_secret = os.getenv("HCAPTCHA_SECRET") or os.getenv("RECAPTCHA_SECRET") local captcha_sitekey = os.getenv("HCAPTCHA_SITEKEY") or os.getenv("RECAPTCHA_SITEKEY") @@ -140,12 +145,12 @@ function _M.view(applet) else pow_body = templates.pow_section noscript_extra_body = string.format(templates.noscript_extra, user_key, challenge_hash, signature, - math.ceil(pow_difficulty/8), pow_time, pow_kb) + math.ceil(pow_difficulty/8), argon_time, argon_kb) end -- sub in the body sections response_body = string.format(templates.body, combined_challenge, - pow_difficulty, pow_time, pow_kb, + pow_difficulty, argon_time, argon_kb, site_name_body, pow_body, captcha_body, noscript_extra_body, ray_id) response_status_code = 403 From 08a966c121735092491cb8f1469d7e28488e55e3 Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 15:06:35 +1100 Subject: [PATCH 05/15] Reorganise, move code to not be split between haproxy and src folder --- docker-compose.yml | 22 ++++++++++----------- haproxy/haproxy.cfg | 8 ++++---- haproxy/{ => map}/backends.map | 0 haproxy/{ => map}/blocked.map | 0 haproxy/{ => map}/ddos.map | 0 haproxy/{ => map}/hosts.map | 0 haproxy/{ => map}/maintenance.map | 0 haproxy/{ => map}/whitelist.map | 0 haproxy/{html => template}/maintenance.html | 0 haproxy/{ => template}/trace.txt | 0 {haproxy => src}/js/argon2.js | 0 {haproxy => src}/js/challenge.js | 0 {haproxy => src}/js/worker.js | 0 src/{ => lua}/libs/cookie.lua | 0 src/{ => lua}/libs/json.lua | 0 src/{ => lua}/libs/print_r.lua | 0 src/{ => lua}/libs/randbytes.lua | 0 src/{ => lua}/libs/sha.lua | 0 src/{ => lua}/libs/url.lua | 0 src/{ => lua}/libs/utils.lua | 0 src/{ => lua}/scripts/bot-check.lua | 0 src/{ => lua}/scripts/register.lua | 0 src/{ => lua}/scripts/templates.lua | 0 23 files changed, 15 insertions(+), 15 deletions(-) rename haproxy/{ => map}/backends.map (100%) rename haproxy/{ => map}/blocked.map (100%) rename haproxy/{ => map}/ddos.map (100%) rename haproxy/{ => map}/hosts.map (100%) rename haproxy/{ => map}/maintenance.map (100%) rename haproxy/{ => map}/whitelist.map (100%) rename haproxy/{html => template}/maintenance.html (100%) rename haproxy/{ => template}/trace.txt (100%) rename {haproxy => src}/js/argon2.js (100%) rename {haproxy => src}/js/challenge.js (100%) rename {haproxy => src}/js/worker.js (100%) rename src/{ => lua}/libs/cookie.lua (100%) rename src/{ => lua}/libs/json.lua (100%) rename src/{ => lua}/libs/print_r.lua (100%) rename src/{ => lua}/libs/randbytes.lua (100%) rename src/{ => lua}/libs/sha.lua (100%) rename src/{ => lua}/libs/url.lua (100%) rename src/{ => lua}/libs/utils.lua (100%) rename src/{ => lua}/scripts/bot-check.lua (100%) rename src/{ => lua}/scripts/register.lua (100%) rename src/{ => lua}/scripts/templates.lua (100%) diff --git a/docker-compose.yml b/docker-compose.yml index 6824c86..fd3b630 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,17 +10,17 @@ services: dockerfile: haproxy/Dockerfile volumes: - ./haproxy/haproxy.cfg:/etc/haproxy/haproxy.cfg - - ./haproxy/ddos.map:/etc/haproxy/ddos.map - - ./haproxy/hosts.map:/etc/haproxy/hosts.map - - ./haproxy/backends.map:/etc/haproxy/backends.map - - ./haproxy/blocked.map:/etc/haproxy/blocked.map - - ./haproxy/whitelist.map:/etc/haproxy/whitelist.map - - ./haproxy/maintenance.map:/etc/haproxy/maintenance.map - - ./haproxy/trace.txt:/etc/haproxy/trace.txt - - ./src/scripts/:/etc/haproxy/scripts/ - - ./src/libs/:/etc/haproxy/libs/ - - ./haproxy/js/:/var/www/js/ - - ./haproxy/html/maintenance.html:/var/www/html/maintenance.html + - ./haproxy/map/ddos.map:/etc/haproxy/ddos.map + - ./haproxy/map/hosts.map:/etc/haproxy/hosts.map + - ./haproxy/map/backends.map:/etc/haproxy/backends.map + - ./haproxy/map/blocked.map:/etc/haproxy/blocked.map + - ./haproxy/map/whitelist.map:/etc/haproxy/whitelist.map + - ./haproxy/map/maintenance.map:/etc/haproxy/maintenance.map + - ./haproxy/template/maintenance.html:/etc/haproxy/maintenance.html + - ./haproxy/template/trace.txt:/etc/haproxy/trace.txt + - ./src/lua/scripts/:/etc/haproxy/scripts/ + - ./src/lua/libs/:/etc/haproxy/libs/ + - ./src/js/:/etc/haproxy/js/ environment: # These are the hcaptcha and recaptcha test keys, not leaking any dont worry :^) - HCAPTCHA_SITEKEY=20000000-ffff-ffff-ffff-000000000002 diff --git a/haproxy/haproxy.cfg b/haproxy/haproxy.cfg index 807e2bd..d03dce1 100644 --- a/haproxy/haproxy.cfg +++ b/haproxy/haproxy.cfg @@ -65,13 +65,13 @@ frontend http-in acl ddos_mode_enabled base,map(/etc/haproxy/ddos.map) -m bool # serve challenge page scripts directly from haproxy - http-request return file /var/www/js/argon2.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /.basedflare/js/argon2.js } - http-request return file /var/www/js/challenge.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /.basedflare/js/challenge.js } - http-request return file /var/www/js/worker.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /.basedflare/js/worker.js } + http-request return file /etc/haproxy/js/argon2.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /.basedflare/js/argon2.js } + http-request return file /etc/haproxy/js/challenge.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /.basedflare/js/challenge.js } + http-request return file /etc/haproxy/js/worker.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /.basedflare/js/worker.js } # acl for domains in maintenance mode to return maintenance page (after challenge page htp-request return rules, for the footerlogo) acl maintenance_mode hdr(host),lower,map_str(/etc/haproxy/maintenance.map) -m found - http-request return lf-file /var/www/html/maintenance.html status 200 content-type "text/html; charset=utf-8" hdr "cache-control" "private, max-age=30" if maintenance_mode + http-request return lf-file /etc/haproxy/maintenance.html status 200 content-type "text/html; charset=utf-8" hdr "cache-control" "private, max-age=30" if maintenance_mode # create acl for bools updated by lua acl captcha_passed var(txn.captcha_passed) -m bool diff --git a/haproxy/backends.map b/haproxy/map/backends.map similarity index 100% rename from haproxy/backends.map rename to haproxy/map/backends.map diff --git a/haproxy/blocked.map b/haproxy/map/blocked.map similarity index 100% rename from haproxy/blocked.map rename to haproxy/map/blocked.map diff --git a/haproxy/ddos.map b/haproxy/map/ddos.map similarity index 100% rename from haproxy/ddos.map rename to haproxy/map/ddos.map diff --git a/haproxy/hosts.map b/haproxy/map/hosts.map similarity index 100% rename from haproxy/hosts.map rename to haproxy/map/hosts.map diff --git a/haproxy/maintenance.map b/haproxy/map/maintenance.map similarity index 100% rename from haproxy/maintenance.map rename to haproxy/map/maintenance.map diff --git a/haproxy/whitelist.map b/haproxy/map/whitelist.map similarity index 100% rename from haproxy/whitelist.map rename to haproxy/map/whitelist.map diff --git a/haproxy/html/maintenance.html b/haproxy/template/maintenance.html similarity index 100% rename from haproxy/html/maintenance.html rename to haproxy/template/maintenance.html diff --git a/haproxy/trace.txt b/haproxy/template/trace.txt similarity index 100% rename from haproxy/trace.txt rename to haproxy/template/trace.txt diff --git a/haproxy/js/argon2.js b/src/js/argon2.js similarity index 100% rename from haproxy/js/argon2.js rename to src/js/argon2.js diff --git a/haproxy/js/challenge.js b/src/js/challenge.js similarity index 100% rename from haproxy/js/challenge.js rename to src/js/challenge.js diff --git a/haproxy/js/worker.js b/src/js/worker.js similarity index 100% rename from haproxy/js/worker.js rename to src/js/worker.js diff --git a/src/libs/cookie.lua b/src/lua/libs/cookie.lua similarity index 100% rename from src/libs/cookie.lua rename to src/lua/libs/cookie.lua diff --git a/src/libs/json.lua b/src/lua/libs/json.lua similarity index 100% rename from src/libs/json.lua rename to src/lua/libs/json.lua diff --git a/src/libs/print_r.lua b/src/lua/libs/print_r.lua similarity index 100% rename from src/libs/print_r.lua rename to src/lua/libs/print_r.lua diff --git a/src/libs/randbytes.lua b/src/lua/libs/randbytes.lua similarity index 100% rename from src/libs/randbytes.lua rename to src/lua/libs/randbytes.lua diff --git a/src/libs/sha.lua b/src/lua/libs/sha.lua similarity index 100% rename from src/libs/sha.lua rename to src/lua/libs/sha.lua diff --git a/src/libs/url.lua b/src/lua/libs/url.lua similarity index 100% rename from src/libs/url.lua rename to src/lua/libs/url.lua diff --git a/src/libs/utils.lua b/src/lua/libs/utils.lua similarity index 100% rename from src/libs/utils.lua rename to src/lua/libs/utils.lua diff --git a/src/scripts/bot-check.lua b/src/lua/scripts/bot-check.lua similarity index 100% rename from src/scripts/bot-check.lua rename to src/lua/scripts/bot-check.lua diff --git a/src/scripts/register.lua b/src/lua/scripts/register.lua similarity index 100% rename from src/scripts/register.lua rename to src/lua/scripts/register.lua diff --git a/src/scripts/templates.lua b/src/lua/scripts/templates.lua similarity index 100% rename from src/scripts/templates.lua rename to src/lua/scripts/templates.lua From a303689641a358b2a1b7afbc8ddfc398c57d772d Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 16:08:44 +1100 Subject: [PATCH 06/15] Close #18 make answers and redirect calls shared between tabs with localstorage to not solve and submit answer multiple times when opening multiple tabs/bookmarks, etc --- docker-compose.yml | 2 +- src/js/challenge.js | 66 ++++++++++++++++++++++++++++----------------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index fd3b630..4508446 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: - CHALLENGE_INCLUDES_IP=1 - POW_TIME=2 - POW_KB=512 - - POW_DIFFICULTY=25 + - POW_DIFFICULTY=24 - TOR_CONTROL_PORT_PASSWORD=changeme nginx: diff --git a/src/js/challenge.js b/src/js/challenge.js index 18198fb..100e575 100644 --- a/src/js/challenge.js +++ b/src/js/challenge.js @@ -52,18 +52,47 @@ function postResponse(powResponse, captchaResponse) { } else if (s >= 500) { return insertError('server responded with error.'); } + window.localStorage.setItem('basedflare-redirect', Math.random()); finishRedirect(); }).catch(() => { insertError('failed to send challenge response.'); }); } -const powFinished = new Promise((resolve, reject) => { +const powFinished = new Promise(resolve => { + + const start = Date.now(); + const workers = []; + let finished = false; + const stopPow = () => { + finished = true; + const hasCaptcha = document.getElementById('captcha'); + updateElem('.powstatus', `Found proof-of-work solution.${!hasCaptcha?' Submitting...':''}`); + workers.forEach(w => w.terminate()); + }; + const submitPow = (answer) => { + window.localStorage.setItem('basedflare-pow-response', answer); + stopPow(); + resolve({ answer }); + }; + window.addEventListener('DOMContentLoaded', async () => { + + const { time, kb, pow, diff } = document.querySelector('[data-pow]').dataset; + window.addEventListener('storage', event => { + if (event.key === 'basedflare-pow-response' && !finished) { + console.log('Got answer', event.newValue, 'from storage event'); + stopPow(); + resolve({ answer: event.newValue, localStorage: true }); + } else if (event.key === 'basedflare-redirect') { + console.log('Redirecting, solved in another tab'); + finishRedirect(); + } + }); + if (!wasmSupported) { return insertError('browser does not support WebAssembly.'); } - const { time, kb, pow, diff } = document.querySelector('[data-pow]').dataset; const argonOpts = { time: time, mem: kb, @@ -74,16 +103,13 @@ const powFinished = new Promise((resolve, reject) => { console.log('Got pow', pow, 'with difficulty', diff); const eHashes = Math.pow(16, Math.floor(diff/8)) * ((diff%8)*2); const diffString = '0'.repeat(Math.floor(diff/8)); - const combined = pow; - const [userkey, challenge] = combined.split("#"); - const start = Date.now(); + const [userkey, challenge] = pow.split("#"); if (window.Worker) { const cpuThreads = window.navigator.hardwareConcurrency; const isTor = location.hostname.endsWith('.onion'); /* Try to use all threads on tor, because tor limits threads for anti fingerprinting but this makes it awfully slow because workerThreads will always be = 1 */ const workerThreads = isTor ? cpuThreads : Math.max(Math.ceil(cpuThreads/2),cpuThreads-1); - let finished = false; const messageHandler = (e) => { if (e.data.length === 1) { const totalHashes = e.data[0]; //assumes all worker threads are same speed @@ -94,28 +120,21 @@ const powFinished = new Promise((resolve, reject) => { return updateElem('.powstatus', `Proof-of-work: ${hps}H/s, ~${remainingSec}s remaining`); } if (finished) { return; } - finished = true; - const hasCaptcha = document.getElementById('captcha'); - updateElem('.powstatus', `Found proof-of-work solution.${!hasCaptcha?' Submitting...':''}`); - workers.forEach(w => w.terminate()); const [workerId, answer] = e.data; console.log('Worker', workerId, 'returned answer', answer, 'in', Date.now()-start+'ms'); - const dummyTime = 5000 - (Date.now()-start); - window.setTimeout(() => { - resolve(`${combined}#${answer}`); - }, dummyTime); + submitPow(`${pow}#${answer}`); } - const workers = []; for (let i = 0; i < workerThreads; i++) { const argonWorker = new Worker('/.basedflare/js/worker.js'); argonWorker.onmessage = messageHandler; workers.push(argonWorker); } for (let i = 0; i < workerThreads; i++) { - await new Promise(res => setTimeout(res, 100)); + await new Promise(res => setTimeout(res, 10)); workers[i].postMessage([userkey, challenge, diff, diffString, argonOpts, i, workerThreads]); } } else { + //TODO: remove this section, it _will_ cause problems console.warn('No webworker support, running in main/UI thread!'); let i = 0; const start = Date.now(); @@ -133,25 +152,24 @@ const powFinished = new Promise((resolve, reject) => { } ++i; } - const dummyTime = 5000 - (Date.now()-start); - window.setTimeout(() => { - resolve(`${combined}#${i}`); - }, dummyTime); + submitPow(`${pow}#${i}`); } }); }).then((powResponse) => { const hasCaptchaForm = document.getElementById('captcha'); - if (!hasCaptchaForm) { - postResponse(powResponse); + if (!hasCaptchaForm && !powResponse.localStorage) { + postResponse(powResponse.answer); } - return powResponse; + return powResponse.answer; +}).catch((e) => { + console.error(e); }); function onCaptchaSubmit(captchaResponse) { const captchaElem = document.querySelector('[data-sitekey]'); captchaElem.insertAdjacentHTML('afterend', `
`); captchaElem.remove(); - powFinished.then((powResponse) => { + powFinished.then(powResponse => { postResponse(powResponse, captchaResponse); }); } From 6e5cf2af31c04e7d0542be1b91e8a49147fc60a5 Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 20:55:30 +1100 Subject: [PATCH 07/15] Make cookie expiry based on issued expiry date from challenge date instead of all expiring on bucket. Fixes potential issue of challenges being incorrect if solved right on the bucket change Allows to solve a challenge at any time (even in the background) and not see the challenge page twice in a small period Allows for backend to make dynamic expiry of tokens e.g make tor tokens or based on IP reputation not last as long (not implemented atm) Close #20 --- src/lua/libs/utils.lua | 16 ++--- src/lua/scripts/bot-check.lua | 119 ++++++++++++++++++++-------------- src/lua/scripts/templates.lua | 2 +- 3 files changed, 81 insertions(+), 56 deletions(-) diff --git a/src/lua/libs/utils.lua b/src/lua/libs/utils.lua index b596f88..1160c7b 100644 --- a/src/lua/libs/utils.lua +++ b/src/lua/libs/utils.lua @@ -1,16 +1,12 @@ local _M = {} local sha = require("sha") -local secret_bucket_duration = tonumber(os.getenv("BUCKET_DURATION")) +local bucket_duration = tonumber(os.getenv("BUCKET_DURATION")) local challenge_includes_ip = os.getenv("CHALLENGE_INCLUDES_IP") local tor_control_port_password = os.getenv("TOR_CONTROL_PORT_PASSWORD") -- generate the challenge hash/user hash -function _M.generate_secret(context, salt, user_key, is_applet) - - -- time bucket for expiry - local start_sec = core.now()['sec'] - local bucket = start_sec - (start_sec % secret_bucket_duration) +function _M.generate_challenge(context, salt, user_key, is_applet) -- optional IP to lock challenges/user_keys to IP (for clearnet or single-onion aka 99% of cases) local ip = "" @@ -28,7 +24,11 @@ function _M.generate_secret(context, salt, user_key, is_applet) user_agent = context.sf:req_fhdr('user-agent') or "" end - return sha.sha3_256(salt .. bucket .. ip .. user_key .. user_agent) + local challenge_hash = sha.sha3_256(salt .. ip .. user_key .. user_agent) + + local expiry = core.now()['sec'] + bucket_duration + + return challenge_hash, expiry end @@ -59,7 +59,7 @@ end function _M.send_tor_control_port(circuit_identifier) local tcp = core.tcp(); tcp:settimeout(1); - tcp:connect("127.0.0.1", 9051); + tcp:connect("127.0.0.1", 9051); --TODO: configurable host/port -- not buffered, so we are better off sending it all at once tcp:send('AUTHENTICATE "' .. tor_control_port_password .. '"\nCLOSECIRCUIT ' .. circuit_identifier ..'\n') tcp:close() diff --git a/src/lua/scripts/bot-check.lua b/src/lua/scripts/bot-check.lua index 5785ee3..946b55f 100644 --- a/src/lua/scripts/bot-check.lua +++ b/src/lua/scripts/bot-check.lua @@ -116,9 +116,9 @@ function _M.view(applet) -- get the user_key#challenge#sig local user_key = sha.bin_to_hex(randbytes(16)) - local challenge_hash = utils.generate_secret(applet, pow_cookie_secret, user_key, true) - local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, user_key .. challenge_hash) - local combined_challenge = user_key .. "#" .. challenge_hash .. "#" .. signature + local challenge_hash, expiry = utils.generate_challenge(applet, pow_cookie_secret, user_key, true) + local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, user_key .. challenge_hash .. expiry) + local combined_challenge = user_key .. "#" .. challenge_hash .. "#" .. expiry .. "#" .. signature -- define body sections local site_name_body = "" @@ -144,8 +144,9 @@ function _M.view(applet) captcha_sitekey, captcha_script_src) else pow_body = templates.pow_section - noscript_extra_body = string.format(templates.noscript_extra, user_key, challenge_hash, signature, - math.ceil(pow_difficulty/8), argon_time, argon_kb) + noscript_extra_body = string.format(templates.noscript_extra, user_key, + challenge_hash, expiry, signature, math.ceil(pow_difficulty/8), + argon_time, argon_kb) end -- sub in the body sections @@ -171,47 +172,57 @@ function _M.view(applet) -- handle setting the POW cookie local user_pow_response = parsed_body["pow_response"] + local matched_expiry = 0 -- ensure captcha cookie expiry matches POW cookie if user_pow_response then -- split the response up (makes the nojs submission easier because it can be a single field) local split_response = utils.split(user_pow_response, "#") - if #split_response == 4 then + if #split_response == 5 then local given_user_key = split_response[1] local given_challenge_hash = split_response[2] - local given_signature = split_response[3] - local given_answer = split_response[4] + local given_expiry = split_response[3] + local given_signature = split_response[4] + local given_answer = split_response[5] - -- regenerate the challenge and compare it - local generated_challenge_hash = utils.generate_secret(applet, pow_cookie_secret, given_user_key, true) - if given_challenge_hash == generated_challenge_hash then + -- expiry check + local number_expiry = tonumber(given_expiry, 10) + if number_expiry ~= nil and number_expiry > core.now()['sec'] then - -- regenerate the signature and compare it - local generated_signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash) - if given_signature == generated_signature then + -- regenerate the challenge and compare it + local generated_challenge_hash = utils.generate_challenge(applet, pow_cookie_secret, given_user_key, true) - -- do the work with their given answer - local full_hash = argon2.hash_encoded(given_challenge_hash .. given_answer, given_user_key) + if given_challenge_hash == generated_challenge_hash then - -- check the output is correct - local hash_output = utils.split(full_hash, '$')[6]:sub(0, 43) -- https://github.com/thibaultcha/lua-argon2/issues/37 - local hex_hash_output = sha.bin_to_hex(sha.base64_to_bin(hash_output)); - if utils.checkdiff(hex_hash_output, pow_difficulty) then + -- regenerate the signature and compare it + local generated_signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash .. given_expiry) - -- the answer was good, give them a cookie - local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash .. given_answer) - local combined_cookie = given_user_key .. "#" .. given_challenge_hash .. "#" .. given_answer .. "#" .. signature - applet:add_header( - "set-cookie", - string.format( - "z_ddos_pow=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; Domain=.%s; SameSite=Strict;%s", - combined_cookie, - applet.headers['host'][0], - secure_cookie_flag + if given_signature == generated_signature then + + -- do the work with their given answer + local full_hash = argon2.hash_encoded(given_challenge_hash .. given_answer, given_user_key) + + -- check the output is correct + local hash_output = utils.split(full_hash, '$')[6]:sub(0, 43) -- https://github.com/thibaultcha/lua-argon2/issues/37 + local hex_hash_output = sha.bin_to_hex(sha.base64_to_bin(hash_output)); + + if utils.checkdiff(hex_hash_output, pow_difficulty) then + + -- the answer was good, give them a cookie + local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash .. given_expiry .. given_answer) + local combined_cookie = given_user_key .. "#" .. given_challenge_hash .. "#" .. given_expiry .. "#" .. given_answer .. "#" .. signature + applet:add_header( + "set-cookie", + string.format( + "_basedflare_pow=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; Domain=.%s; SameSite=Strict;%s", + combined_cookie, + applet.headers['host'][0], + secure_cookie_flag + ) ) - ) - valid_submission = true + valid_submission = true + end end end end @@ -251,13 +262,13 @@ function _M.view(applet) if api_response.success == true then local user_key = sha.bin_to_hex(randbytes(16)) - local user_hash = utils.generate_secret(applet, captcha_cookie_secret, user_key, true) - local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, user_key .. user_hash) - local combined_cookie = user_key .. "#" .. user_hash .. "#" .. signature + local user_hash = utils.generate_challenge(applet, captcha_cookie_secret, user_key, true) + local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, user_key .. user_hash .. matched_expiry) + local combined_cookie = user_key .. "#" .. user_hash .. "#" .. matched_expiry .. "#" .. signature applet:add_header( "set-cookie", string.format( - "z_ddos_captcha=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; Domain=.%s; SameSite=Strict;%s", + "_basedflare_captcha=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; Domain=.%s; SameSite=Strict;%s", combined_cookie, applet.headers['host'][0], secure_cookie_flag @@ -309,22 +320,29 @@ end -- check if captcha cookie is valid, separate secret from POW function _M.check_captcha_status(txn) local parsed_request_cookies = cookie.get_cookie_table(txn.sf:hdr("Cookie")) - local received_captcha_cookie = parsed_request_cookies["z_ddos_captcha"] or "" + local received_captcha_cookie = parsed_request_cookies["_basedflare_captcha"] or "" -- split the cookie up local split_cookie = utils.split(received_captcha_cookie, "#") - if #split_cookie ~= 3 then + if #split_cookie ~= 4 then return end local given_user_key = split_cookie[1] local given_user_hash = split_cookie[2] - local given_signature = split_cookie[3] + local given_expiry = split_cookie[3] + local given_signature = split_cookie[4] + + -- expiry check + local number_expiry = tonumber(given_expiry, 10) + if number_expiry == nil or number_expiry <= core.now()['sec'] then + return + end -- regenerate the user hash and compare it - local generated_user_hash = utils.generate_secret(txn, captcha_cookie_secret, given_user_key, false) + local generated_user_hash = utils.generate_challenge(txn, captcha_cookie_secret, given_user_key, false) if generated_user_hash ~= given_user_hash then return end -- regenerate the signature and compare it - local generated_signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_user_hash) + local generated_signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_user_hash .. given_expiry) if given_signature == generated_signature then return txn:set_var("txn.captcha_passed", true) end @@ -333,23 +351,30 @@ end -- check if pow cookie is valid function _M.check_pow_status(txn) local parsed_request_cookies = cookie.get_cookie_table(txn.sf:hdr("Cookie")) - local received_pow_cookie = parsed_request_cookies["z_ddos_pow"] or "" + local received_pow_cookie = parsed_request_cookies["_basedflare_pow"] or "" -- split the cookie up local split_cookie = utils.split(received_pow_cookie, "#") - if #split_cookie ~= 4 then + if #split_cookie ~= 5 then return end local given_user_key = split_cookie[1] local given_challenge_hash = split_cookie[2] - local given_answer = split_cookie[3] - local given_signature = split_cookie[4] + local given_expiry = split_cookie[3] + local given_answer = split_cookie[4] + local given_signature = split_cookie[5] + + -- expiry check + local number_expiry = tonumber(given_expiry, 10) + if number_expiry == nil or number_expiry <= core.now()['sec'] then + return + end -- regenerate the challenge and compare it - local generated_challenge_hash = utils.generate_secret(txn, pow_cookie_secret, given_user_key, false) + local generated_challenge_hash = utils.generate_challenge(txn, pow_cookie_secret, given_user_key, false) if given_challenge_hash ~= generated_challenge_hash then return end -- regenerate the signature and compare it - local generated_signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash .. given_answer) + local generated_signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash .. given_expiry .. given_answer) if given_signature == generated_signature then return txn:set_var("txn.pow_passed", true) end diff --git a/src/lua/scripts/templates.lua b/src/lua/scripts/templates.lua index e223f27..02fadb5 100644 --- a/src/lua/scripts/templates.lua +++ b/src/lua/scripts/templates.lua @@ -57,7 +57,7 @@ _M.noscript_extra = [[
  • Run this in a linux terminal (requires argon2 package installed):

    - echo "Q0g9IiQyIjtCPSQocHJpbnRmICcwJS4wcycgJChzZXEgMSAkNCkpO2VjaG8gIldvcmtpbmcuLi4iO0k9MDt3aGlsZSB0cnVlOyBkbyBIPSQoZWNobyAtbiAkQ0gkSSB8IGFyZ29uMiAkMSAtaWQgLXQgJDUgLWsgJDYgLXAgMSAtbCAzMiAtcik7RT0ke0g6MDokNH07W1sgJEUgPT0gJEIgXV0gJiYgZWNobyAiT3V0cHV0OiIgJiYgZWNobyAkMSMkMiMkMyMkSSAmJiBleGl0IDA7KChJKyspKTtkb25lOwo=" | base64 -d | bash -s %s %s %s %s %s %s + echo "Q0g9IiQyIjtCPSQocHJpbnRmIDAlLjBzICQoc2VxIDEgJDUpKTtlY2hvICJXb3JraW5nLi4uIjtJPTA7d2hpbGUgdHJ1ZTsgZG8gSD0kKGVjaG8gLW4gJENIJEkgfCBhcmdvbjIgJDEgLWlkIC10ICQ2IC1rICQ3IC1wIDEgLWwgMzIgLXIpO0U9JHtIOjA6JDV9O1tbICRFID09ICRCIF1dICYmIGVjaG8gIk91dHB1dDoiICYmIGVjaG8gJDEjJDIjJDMjJDQjJEkgJiYgZXhpdCAwOygoSSsrKSk7ZG9uZTsK" | base64 -d | bash -s %s %s %s %s %s %s %s
  • Paste the script output into the box and submit:
    From 1612e342ee50ec612b456c88b8385625d984a394 Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 21:05:10 +1100 Subject: [PATCH 08/15] rename BUCKET_DURATION since its now an expiry time --- INSTALLATION.md | 2 +- docker-compose.yml | 2 +- haproxy/template/trace.txt | 2 +- src/lua/libs/utils.lua | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/INSTALLATION.md b/INSTALLATION.md index 1245d4d..ba4962f 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -11,7 +11,7 @@ NOTE: Use either HCAPTCHA_ or RECAPTHCA_, not both. - POW_COOKIE_SECRET - different random string, a salt for pow cookies - HMAC_COOKIE_SECRET - different random string, a salt for pow cookies - RAY_ID - string to identify the HAProxy node by -- BUCKET_DURATION - how long between bucket changes, invalidating cookies +- CHALLENGE_EXPIRY - how long solution cookies last for, in seconds - CHALLENGE_INCLUDES_IP - any value, whether to lock solved challenges to IP or tor circuit - BACKEND_NAME - Optional, name of backend to build from hosts.map - SERVER_PREFIX - Optional, prefix of server names used in server-template diff --git a/docker-compose.yml b/docker-compose.yml index 4508446..86edc5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,7 +31,7 @@ services: - POW_COOKIE_SECRET=changeme - HMAC_COOKIE_SECRET=changeme - RAY_ID=docker - - BUCKET_DURATION=43200 + - CHALLENGE_EXPIRY=43200 - BACKEND_NAME=servers - SERVER_PREFIX=websrv - CHALLENGE_INCLUDES_IP=1 diff --git a/haproxy/template/trace.txt b/haproxy/template/trace.txt index be55d08..48410ef 100644 --- a/haproxy/template/trace.txt +++ b/haproxy/template/trace.txt @@ -7,4 +7,4 @@ tls=%[ssl_fc] tlsv=%sslv sni=%[ssl_fc_sni] vey_id=%[env(RAY_ID)] -bucket=%[env(BUCKET_DURATION)] +expiry=%[env(CHALLENGE_EXPIRY)] diff --git a/src/lua/libs/utils.lua b/src/lua/libs/utils.lua index 1160c7b..eea98ff 100644 --- a/src/lua/libs/utils.lua +++ b/src/lua/libs/utils.lua @@ -1,7 +1,7 @@ local _M = {} local sha = require("sha") -local bucket_duration = tonumber(os.getenv("BUCKET_DURATION")) +local challenge_expiry = tonumber(os.getenv("CHALLENGE_EXPIRY")) local challenge_includes_ip = os.getenv("CHALLENGE_INCLUDES_IP") local tor_control_port_password = os.getenv("TOR_CONTROL_PORT_PASSWORD") @@ -26,7 +26,7 @@ function _M.generate_challenge(context, salt, user_key, is_applet) local challenge_hash = sha.sha3_256(salt .. ip .. user_key .. user_agent) - local expiry = core.now()['sec'] + bucket_duration + local expiry = core.now()['sec'] + challenge_expiry return challenge_hash, expiry From 9f4c8e8fbb3ce771d1358794f8306d9309bea2a1 Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 21:05:47 +1100 Subject: [PATCH 09/15] increase the throttle from 100 -> 1 back to 10 to prevent crashing --- src/js/worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/worker.js b/src/js/worker.js index 8a6fcdc..b401b49 100644 --- a/src/js/worker.js +++ b/src/js/worker.js @@ -16,7 +16,7 @@ onmessage = async function(e) { ...argonOpts, }); // This throttle seems to really help some browsers not stop the workers abruptly - i % 10 === 0 && await new Promise(res => setTimeout(res, 1)); + i % 10 === 0 && await new Promise(res => setTimeout(res, 10)); if (hash.hashHex.startsWith(diffString) && ((parseInt(hash.hashHex[diffString.length],16) & 0xff >> (((diffString.length+1)*8)-diff)) === 0)) { From 9531049aa99f1cd75a48dfce6f585090396f72a0 Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 21:39:38 +1100 Subject: [PATCH 10/15] Update haproxy config, scripts & docker-compose to use simpler mroe organised files layout Make cookies sent from captcha/pow response be httponly --- docker-compose.yml | 10 ++-------- haproxy/haproxy.cfg | 18 +++++++++--------- src/lua/scripts/bot-check.lua | 12 ++++++------ 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 86edc5c..d0b413f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,14 +10,8 @@ services: dockerfile: haproxy/Dockerfile volumes: - ./haproxy/haproxy.cfg:/etc/haproxy/haproxy.cfg - - ./haproxy/map/ddos.map:/etc/haproxy/ddos.map - - ./haproxy/map/hosts.map:/etc/haproxy/hosts.map - - ./haproxy/map/backends.map:/etc/haproxy/backends.map - - ./haproxy/map/blocked.map:/etc/haproxy/blocked.map - - ./haproxy/map/whitelist.map:/etc/haproxy/whitelist.map - - ./haproxy/map/maintenance.map:/etc/haproxy/maintenance.map - - ./haproxy/template/maintenance.html:/etc/haproxy/maintenance.html - - ./haproxy/template/trace.txt:/etc/haproxy/trace.txt + - ./haproxy/map/:/etc/haproxy/map/ + - ./haproxy/template/:/etc/haproxy/template/ - ./src/lua/scripts/:/etc/haproxy/scripts/ - ./src/lua/libs/:/etc/haproxy/libs/ - ./src/js/:/etc/haproxy/js/ diff --git a/haproxy/haproxy.cfg b/haproxy/haproxy.cfg index d03dce1..f302c1b 100644 --- a/haproxy/haproxy.cfg +++ b/haproxy/haproxy.cfg @@ -40,14 +40,14 @@ frontend http-in #option forwardfor # drop requests with invalid host header - acl is_existing_vhost hdr(host),lower,map_str(/etc/haproxy/hosts.map) -m found + acl is_existing_vhost hdr(host),lower,map_str(/etc/haproxy/map/hosts.map) -m found http-request silent-drop unless is_existing_vhost # debug information at /.basedflare/cgi/trace - http-request return status 200 content-type "text/plain; charset=utf-8" lf-file /etc/haproxy/trace.txt if { path /.basedflare/cgi/trace } + http-request return status 200 content-type "text/plain; charset=utf-8" lf-file /etc/haproxy/template/trace.txt if { path /.basedflare/cgi/trace } # acl for blocked IPs/subnets - acl blocked_ip_or_subnet src,map_ip(/etc/haproxy/blocked.map) -m found + acl blocked_ip_or_subnet src,map_ip(/etc/haproxy/map/blocked.map) -m found http-request deny deny_status 403 if blocked_ip_or_subnet # ratelimit (and for tor, kill circuit) on POST bot-check. legitimate users shouldn't hit this. @@ -56,13 +56,13 @@ frontend http-in http-request tarpit if { sc_http_req_rate(0) gt 1 } # acl for lua check whitelisted IPs/subnets and some excluded paths - acl is_excluded src,map_ip(/etc/haproxy/whitelist.map) -m found + acl is_excluded src,map_ip(/etc/haproxy/map/whitelist.map) -m found acl is_excluded path /favicon.ico #add more # acl ORs for when ddos_mode_enabled acl ddos_mode_enabled_override hdr_cnt(xr3la1rfFc) eq 0 - acl ddos_mode_enabled hdr(host),lower,map(/etc/haproxy/ddos.map) -m bool - acl ddos_mode_enabled base,map(/etc/haproxy/ddos.map) -m bool + acl ddos_mode_enabled hdr(host),lower,map(/etc/haproxy/map/ddos.map) -m bool + acl ddos_mode_enabled base,map(/etc/haproxy/map/ddos.map) -m bool # serve challenge page scripts directly from haproxy http-request return file /etc/haproxy/js/argon2.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /.basedflare/js/argon2.js } @@ -70,8 +70,8 @@ frontend http-in http-request return file /etc/haproxy/js/worker.js status 200 content-type "application/javascript; charset=utf-8" hdr "cache-control" "public, max-age=300" if { path /.basedflare/js/worker.js } # acl for domains in maintenance mode to return maintenance page (after challenge page htp-request return rules, for the footerlogo) - acl maintenance_mode hdr(host),lower,map_str(/etc/haproxy/maintenance.map) -m found - http-request return lf-file /etc/haproxy/maintenance.html status 200 content-type "text/html; charset=utf-8" hdr "cache-control" "private, max-age=30" if maintenance_mode + acl maintenance_mode hdr(host),lower,map_str(/etc/haproxy/map/maintenance.map) -m found + http-request return lf-file /etc/haproxy/template/maintenance.html status 200 content-type "text/html; charset=utf-8" hdr "cache-control" "private, max-age=30" if maintenance_mode # create acl for bools updated by lua acl captcha_passed var(txn.captcha_passed) -m bool @@ -111,7 +111,7 @@ backend servers # placeholder servers, activated by LUA or the control panel server-template websrv 1-100 0.0.0.0:80 check disabled # use server based on hostname - use-server %[req.hdr(host),lower,map(/etc/haproxy/backends.map)] if TRUE + use-server %[req.hdr(host),lower,map(/etc/haproxy/map/backends.map)] if TRUE backend bot_check_post_throttle stick-table type ipv6 size 100k expire 60s store http_req_rate(60s) diff --git a/src/lua/scripts/bot-check.lua b/src/lua/scripts/bot-check.lua index 946b55f..c8e5236 100644 --- a/src/lua/scripts/bot-check.lua +++ b/src/lua/scripts/bot-check.lua @@ -38,7 +38,7 @@ local hmac_cookie_secret = os.getenv("HMAC_COOKIE_SECRET") local ray_id = os.getenv("RAY_ID") -- load captcha map and set hcaptcha/recaptch based off env vars -local captcha_map = Map.new("/etc/haproxy/ddos.map", Map._str); +local captcha_map = Map.new("/etc/haproxy/map/ddos.map", Map._str); local captcha_provider_domain = "" local captcha_classname = "" local captcha_script_src = "" @@ -68,8 +68,8 @@ function _M.setup_servers() if backend_name == nil or server_prefix == nil then return; end - local hosts_map = Map.new("/etc/haproxy/hosts.map", Map._str); - local handle = io.open("/etc/haproxy/hosts.map", "r") + local hosts_map = Map.new("/etc/haproxy/map/hosts.map", Map._str); + local handle = io.open("/etc/haproxy/map/hosts.map", "r") local line = handle:read("*line") local counter = 1 while line do @@ -77,7 +77,7 @@ function _M.setup_servers() local port_index = backend_host:match'^.*():' local backend_hostname = backend_host:sub(0, port_index-1) local backend_port = backend_host:sub(port_index + 1) - core.set_map("/etc/haproxy/backends.map", domain, server_prefix..counter) + core.set_map("/etc/haproxy/map/backends.map", domain, server_prefix..counter) local proxy = core.proxies[backend_name].servers[server_prefix..counter] proxy:set_addr(backend_hostname, backend_port) proxy:set_ready() @@ -214,7 +214,7 @@ function _M.view(applet) applet:add_header( "set-cookie", string.format( - "_basedflare_pow=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; Domain=.%s; SameSite=Strict;%s", + "_basedflare_pow=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; Domain=.%s; SameSite=Strict; HttpOnly;%s", combined_cookie, applet.headers['host'][0], secure_cookie_flag @@ -268,7 +268,7 @@ function _M.view(applet) applet:add_header( "set-cookie", string.format( - "_basedflare_captcha=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; Domain=.%s; SameSite=Strict;%s", + "_basedflare_captcha=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; Domain=.%s; SameSite=Strict; HttpOnly;%s", combined_cookie, applet.headers['host'][0], secure_cookie_flag From ef938270d870e91e7ea4120ba94b50007c55c4eb Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 21:45:23 +1100 Subject: [PATCH 11/15] update docker-compose with correct argon2 envs --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d0b413f..b5db76d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,8 +29,8 @@ services: - BACKEND_NAME=servers - SERVER_PREFIX=websrv - CHALLENGE_INCLUDES_IP=1 - - POW_TIME=2 - - POW_KB=512 + - ARGON_TIME=2 + - ARGON_KB=512 - POW_DIFFICULTY=24 - TOR_CONTROL_PORT_PASSWORD=changeme From eb92f6c31b728c634ece278ce6b543b0c60c29ab Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 21:46:29 +1100 Subject: [PATCH 12/15] Add back dummy time, shorter (3000ms) --- src/js/challenge.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/js/challenge.js b/src/js/challenge.js index 100e575..a678b34 100644 --- a/src/js/challenge.js +++ b/src/js/challenge.js @@ -73,7 +73,10 @@ const powFinished = new Promise(resolve => { const submitPow = (answer) => { window.localStorage.setItem('basedflare-pow-response', answer); stopPow(); - resolve({ answer }); + const dummyTime = 3000 - (Date.now()-start); + window.setTimeout(() => { + resolve({ answer }); + }, dummyTime); }; window.addEventListener('DOMContentLoaded', async () => { From 2089d790a23c415c32b296af76e3f3733a1f8fc1 Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 21:57:03 +1100 Subject: [PATCH 13/15] update INSTALLATION.md --- INSTALLATION.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/INSTALLATION.md b/INSTALLATION.md index ba4962f..1569b65 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -10,6 +10,7 @@ NOTE: Use either HCAPTCHA_ or RECAPTHCA_, not both. - CAPTCHA_COOKIE_SECRET - random string, a salt for captcha cookies - POW_COOKIE_SECRET - different random string, a salt for pow cookies - HMAC_COOKIE_SECRET - different random string, a salt for pow cookies +- TOR_CONTROL_PORT_PASSWORD - the control port password for tor daemon - RAY_ID - string to identify the HAProxy node by - CHALLENGE_EXPIRY - how long solution cookies last for, in seconds - CHALLENGE_INCLUDES_IP - any value, whether to lock solved challenges to IP or tor circuit @@ -18,7 +19,6 @@ NOTE: Use either HCAPTCHA_ or RECAPTHCA_, not both. - ARGON_TIME - argon2 iterations - ARGON_KB - argon2 memory usage in KB - POW_DIFFICULTY - pow difficulty -- TOR_CONTROL_PORT_PASSWORD - the control port password for tor daemon #### Run in docker (for testing/development) @@ -36,9 +36,11 @@ Requires HAProxy compiled with lua support, and version >=2.5 for the native lua - Clone the repo somewhere. `/var/www/haproxy-protection` works. - Copy [haproxy.cfg](haproxy/haproxy.cfg) to `/etc/haproxy/haproxy.cfg`. - Please note this configuration is very minimal, and is simply an example configuration for haproxy-protection. You are expected to customise it significantly or otherwise copy the relevant parts into your own haproxy config. -- Copy (preferably link) [scripts](src/scripts) to `/etc/haproxy/scripts`. -- Copy (preferably link) [libs](src/libs) to `/etc/haproxy/libs`. -- Copy the map files from haproxy folder to `/etc/haproxy`. +- Copy/link [scripts](src/lua/scripts) to `/etc/haproxy/scripts`. +- Copy/link [libs](src/lua/libs) to `/etc/haproxy/libs`. +- Copy/link [template](haproxy/template) to `/etc/haproxy/template`. +- Copy/link [js](src/js) to `/etc/haproxy/js`. +- Copy [map](haproxy/map) to `/etc/haproxy/map`. - Install argon2, and the lua argon2 module with luarocks: ```bash sudo apt install -y git lua5.3 liblua5.3-dev argon2 libargon2-dev luarocks From f231b865447b144bdd9c90768649a8f7c54da7cf Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 23:38:59 +1100 Subject: [PATCH 14/15] Ability to choose between sha256 or argon2 with env var close #21 --- INSTALLATION.md | 1 + docker-compose.yml | 1 + src/js/challenge.js | 37 ++++++++++------------------------- src/js/worker.js | 32 +++++++++++++++++++++--------- src/lua/libs/utils.lua | 3 +++ src/lua/scripts/bot-check.lua | 29 ++++++++++++++++----------- src/lua/scripts/templates.lua | 22 +++++++++++++++++++-- 7 files changed, 76 insertions(+), 49 deletions(-) diff --git a/INSTALLATION.md b/INSTALLATION.md index 1569b65..436299c 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -19,6 +19,7 @@ NOTE: Use either HCAPTCHA_ or RECAPTHCA_, not both. - ARGON_TIME - argon2 iterations - ARGON_KB - argon2 memory usage in KB - POW_DIFFICULTY - pow difficulty +- POW_TYPE - type of ahsh algorithm for pow "argon2" or "sha256" #### Run in docker (for testing/development) diff --git a/docker-compose.yml b/docker-compose.yml index b5db76d..768642d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: - ARGON_TIME=2 - ARGON_KB=512 - POW_DIFFICULTY=24 + - POW_TYPE=argon2 - TOR_CONTROL_PORT_PASSWORD=changeme nginx: diff --git a/src/js/challenge.js b/src/js/challenge.js index a678b34..4dcd4de 100644 --- a/src/js/challenge.js +++ b/src/js/challenge.js @@ -81,7 +81,7 @@ const powFinished = new Promise(resolve => { window.addEventListener('DOMContentLoaded', async () => { - const { time, kb, pow, diff } = document.querySelector('[data-pow]').dataset; + const { time, kb, pow, diff, mode } = document.querySelector('[data-pow]').dataset; window.addEventListener('storage', event => { if (event.key === 'basedflare-pow-response' && !finished) { console.log('Got answer', event.newValue, 'from storage event'); @@ -94,14 +94,15 @@ const powFinished = new Promise(resolve => { }); if (!wasmSupported) { - return insertError('browser does not support WebAssembly.'); + return insertError('Browser does not support WebAssembly.'); } - const argonOpts = { + const powOpts = { time: time, mem: kb, hashLen: 32, parallelism: 1, - type: argon2.ArgonType.Argon2id, + type: argon2 ? argon2.ArgonType.Argon2id : null, + mode: mode, }; console.log('Got pow', pow, 'with difficulty', diff); const eHashes = Math.pow(16, Math.floor(diff/8)) * ((diff%8)*2); @@ -128,34 +129,16 @@ const powFinished = new Promise(resolve => { submitPow(`${pow}#${answer}`); } for (let i = 0; i < workerThreads; i++) { - const argonWorker = new Worker('/.basedflare/js/worker.js'); - argonWorker.onmessage = messageHandler; - workers.push(argonWorker); + const powWorker = new Worker('/.basedflare/js/worker.js'); + powWorker.onmessage = messageHandler; + workers.push(powWorker); } for (let i = 0; i < workerThreads; i++) { await new Promise(res => setTimeout(res, 10)); - workers[i].postMessage([userkey, challenge, diff, diffString, argonOpts, i, workerThreads]); + workers[i].postMessage([userkey, challenge, diff, diffString, powOpts, i, workerThreads]); } } else { - //TODO: remove this section, it _will_ cause problems - console.warn('No webworker support, running in main/UI thread!'); - let i = 0; - const start = Date.now(); - while(true) { - const hash = await argon2.hash({ - pass: challenge + i.toString(), - salt: userkey, - ...argonOpts, - }); - if (hash.hashHex.startsWith(diffString) - && ((parseInt(hash.hashHex[diffString.length],16) & - 0xff >> (((diffString.length+1)*8)-diff)) === 0)) { - console.log('Main thread found solution:', hash.hashHex, 'in', (Date.now()-start)+'ms'); - break; - } - ++i; - } - submitPow(`${pow}#${i}`); + return insertError('Browser does not support Web Workers.'); } }); }).then((powResponse) => { diff --git a/src/js/worker.js b/src/js/worker.js index b401b49..9a120f0 100644 --- a/src/js/worker.js +++ b/src/js/worker.js @@ -1,7 +1,15 @@ -importScripts('/.basedflare/js/argon2.js'); +async function nativeHash(data, method) { + const buffer = new TextEncoder('utf-8').encode(data); + const hashBuffer = await crypto.subtle.digest(method, buffer) + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +} onmessage = async function(e) { - const [userkey, challenge, diff, diffString, argonOpts, id, threads] = e.data; + const [userkey, challenge, diff, diffString, powOpts, id, threads] = e.data; + if (powOpts.mode === "argon2") { + importScripts('/.basedflare/js/argon2.js'); + } console.log('Worker thread', id, 'started'); let i = id; if (id === 0) { @@ -10,15 +18,21 @@ onmessage = async function(e) { }, 500); } while(true) { - const hash = await argon2.hash({ - pass: challenge + i.toString(), - salt: userkey, - ...argonOpts, - }); + let hash; + if (powOpts.mode === "argon2") { + const argonHash = await argon2.hash({ + pass: challenge + i.toString(), + salt: userkey, + ...powOpts, + }); + hash = argonHash.hashHex; + } else { + hash = await nativeHash(userkey + challenge + i.toString(), 'sha-256'); + } // This throttle seems to really help some browsers not stop the workers abruptly i % 10 === 0 && await new Promise(res => setTimeout(res, 10)); - if (hash.hashHex.startsWith(diffString) - && ((parseInt(hash.hashHex[diffString.length],16) & + if (hash.toString().startsWith(diffString) + && ((parseInt(hash[diffString.length],16) & 0xff >> (((diffString.length+1)*8)-diff)) === 0)) { console.log('Worker', id, 'found solution'); postMessage([id, i]); diff --git a/src/lua/libs/utils.lua b/src/lua/libs/utils.lua index eea98ff..00cd71a 100644 --- a/src/lua/libs/utils.lua +++ b/src/lua/libs/utils.lua @@ -43,6 +43,9 @@ end -- return true if hash passes difficulty function _M.checkdiff(hash, diff) + if #hash == 0 then + return false + end local i = 1 for j = 0, (diff-8), 8 do if hash:sub(i, i) ~= "0" then diff --git a/src/lua/scripts/bot-check.lua b/src/lua/scripts/bot-check.lua index c8e5236..f42b28a 100644 --- a/src/lua/scripts/bot-check.lua +++ b/src/lua/scripts/bot-check.lua @@ -9,13 +9,12 @@ local url = require("url") local utils = require("utils") local cookie = require("cookie") local json = require("json") -local sha = require("sha") local randbytes = require("randbytes") local templates = require("templates") -- POW +local pow_type = os.getenv("POW_TYPE") or "argon2" local pow_difficulty = tonumber(os.getenv("POW_DIFFICULTY") or 18) - -- argon2 local argon2 = require("argon2") local argon_kb = tonumber(os.getenv("ARGON_KB") or 6000) @@ -25,9 +24,8 @@ argon2.m_cost(argon_kb) argon2.parallelism(1) argon2.hash_len(32) argon2.variant(argon2.variants.argon2_id) - -- sha2 --- TODO +local sha = require("sha") -- environment variables local captcha_secret = os.getenv("HCAPTCHA_SECRET") or os.getenv("RECAPTCHA_SECRET") @@ -144,14 +142,20 @@ function _M.view(applet) captcha_sitekey, captcha_script_src) else pow_body = templates.pow_section - noscript_extra_body = string.format(templates.noscript_extra, user_key, + local noscript_extra + if pow_type == "argon2" then + noscript_extra = templates.noscript_extra_argon2 + else + noscript_extra = templates.noscript_extra_sha256 + end + noscript_extra_body = string.format(noscript_extra, user_key, challenge_hash, expiry, signature, math.ceil(pow_difficulty/8), argon_time, argon_kb) end -- sub in the body sections response_body = string.format(templates.body, combined_challenge, - pow_difficulty, argon_time, argon_kb, + pow_difficulty, argon_time, argon_kb, pow_type, site_name_body, pow_body, captcha_body, noscript_extra_body, ray_id) response_status_code = 403 @@ -200,11 +204,14 @@ function _M.view(applet) if given_signature == generated_signature then -- do the work with their given answer - local full_hash = argon2.hash_encoded(given_challenge_hash .. given_answer, given_user_key) - - -- check the output is correct - local hash_output = utils.split(full_hash, '$')[6]:sub(0, 43) -- https://github.com/thibaultcha/lua-argon2/issues/37 - local hex_hash_output = sha.bin_to_hex(sha.base64_to_bin(hash_output)); + local hex_hash_output = "" + if pow_type == "argon2" then + local encoded_argon_hash = argon2.hash_encoded(given_challenge_hash .. given_answer, given_user_key) + local trimmed_argon_hash = utils.split(encoded_argon_hash, '$')[6]:sub(0, 43) -- https://github.com/thibaultcha/lua-argon2/issues/37 + hex_hash_output = sha.bin_to_hex(sha.base64_to_bin(trimmed_argon_hash)); + else + hex_hash_output = sha.sha256(given_user_key .. given_challenge_hash .. given_answer) + end if utils.checkdiff(hex_hash_output, pow_difficulty) then diff --git a/src/lua/scripts/templates.lua b/src/lua/scripts/templates.lua index 02fadb5..93e1b46 100644 --- a/src/lua/scripts/templates.lua +++ b/src/lua/scripts/templates.lua @@ -32,7 +32,7 @@ _M.body = [[ - + %s %s %s @@ -50,7 +50,7 @@ _M.body = [[ ]] -_M.noscript_extra = [[ +_M.noscript_extra_argon2 = [[
    No JavaScript?
      @@ -68,6 +68,24 @@ _M.noscript_extra = [[
    ]] +_M.noscript_extra_sha256 = [[ +
    + No JavaScript? +
      +
    1. +

      Run this in a linux terminal (requires perl):

      + + echo "dXNlIHN0cmljdDt1c2UgRGlnZXN0OjpTSEEgcXcoc2hhMjU2X2hleCk7cHJpbnQgIldvcmtpbmcuLi4iO215JGM9IiRBUkdWWzBdIi4iJEFSR1ZbMV0iO215JGlkPSRBUkdWWzRdKzA7bXkkZD0iMCJ4JGlkO215JGk9MDt3aGlsZSgxKXtsYXN0IGlmICRkIGVxIHN1YnN0ciBzaGEyNTZfaGV4KCRjLCRpKSwwLCRpZDskaSsrfXByaW50IlxuT3V0cHV0OlxuJEFSR1ZbMF0jJEFSR1ZbMV0jJEFSR1ZbMl0jJEFSR1ZbM10jJGlcbiI=" | base64 -d | perl -w - %s %s %s %s %s %s %s + +
    2. Paste the script output into the box and submit: + + +
      +
    3. +
    +
    +]] + -- title with favicon and hostname _M.site_name_section = [[

    From 608a7bc9ef9cff9543a675c00234d3a427fdf0df Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 11 Feb 2023 23:42:22 +1100 Subject: [PATCH 15/15] Update README & LICENSE, definitely belong here now :^) --- LICENSE.md | 3 ++- README.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/LICENSE.md b/LICENSE.md index 3457a53..1870857 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,7 @@ MIT License Copyright (c) 2021 Eugene Prodan +Copyright (c) 2022-2023 Thomas Lynch (fatchan) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +19,4 @@ 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. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index 76db049..a043ba7 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ Integrates with https://gitgud.io/fatchan/haproxy-panel-next to add/remove/edit #### Features / improvements in this fork: - Implement a proof-of-work mode, in addition to the existing captcha only mode. +- Ability to choose between argon2 or sha256 proof of work modes. +- Sharing POW answers with storage events to prevent unnecessary re-solving when opening multiple tabs. - Supports either hcaptcha or recaptcha. - Support .onion/tor with the HAProxy PROXY protocol, using circuit identifiers as a substitute for IPs. - Allow users without javascript to solve the POW by providing a shell script and html form inside `noscript` tags.