Merge branch 'next' into 'master'

'next' into master

Closes #21, #18, and #20

See merge request fatchan/haproxy-protection!3
This commit is contained in:
Thomas Lynch
2023-02-12 02:18:50 +00:00
31 changed files with 497 additions and 358 deletions

View File

@ -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 #### Environment variables
For docker, these are in docker-compose.yml. For production deployments, add them to `/etc/default/haproxy`. For docker, these are in docker-compose.yml. For production deployments, add them to `/etc/default/haproxy`.
@ -31,15 +10,16 @@ NOTE: Use either HCAPTCHA_ or RECAPTHCA_, not both.
- CAPTCHA_COOKIE_SECRET - random string, a salt for captcha cookies - CAPTCHA_COOKIE_SECRET - random string, a salt for captcha cookies
- POW_COOKIE_SECRET - different random string, a salt for pow cookies - POW_COOKIE_SECRET - different random string, a salt for pow cookies
- HMAC_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 - 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 - 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 - BACKEND_NAME - Optional, name of backend to build from hosts.map
- SERVER_PREFIX - Optional, prefix of server names used in server-template - SERVER_PREFIX - Optional, prefix of server names used in server-template
- POW_TIME - argon2 iterations - ARGON_TIME - argon2 iterations
- POW_KB - argon2 memory usage in KB - ARGON_KB - argon2 memory usage in KB
- POW_DIFFICULTY - pow "difficulty" (you should use all 3 POW_ parameters to tune the difficulty) - POW_DIFFICULTY - pow difficulty
- TOR_CONTROL_PORT_PASSWORD - the control port password for tor daemon - POW_TYPE - type of ahsh algorithm for pow "argon2" or "sha256"
#### Run in docker (for testing/development) #### Run in docker (for testing/development)
@ -57,9 +37,11 @@ Requires HAProxy compiled with lua support, and version >=2.5 for the native lua
- Clone the repo somewhere. `/var/www/haproxy-protection` works. - Clone the repo somewhere. `/var/www/haproxy-protection` works.
- Copy [haproxy.cfg](haproxy/haproxy.cfg) to `/etc/haproxy/haproxy.cfg`. - 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. - 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/link [scripts](src/lua/scripts) to `/etc/haproxy/scripts`.
- Copy (preferably link) [libs](src/libs) to `/etc/haproxy/libs`. - Copy/link [libs](src/lua/libs) to `/etc/haproxy/libs`.
- Copy the map files from haproxy folder to `/etc/haproxy`. - 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: - Install argon2, and the lua argon2 module with luarocks:
```bash ```bash
sudo apt install -y git lua5.3 liblua5.3-dev argon2 libargon2-dev luarocks sudo apt install -y git lua5.3 liblua5.3-dev argon2 libargon2-dev luarocks
@ -85,8 +67,3 @@ ControlPort 9051
HashedControlPassword xxxxxxxxxxxxxxxxx HashedControlPassword xxxxxxxxxxxxxxxxx
``` ```
- Don't forget to restart tor - 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)")

View File

@ -1,6 +1,7 @@
MIT License MIT License
Copyright (c) 2021 Eugene Prodan Copyright (c) 2021 Eugene Prodan
Copyright (c) 2022-2023 Thomas Lynch (fatchan) <thomas@69420.me>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal 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 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.

40
README.md Normal file
View File

@ -0,0 +1,40 @@
## 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.
- 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.
- 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`

View File

@ -5,22 +5,19 @@ services:
network_mode: host network_mode: host
ports: ports:
- 80:80 - 80:80
# - 2000:2000 #runtime api
# - 2001:2001 #dataplaneapi
build: build:
context: ./ context: ./
dockerfile: haproxy/Dockerfile dockerfile: haproxy/Dockerfile
volumes: volumes:
- ./haproxy/haproxy.cfg:/etc/haproxy/haproxy.cfg - ./haproxy/haproxy.cfg:/etc/haproxy/haproxy.cfg
- ./haproxy/ddos.map:/etc/haproxy/ddos.map - ./haproxy/dataplaneapi.hcl:/etc/haproxy/dataplaneapi.hcl
- ./haproxy/hosts.map:/etc/haproxy/hosts.map - ./haproxy/map/:/etc/haproxy/map/
- ./haproxy/backends.map:/etc/haproxy/backends.map - ./haproxy/template/:/etc/haproxy/template/
- ./haproxy/blocked.map:/etc/haproxy/blocked.map - ./src/lua/scripts/:/etc/haproxy/scripts/
- ./haproxy/whitelist.map:/etc/haproxy/whitelist.map - ./src/lua/libs/:/etc/haproxy/libs/
- ./haproxy/maintenance.map:/etc/haproxy/maintenance.map - ./src/js/:/etc/haproxy/js/
- ./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
environment: environment:
# These are the hcaptcha and recaptcha test keys, not leaking any dont worry :^) # These are the hcaptcha and recaptcha test keys, not leaking any dont worry :^)
- HCAPTCHA_SITEKEY=20000000-ffff-ffff-ffff-000000000002 - HCAPTCHA_SITEKEY=20000000-ffff-ffff-ffff-000000000002
@ -31,13 +28,14 @@ services:
- POW_COOKIE_SECRET=changeme - POW_COOKIE_SECRET=changeme
- HMAC_COOKIE_SECRET=changeme - HMAC_COOKIE_SECRET=changeme
- RAY_ID=docker - RAY_ID=docker
- BUCKET_DURATION=43200 - CHALLENGE_EXPIRY=43200
- BACKEND_NAME=servers - BACKEND_NAME=servers
- SERVER_PREFIX=websrv - SERVER_PREFIX=websrv
- CHALLENGE_INCLUDES_IP=1 - CHALLENGE_INCLUDES_IP=1
- POW_TIME=2 - ARGON_TIME=2
- POW_KB=512 - ARGON_KB=512
- POW_DIFFICULTY=25 - POW_DIFFICULTY=24
- POW_TYPE=argon2
- TOR_CONTROL_PORT_PASSWORD=changeme - TOR_CONTROL_PORT_PASSWORD=changeme
nginx: nginx:

View File

@ -17,7 +17,8 @@ RUN set -eux; \
--uid 99 \ --uid 99 \
haproxy haproxy
ENV HAPROXY_URL http://www.haproxy.org/download/2.6/src/snapshot/haproxy-ss-LATEST.tar.gz ENV HAPROXY_URL http://www.haproxy.org/download/2.7/src/snapshot/haproxy-ss-LATEST.tar.gz
ENV DATAPLANEAPI_URL https://github.com/haproxytech/dataplaneapi/releases/download/v2.7.2/dataplaneapi_2.7.2_Linux_x86_64.tar.gz
# see https://sources.debian.net/src/haproxy/jessie/debian/rules/ for some helpful navigation of the possible "make" arguments # see https://sources.debian.net/src/haproxy/jessie/debian/rules/ for some helpful navigation of the possible "make" arguments
RUN set -eux; \ RUN set -eux; \
@ -37,6 +38,11 @@ RUN set -eux; \
; \ ; \
rm -rf /var/lib/apt/lists/*; \ rm -rf /var/lib/apt/lists/*; \
\ \
wget -O dataplaneapi_Linux_x86_64.tar.gz "$DATAPLANEAPI_URL"; \
tar -zxvf dataplaneapi_Linux_x86_64.tar.gz; \
chmod +x build/dataplaneapi; \
cp build/dataplaneapi /usr/local/bin/; \
\
wget -O haproxy.tar.gz "$HAPROXY_URL"; \ wget -O haproxy.tar.gz "$HAPROXY_URL"; \
# echo "$HAPROXY_SHA256 *haproxy.tar.gz" | sha256sum -c; \ # echo "$HAPROXY_SHA256 *haproxy.tar.gz" | sha256sum -c; \
mkdir -p /usr/src/haproxy; \ mkdir -p /usr/src/haproxy; \

27
haproxy/dataplaneapi.hcl Normal file
View File

@ -0,0 +1,27 @@
config_version = 2
name = "basedflare"
mode = "single"
dataplaneapi {
host = "127.0.0.1"
port = 2001
user "admin" {
insecure = true
password = "admin"
}
transaction {
transaction_dir = "/tmp/haproxy"
}
advertised {}
}
haproxy {
config_file = "/etc/haproxy/haproxy.cfg"
haproxy_bin = "/usr/local/sbin/haproxy"
reload {
reload_delay = 5
reload_cmd = "service haproxy reload"
restart_cmd = "service haproxy restart"
reload_strategy = "custom"
}
}

View File

@ -1,2 +0,0 @@
localhost 1
localhost/captcha 2

View File

@ -18,17 +18,21 @@ defaults
timeout server 50000ms timeout server 50000ms
timeout tarpit 5000ms timeout tarpit 5000ms
#frontend stats-frontend # program api
# bind *:2000 # command dataplaneapi -f /etc/haproxy/dataplaneapi.hcl --update-map-files
# option tcplog # no option start-on-reload
# mode tcp #
# acl white_list src xxx.xxx.xxx.xxx # frontend stats-frontend
# tcp-request connection reject unless white_list # bind *:2000
# default_backend stats-backend # option tcplog
# mode tcp
#backend stats-backend # acl white_list src 127.0.0.1
# mode tcp # tcp-request connection reject unless white_list
# server stats-localhost 127.0.0.1:1999 # default_backend stats-backend
#
# backend stats-backend
# mode tcp
# server stats-localhost 127.0.0.1:1999
frontend http-in frontend http-in
@ -40,38 +44,38 @@ frontend http-in
#option forwardfor #option forwardfor
# drop requests with invalid host header # 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 http-request silent-drop unless is_existing_vhost
# debug only, /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 /cdn-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 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 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. # 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 lua.kill-tor-circuit if { sc_http_req_rate(0) gt 1 }
http-request tarpit if { sc_http_req_rate(0) gt 1 } http-request tarpit if { sc_http_req_rate(0) gt 1 }
# acl for lua check whitelisted IPs/subnets and some excluded paths # 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 is_excluded path /favicon.ico #add more
# acl ORs for when ddos_mode_enabled # acl ORs for when ddos_mode_enabled
acl ddos_mode_enabled_override hdr_cnt(xr3la1rfFc) eq 0 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 hdr(host),lower,map(/etc/haproxy/map/ddos.map) -m bool
acl ddos_mode_enabled base,map(/etc/haproxy/ddos.map) -m bool acl ddos_mode_enabled base,map(/etc/haproxy/map/ddos.map) -m bool
# serve challenge page scripts directly from haproxy # 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 /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 /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 /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 /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 /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 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 acl maintenance_mode hdr(host),lower,map_str(/etc/haproxy/map/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 /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 # create acl for bools updated by lua
acl captcha_passed var(txn.captcha_passed) -m bool acl captcha_passed var(txn.captcha_passed) -m bool
@ -80,14 +84,14 @@ frontend http-in
acl validate_pow var(txn.validate_pow) -m bool acl validate_pow var(txn.validate_pow) -m bool
# check pow/captcha and show page if necessary # check pow/captcha and show page if necessary
acl on_captcha_url path /bot-check acl on_bot_check path /.basedflare/bot-check
http-request use-service lua.hcaptcha-view if on_captcha_url !is_excluded http-request use-service lua.bot-check if on_bot_check !is_excluded
# challenge decisions, checking, and redirecting to /bot-check # 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.decide-checks-necessary if !is_excluded !on_bot_check ddos_mode_enabled
http-request lua.hcaptcha-check if !is_excluded !on_captcha_url validate_captcha http-request lua.captcha-check if !is_excluded !on_bot_check 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 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 /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 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) # 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 } http-response set-header X-Cache-Status HIT if !{ srv_id -m found }
@ -111,7 +115,7 @@ backend servers
# placeholder servers, activated by LUA or the control panel # placeholder servers, activated by LUA or the control panel
server-template websrv 1-100 0.0.0.0:80 check disabled server-template websrv 1-100 0.0.0.0:80 check disabled
# use server based on hostname # 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 backend bot_check_post_throttle
stick-table type ipv6 size 100k expire 60s store http_req_rate(60s) stick-table type ipv6 size 100k expire 60s store http_req_rate(60s)

View File

@ -1,29 +0,0 @@
importScripts('/js/argon2.js');
onmessage = async function(e) {
const [userkey, challenge, diff, diffString, argonOpts, id, threads] = e.data;
console.log('Worker thread', id, 'started');
let i = id;
if (id === 0) {
setInterval(() => {
postMessage([i]);
}, 500);
}
while(true) {
const hash = await argon2.hash({
pass: challenge + i.toString(),
salt: userkey,
...argonOpts,
});
// 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) &
0xff >> (((diffString.length+1)*8)-diff)) === 0)) {
console.log('Worker', id, 'found solution');
postMessage([id, i]);
break;
}
i+=threads;
}
}

2
haproxy/map/ddos.map Normal file
View File

@ -0,0 +1,2 @@
localhost 1
localhost/test 2

View File

@ -7,7 +7,7 @@
:root{--text-color:#c5c8c6;--bg-color:#1d1f21} :root{--text-color:#c5c8c6;--bg-color:#1d1f21}
@media (prefers-color-scheme:light){:root{--text-color:#333;--bg-color:#EEE}} @media (prefers-color-scheme:light){:root{--text-color:#333;--bg-color:#EEE}}
a,a:visited{color:var(--text-color)} 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} 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;} code{background-color:#dfdfdf30;border-radius:3px;padding:0 3px;}
img,h3,p{margin:0 0 5px 0} 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
<h3 class="pt">Under maintenance. Please try again soon!</h3> <h3 class="pt">Under maintenance. Please try again soon!</h3>
<footer> <footer>
<p>Security and Performance by <a href="https://gitgud.io/fatchan/haproxy-protection/">haproxy-protection</a></p> <p>Security and Performance by <a href="https://gitgud.io/fatchan/haproxy-protection/">haproxy-protection</a></p>
<p>Vey ID: <code>553d0499f419bd4c66ff48f7d0a27706</code></p> <p>Node: <code>%[env(RAY_ID)]</code></p>
</footer> </footer>
</body> </body>
</html> </html>

View File

@ -7,4 +7,4 @@ tls=%[ssl_fc]
tlsv=%sslv tlsv=%sslv
sni=%[ssl_fc_sni] sni=%[ssl_fc_sni]
vey_id=%[env(RAY_ID)] vey_id=%[env(RAY_ID)]
bucket=%[env(BUCKET_DURATION)] expiry=%[env(CHALLENGE_EXPIRY)]

View File

@ -25,6 +25,7 @@ const wasmSupported = (() => {
return new WebAssembly.Instance(module) instanceof WebAssembly.Instance; return new WebAssembly.Instance(module) instanceof WebAssembly.Instance;
} }
} catch (e) { } catch (e) {
console.error(e);
} }
return false; return false;
})(); })();
@ -37,7 +38,7 @@ function postResponse(powResponse, captchaResponse) {
body['h-captcha-response'] = captchaResponse; body['h-captcha-response'] = captchaResponse;
body['g-recaptcha-response'] = captchaResponse; body['g-recaptcha-response'] = captchaResponse;
} }
fetch('/bot-check', { fetch('/.basedflare/bot-check', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@ -51,38 +52,68 @@ function postResponse(powResponse, captchaResponse) {
} else if (s >= 500) { } else if (s >= 500) {
return insertError('server responded with error.'); return insertError('server responded with error.');
} }
window.localStorage.setItem('basedflare-redirect', Math.random());
finishRedirect(); finishRedirect();
}).catch(err => { }).catch(() => {
insertError('failed to send challenge response.'); 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();
const dummyTime = 3000 - (Date.now()-start);
window.setTimeout(() => {
resolve({ answer });
}, dummyTime);
};
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
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');
stopPow();
resolve({ answer: event.newValue, localStorage: true });
} else if (event.key === 'basedflare-redirect') {
console.log('Redirecting, solved in another tab');
finishRedirect();
}
});
if (!wasmSupported) { if (!wasmSupported) {
return insertError('browser does not support WebAssembly.'); return insertError('Browser does not support WebAssembly.');
} }
const { time, kb, pow, diff } = document.querySelector('[data-pow]').dataset; const powOpts = {
const argonOpts = {
time: time, time: time,
mem: kb, mem: kb,
hashLen: 32, hashLen: 32,
parallelism: 1, parallelism: 1,
type: argon2.ArgonType.Argon2id, type: argon2 ? argon2.ArgonType.Argon2id : null,
mode: mode,
}; };
console.log('Got pow', pow, 'with difficulty', diff); console.log('Got pow', pow, 'with difficulty', diff);
const eHashes = Math.pow(16, Math.floor(diff/8)) * ((diff%8)*2); const eHashes = Math.pow(16, Math.floor(diff/8)) * ((diff%8)*2);
const diffString = '0'.repeat(Math.floor(diff/8)); const diffString = '0'.repeat(Math.floor(diff/8));
const combined = pow; const [userkey, challenge] = pow.split("#");
const [userkey, challenge, signature] = combined.split("#");
const start = Date.now();
if (window.Worker) { if (window.Worker) {
const cpuThreads = window.navigator.hardwareConcurrency; const cpuThreads = window.navigator.hardwareConcurrency;
const isTor = location.hostname.endsWith('.onion'); const isTor = location.hostname.endsWith('.onion');
/* Try to use all threads on tor, because tor limits threads for anti fingerprinting but this /* 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 */ makes it awfully slow because workerThreads will always be = 1 */
const workerThreads = isTor ? cpuThreads : Math.max(Math.ceil(cpuThreads/2),cpuThreads-1); const workerThreads = (isTor || cpuThreads === 2) ? cpuThreads : Math.max(Math.ceil(cpuThreads/2),cpuThreads-1);
let finished = false;
const messageHandler = (e) => { const messageHandler = (e) => {
if (e.data.length === 1) { if (e.data.length === 1) {
const totalHashes = e.data[0]; //assumes all worker threads are same speed const totalHashes = e.data[0]; //assumes all worker threads are same speed
@ -93,64 +124,38 @@ const powFinished = new Promise((resolve, reject) => {
return updateElem('.powstatus', `Proof-of-work: ${hps}H/s, ~${remainingSec}s remaining`); return updateElem('.powstatus', `Proof-of-work: ${hps}H/s, ~${remainingSec}s remaining`);
} }
if (finished) { return; } 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; const [workerId, answer] = e.data;
console.log('Worker', workerId, 'returned answer', answer, 'in', Date.now()-start+'ms'); console.log('Worker', workerId, 'returned answer', answer, 'in', Date.now()-start+'ms');
const dummyTime = 5000 - (Date.now()-start); submitPow(`${pow}#${answer}`);
window.setTimeout(() => {
resolve(`${combined}#${answer}`);
}, dummyTime);
}
const workers = [];
for (let i = 0; i < workerThreads; i++) {
const argonWorker = new Worker('/js/worker.js');
argonWorker.onmessage = messageHandler;
workers.push(argonWorker);
} }
for (let i = 0; i < workerThreads; i++) { for (let i = 0; i < workerThreads; i++) {
await new Promise(res => setTimeout(res, 100)); const powWorker = new Worker('/.basedflare/js/worker.js');
workers[i].postMessage([userkey, challenge, diff, diffString, argonOpts, i, workerThreads]); 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, powOpts, i, workerThreads]);
} }
} else { } else {
console.warn('No webworker support, running in main/UI thread!'); return insertError('Browser does not support Web Workers.');
let i = 0;
let 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;
}
const dummyTime = 5000 - (Date.now()-start);
window.setTimeout(() => {
resolve(`${combined}#${i}`);
}, dummyTime);
} }
}); });
}).then((powResponse) => { }).then((powResponse) => {
const hasCaptchaForm = document.getElementById('captcha'); const hasCaptchaForm = document.getElementById('captcha');
if (!hasCaptchaForm) { if (!hasCaptchaForm && !powResponse.localStorage) {
postResponse(powResponse); postResponse(powResponse.answer);
} }
return powResponse; return powResponse.answer;
}).catch((e) => {
console.error(e);
}); });
function onCaptchaSubmit(captchaResponse) { function onCaptchaSubmit(captchaResponse) {
const captchaElem = document.querySelector('[data-sitekey]'); const captchaElem = document.querySelector('[data-sitekey]');
captchaElem.insertAdjacentHTML('afterend', `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`); captchaElem.insertAdjacentHTML('afterend', `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`);
captchaElem.remove(); captchaElem.remove();
powFinished.then((powResponse) => { powFinished.then(powResponse => {
postResponse(powResponse, captchaResponse); postResponse(powResponse, captchaResponse);
}); });
} }

43
src/js/worker.js Normal file
View File

@ -0,0 +1,43 @@
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, 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) {
setInterval(() => {
postMessage([i]);
}, 500);
}
while(true) {
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.toString().startsWith(diffString)
&& ((parseInt(hash[diffString.length],16) &
0xff >> (((diffString.length+1)*8)-diff)) === 0)) {
console.log('Worker', id, 'found solution');
postMessage([id, i]);
break;
}
i+=threads;
}
}

View File

@ -1,16 +1,12 @@
local _M = {} local _M = {}
local sha = require("sha") local sha = require("sha")
local secret_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 challenge_includes_ip = os.getenv("CHALLENGE_INCLUDES_IP")
local tor_control_port_password = os.getenv("TOR_CONTROL_PORT_PASSWORD") local tor_control_port_password = os.getenv("TOR_CONTROL_PORT_PASSWORD")
-- generate the challenge hash/user hash -- generate the challenge hash/user hash
function _M.generate_secret(context, salt, user_key, is_applet) function _M.generate_challenge(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)
-- optional IP to lock challenges/user_keys to IP (for clearnet or single-onion aka 99% of cases) -- optional IP to lock challenges/user_keys to IP (for clearnet or single-onion aka 99% of cases)
local ip = "" 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 "" user_agent = context.sf:req_fhdr('user-agent') or ""
end 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'] + challenge_expiry
return challenge_hash, expiry
end end
@ -43,6 +43,9 @@ end
-- return true if hash passes difficulty -- return true if hash passes difficulty
function _M.checkdiff(hash, diff) function _M.checkdiff(hash, diff)
if #hash == 0 then
return false
end
local i = 1 local i = 1
for j = 0, (diff-8), 8 do for j = 0, (diff-8), 8 do
if hash:sub(i, i) ~= "0" then if hash:sub(i, i) ~= "0" then
@ -59,11 +62,10 @@ end
function _M.send_tor_control_port(circuit_identifier) function _M.send_tor_control_port(circuit_identifier)
local tcp = core.tcp(); local tcp = core.tcp();
tcp:settimeout(1); 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 -- not buffered, so we are better off sending it all at once
tcp:send('AUTHENTICATE "' .. tor_control_port_password .. '"\nCLOSECIRCUIT ' .. circuit_identifier ..'\n') tcp:send('AUTHENTICATE "' .. tor_control_port_password .. '"\nCLOSECIRCUIT ' .. circuit_identifier ..'\n')
tcp:close() tcp:close()
end end
return _M return _M

View File

@ -1,25 +1,33 @@
_M = {} _M = {}
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 argon2 = require("argon2")
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.parallelism(1)
argon2.hash_len(32)
argon2.variant(argon2.variants.argon2_id)
-- Testing only -- Testing only
-- require("socket") -- require("socket")
-- require("print_r") -- require("print_r")
-- main libs
local url = require("url")
local utils = require("utils")
local cookie = require("cookie")
local json = require("json")
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)
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
local sha = require("sha")
-- environment variables
local captcha_secret = os.getenv("HCAPTCHA_SECRET") or os.getenv("RECAPTCHA_SECRET") 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_sitekey = os.getenv("HCAPTCHA_SITEKEY") or os.getenv("RECAPTCHA_SITEKEY")
local captcha_cookie_secret = os.getenv("CAPTCHA_COOKIE_SECRET") local captcha_cookie_secret = os.getenv("CAPTCHA_COOKIE_SECRET")
@ -27,7 +35,8 @@ local pow_cookie_secret = os.getenv("POW_COOKIE_SECRET")
local hmac_cookie_secret = os.getenv("HMAC_COOKIE_SECRET") local hmac_cookie_secret = os.getenv("HMAC_COOKIE_SECRET")
local ray_id = os.getenv("RAY_ID") local ray_id = os.getenv("RAY_ID")
local captcha_map = Map.new("/etc/haproxy/ddos.map", Map._str); -- load captcha map and set hcaptcha/recaptch based off env vars
local captcha_map = Map.new("/etc/haproxy/map/ddos.map", Map._str);
local captcha_provider_domain = "" local captcha_provider_domain = ""
local captcha_classname = "" local captcha_classname = ""
local captcha_script_src = "" local captcha_script_src = ""
@ -47,6 +56,7 @@ else
captcha_backend_name = "recaptcha" captcha_backend_name = "recaptcha"
end end
-- setup initial server backends based on hosts.map into backends.map
function _M.setup_servers() function _M.setup_servers()
if pow_difficulty < 8 then if pow_difficulty < 8 then
error("POW_DIFFICULTY must be > 8. Around 16-32 is better") error("POW_DIFFICULTY must be > 8. Around 16-32 is better")
@ -56,8 +66,8 @@ function _M.setup_servers()
if backend_name == nil or server_prefix == nil then if backend_name == nil or server_prefix == nil then
return; return;
end end
local hosts_map = Map.new("/etc/haproxy/hosts.map", Map._str); local hosts_map = Map.new("/etc/haproxy/map/hosts.map", Map._str);
local handle = io.open("/etc/haproxy/hosts.map", "r") local handle = io.open("/etc/haproxy/map/hosts.map", "r")
local line = handle:read("*line") local line = handle:read("*line")
local counter = 1 local counter = 1
while line do while line do
@ -65,7 +75,7 @@ function _M.setup_servers()
local port_index = backend_host:match'^.*():' local port_index = backend_host:match'^.*():'
local backend_hostname = backend_host:sub(0, port_index-1) local backend_hostname = backend_host:sub(0, port_index-1)
local backend_port = backend_host:sub(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] local proxy = core.proxies[backend_name].servers[server_prefix..counter]
proxy:set_addr(backend_hostname, backend_port) proxy:set_addr(backend_hostname, backend_port)
proxy:set_ready() proxy:set_ready()
@ -75,103 +85,6 @@ function _M.setup_servers()
handle:close() handle:close()
end end
-- main page template
local body_template = [[
<!DOCTYPE html>
<html>
<head>
<meta name='viewport' content='width=device-width initial-scale=1'>
<title>Hold on...</title>
<style>
:root{--text-color:#c5c8c6;--bg-color:#1d1f21}
@media (prefers-color-scheme:light){:root{--text-color:#333;--bg-color:#EEE}}
.h-captcha,.g-recaptcha{min-height:85px;display:block}
.red{color:red;font-weight:bold}
.powstatus{color:green;font-weight:bold}
a,a:visited{color:var(--text-color)}
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;max-width:1200px;margin:0 auto;padding: 0 20px}
details{transition: border-left-color 0.5s;max-width:1200px;text-align:left;border-left: 2px solid var(--text-color);padding:10px}
code{background-color:#dfdfdf30;border-radius:3px;padding:0 3px;}
img,h3,p{margin:0 0 5px 0}
footer{font-size:x-small;margin-top:auto;margin-bottom:20px;text-align:center}
img{display:inline}
.pt{padding-top:15vh;display:flex;align-items:center;word-break:break-all}
.pt img{margin-right:10px}
details[open]{border-left-color: #1400ff}
.lds-ring{display:inline-block;position:relative;width:80px;height:80px}.lds-ring div{box-sizing:border-box;display:block;position:absolute;width:32px;height:32px;margin:10px;border:5px solid var(--text-color);border-radius:50%%;animation:lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;border-color:var(--text-color) transparent transparent transparent}.lds-ring div:nth-child(1){animation-delay:-0.45s}.lds-ring div:nth-child(2){animation-delay:-0.3s}.lds-ring div:nth-child(3){animation-delay:-0.15s}@keyframes lds-ring{0%%{transform:rotate(0deg)}100%%{transform:rotate(360deg)}}
</style>
<noscript>
<style>.jsonly{display:none}</style>
</noscript>
<script src="/js/argon2.js"></script>
<script src="/js/challenge.js"></script>
</head>
<body data-pow="%s" data-diff="%s" data-time="%s" data-kb="%s">
%s
%s
%s
<noscript>
<br>
<p class="red">JavaScript is required on this page.</p>
%s
</noscript>
<div class="powstatus"></div>
<footer>
<p>Security and Performance by <a href="https://gitgud.io/fatchan/haproxy-protection/">haproxy-protection</a></p>
<p>Node: <code>%s</code></p>
</footer>
</body>
</html>
]]
local noscript_extra_template = [[
<details>
<summary>No JavaScript?</summary>
<ol>
<li>
<p>Run this in a linux terminal (requires <code>argon2</code> package installed):</p>
<code style="word-break: break-all;">
echo "Q0g9IiQyIjtCPSQocHJpbnRmICcwJS4wcycgJChzZXEgMSAkNCkpO2VjaG8gIldvcmtpbmcuLi4iO0k9MDt3aGlsZSB0cnVlOyBkbyBIPSQoZWNobyAtbiAkQ0gkSSB8IGFyZ29uMiAkMSAtaWQgLXQgJDUgLWsgJDYgLXAgMSAtbCAzMiAtcik7RT0ke0g6MDokNH07W1sgJEUgPT0gJEIgXV0gJiYgZWNobyAiT3V0cHV0OiIgJiYgZWNobyAkMSMkMiMkMyMkSSAmJiBleGl0IDA7KChJKyspKTtkb25lOwo=" | base64 -d | bash -s %s %s %s %s %s %s
</code>
<li>Paste the script output into the box and submit:
<form method="post">
<textarea name="pow_response" placeholder="script output" required></textarea>
<div><input type="submit" value="submit" /></div>
</form>
</ol>
</details>
]]
-- title with favicon and hostname
local site_name_section_template = [[
<h3 class="pt">
<img src="/favicon.ico" width="32" height="32" alt="icon">
%s
</h3>
]]
-- spinner animation for proof of work
local pow_section_template = [[
<h3>
Checking your browser for robots 🤖
</h3>
<div class="jsonly">
<div class="lds-ring"><div></div><div></div><div></div><div></div></div>
</div>
]]
-- message, captcha form and submit button
local captcha_section_template = [[
<h3>
Please solve the captcha to continue.
</h3>
<div id="captcha" class="jsonly">
<div class="%s" data-sitekey="%s" data-callback="onCaptchaSubmit"></div>
<script src="%s" async defer></script>
</div>
]]
-- kill a tor circuit -- kill a tor circuit
function _M.kill_tor_circuit(txn) function _M.kill_tor_circuit(txn)
local ip = txn.sf:src() local ip = txn.sf:src()
@ -201,9 +114,9 @@ function _M.view(applet)
-- get the user_key#challenge#sig -- get the user_key#challenge#sig
local user_key = sha.bin_to_hex(randbytes(16)) local user_key = sha.bin_to_hex(randbytes(16))
local challenge_hash = utils.generate_secret(applet, pow_cookie_secret, user_key, true) 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) local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, user_key .. challenge_hash .. expiry)
local combined_challenge = user_key .. "#" .. challenge_hash .. "#" .. signature local combined_challenge = user_key .. "#" .. challenge_hash .. "#" .. expiry .. "#" .. signature
-- define body sections -- define body sections
local site_name_body = "" local site_name_body = ""
@ -214,7 +127,7 @@ function _M.view(applet)
-- check if captcha is enabled, path+domain priority, then just domain, and 0 otherwise -- check if captcha is enabled, path+domain priority, then just domain, and 0 otherwise
local captcha_enabled = false local captcha_enabled = false
local host = applet.headers['host'][0] 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 local captcha_map_lookup = captcha_map:lookup(host..path) or captcha_map:lookup(host) or 0
captcha_map_lookup = tonumber(captcha_map_lookup) captcha_map_lookup = tonumber(captcha_map_lookup)
@ -223,19 +136,26 @@ function _M.view(applet)
end end
-- pow at least is always enabled when reaching bot-check page -- 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 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) captcha_sitekey, captcha_script_src)
else else
pow_body = pow_section_template pow_body = templates.pow_section
noscript_extra_body = string.format(noscript_extra_template, user_key, challenge_hash, signature, local noscript_extra
math.ceil(pow_difficulty/8), pow_time, pow_kb) 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 end
-- sub in the body sections -- 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, pow_difficulty, argon_time, argon_kb, pow_type,
site_name_body, pow_body, captcha_body, noscript_extra_body, ray_id) site_name_body, pow_body, captcha_body, noscript_extra_body, ray_id)
response_status_code = 403 response_status_code = 403
@ -256,47 +176,60 @@ function _M.view(applet)
-- handle setting the POW cookie -- handle setting the POW cookie
local user_pow_response = parsed_body["pow_response"] local user_pow_response = parsed_body["pow_response"]
local matched_expiry = 0 -- ensure captcha cookie expiry matches POW cookie
if user_pow_response then if user_pow_response then
-- split the response up (makes the nojs submission easier because it can be a single field) -- split the response up (makes the nojs submission easier because it can be a single field)
local split_response = utils.split(user_pow_response, "#") 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_user_key = split_response[1]
local given_challenge_hash = split_response[2] local given_challenge_hash = split_response[2]
local given_signature = split_response[3] local given_expiry = split_response[3]
local given_answer = split_response[4] local given_signature = split_response[4]
local given_answer = split_response[5]
-- regenerate the challenge and compare it -- expiry check
local generated_challenge_hash = utils.generate_secret(applet, pow_cookie_secret, given_user_key, true) local number_expiry = tonumber(given_expiry, 10)
if given_challenge_hash == generated_challenge_hash then if number_expiry ~= nil and number_expiry > core.now()['sec'] then
-- regenerate the signature and compare it -- regenerate the challenge and compare it
local generated_signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash) local generated_challenge_hash = utils.generate_challenge(applet, pow_cookie_secret, given_user_key, true)
if given_signature == generated_signature then
-- do the work with their given answer if given_challenge_hash == generated_challenge_hash then
local full_hash = argon2.hash_encoded(given_challenge_hash .. given_answer, given_user_key)
-- check the output is correct -- regenerate the signature and compare it
local hash_output = utils.split(full_hash, '$')[6]:sub(0, 43) -- https://github.com/thibaultcha/lua-argon2/issues/37 local generated_signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash .. given_expiry)
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 if given_signature == generated_signature then
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 -- do the work with their given answer
applet:add_header( local hex_hash_output = ""
"set-cookie", if pow_type == "argon2" then
string.format( local encoded_argon_hash = argon2.hash_encoded(given_challenge_hash .. given_answer, given_user_key)
"z_ddos_pow=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; Domain=.%s; SameSite=Strict;%s", local trimmed_argon_hash = utils.split(encoded_argon_hash, '$')[6]:sub(0, 43) -- https://github.com/thibaultcha/lua-argon2/issues/37
combined_cookie, hex_hash_output = sha.bin_to_hex(sha.base64_to_bin(trimmed_argon_hash));
applet.headers['host'][0], else
secure_cookie_flag hex_hash_output = sha.sha256(given_user_key .. given_challenge_hash .. given_answer)
end
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; HttpOnly;%s",
combined_cookie,
applet.headers['host'][0],
secure_cookie_flag
)
) )
) valid_submission = true
valid_submission = true
end
end end
end end
end end
@ -336,13 +269,13 @@ function _M.view(applet)
if api_response.success == true then if api_response.success == true then
local user_key = sha.bin_to_hex(randbytes(16)) local user_key = sha.bin_to_hex(randbytes(16))
local user_hash = utils.generate_secret(applet, captcha_cookie_secret, user_key, true) 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) local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, user_key .. user_hash .. matched_expiry)
local combined_cookie = user_key .. "#" .. user_hash .. "#" .. signature local combined_cookie = user_key .. "#" .. user_hash .. "#" .. matched_expiry .. "#" .. signature
applet:add_header( applet:add_header(
"set-cookie", "set-cookie",
string.format( 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; HttpOnly;%s",
combined_cookie, combined_cookie,
applet.headers['host'][0], applet.headers['host'][0],
secure_cookie_flag secure_cookie_flag
@ -394,22 +327,29 @@ end
-- check if captcha cookie is valid, separate secret from POW -- check if captcha cookie is valid, separate secret from POW
function _M.check_captcha_status(txn) function _M.check_captcha_status(txn)
local parsed_request_cookies = cookie.get_cookie_table(txn.sf:hdr("Cookie")) 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 -- split the cookie up
local split_cookie = utils.split(received_captcha_cookie, "#") local split_cookie = utils.split(received_captcha_cookie, "#")
if #split_cookie ~= 3 then if #split_cookie ~= 4 then
return return
end end
local given_user_key = split_cookie[1] local given_user_key = split_cookie[1]
local given_user_hash = split_cookie[2] 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 -- 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 if generated_user_hash ~= given_user_hash then
return return
end end
-- regenerate the signature and compare it -- 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 if given_signature == generated_signature then
return txn:set_var("txn.captcha_passed", true) return txn:set_var("txn.captcha_passed", true)
end end
@ -418,23 +358,30 @@ end
-- check if pow cookie is valid -- check if pow cookie is valid
function _M.check_pow_status(txn) function _M.check_pow_status(txn)
local parsed_request_cookies = cookie.get_cookie_table(txn.sf:hdr("Cookie")) 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 -- split the cookie up
local split_cookie = utils.split(received_pow_cookie, "#") local split_cookie = utils.split(received_pow_cookie, "#")
if #split_cookie ~= 4 then if #split_cookie ~= 5 then
return return
end end
local given_user_key = split_cookie[1] local given_user_key = split_cookie[1]
local given_challenge_hash = split_cookie[2] local given_challenge_hash = split_cookie[2]
local given_answer = split_cookie[3] local given_expiry = split_cookie[3]
local given_signature = split_cookie[4] 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 -- 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 if given_challenge_hash ~= generated_challenge_hash then
return return
end end
-- regenerate the signature and compare it -- 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 if given_signature == generated_signature then
return txn:set_var("txn.pow_passed", true) return txn:set_var("txn.pow_passed", true)
end end

View File

@ -0,0 +1,10 @@
package.path = package.path .. "./?.lua;/etc/haproxy/scripts/?.lua;/etc/haproxy/libs/?.lua"
local bot_check = require("bot-check")
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)

View File

@ -0,0 +1,118 @@
local _M = {}
-- main page template
_M.body = [[
<!DOCTYPE html>
<html>
<head>
<meta name='viewport' content='width=device-width initial-scale=1'>
<title>Hold on...</title>
<style>
:root{--text-color:#c5c8c6;--bg-color:#1d1f21}
@media (prefers-color-scheme:light){:root{--text-color:#333;--bg-color:#EEE}}
.h-captcha,.g-recaptcha{min-height:85px;display:block}
.red{color:red;font-weight:bold}
.powstatus{color:green;font-weight:bold}
a,a:visited{color:var(--text-color)}
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;max-width:1200px;margin:0 auto;padding: 0 20px}
details{transition: border-left-color 0.5s;max-width:1200px;text-align:left;border-left: 2px solid var(--text-color);padding:10px}
code{background-color:#dfdfdf30;border-radius:3px;padding:0 3px;}
img,h3,p{margin:0 0 5px 0}
footer{font-size:x-small;margin-top:auto;margin-bottom:20px;text-align:center}
img{display:inline}
.pt{padding-top:15vh;display:flex;align-items:center;word-break:break-all}
.pt img{margin-right:10px}
details[open]{border-left-color: #1400ff}
.lds-ring{display:inline-block;position:relative;width:80px;height:80px}.lds-ring div{box-sizing:border-box;display:block;position:absolute;width:32px;height:32px;margin:10px;border:5px solid var(--text-color);border-radius:50%%;animation:lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;border-color:var(--text-color) transparent transparent transparent}.lds-ring div:nth-child(1){animation-delay:-0.45s}.lds-ring div:nth-child(2){animation-delay:-0.3s}.lds-ring div:nth-child(3){animation-delay:-0.15s}@keyframes lds-ring{0%%{transform:rotate(0deg)}100%%{transform:rotate(360deg)}}
</style>
<noscript>
<style>.jsonly{display:none}</style>
</noscript>
<script src="/.basedflare/js/argon2.js"></script>
<script src="/.basedflare/js/challenge.js"></script>
</head>
<body data-pow="%s" data-diff="%s" data-time="%s" data-kb="%s" data-mode="%s">
%s
%s
%s
<noscript>
<br>
<p class="red">JavaScript is required on this page.</p>
%s
</noscript>
<div class="powstatus"></div>
<footer>
<p>Security and Performance by <a href="https://gitgud.io/fatchan/haproxy-protection/">haproxy-protection</a></p>
<p>Node: <code>%s</code></p>
</footer>
</body>
</html>
]]
_M.noscript_extra_argon2 = [[
<details>
<summary>No JavaScript?</summary>
<ol>
<li>
<p>Run this in a linux terminal (requires <code>argon2</code> package installed):</p>
<code style="word-break: break-all;">
echo "Q0g9IiQyIjtCPSQocHJpbnRmIDAlLjBzICQoc2VxIDEgJDUpKTtlY2hvICJXb3JraW5nLi4uIjtJPTA7d2hpbGUgdHJ1ZTsgZG8gSD0kKGVjaG8gLW4gJENIJEkgfCBhcmdvbjIgJDEgLWlkIC10ICQ2IC1rICQ3IC1wIDEgLWwgMzIgLXIpO0U9JHtIOjA6JDV9O1tbICRFID09ICRCIF1dICYmIGVjaG8gIk91dHB1dDoiICYmIGVjaG8gJDEjJDIjJDMjJDQjJEkgJiYgZXhpdCAwOygoSSsrKSk7ZG9uZTsK" | base64 -d | bash -s %s %s %s %s %s %s %s
</code>
<li>Paste the script output into the box and submit:
<form method="post">
<textarea name="pow_response" placeholder="script output" required></textarea>
<div><input type="submit" value="submit" /></div>
</form>
</ol>
</details>
]]
_M.noscript_extra_sha256 = [[
<details>
<summary>No JavaScript?</summary>
<ol>
<li>
<p>Run this in a linux terminal (requires <code>perl</code>):</p>
<code style="word-break: break-all;">
echo "dXNlIHN0cmljdDt1c2UgRGlnZXN0OjpTSEEgcXcoc2hhMjU2X2hleCk7cHJpbnQgIldvcmtpbmcuLi4iO215JGM9IiRBUkdWWzBdIi4iJEFSR1ZbMV0iO215JGlkPSRBUkdWWzRdKzA7bXkkZD0iMCJ4JGlkO215JGk9MDt3aGlsZSgxKXtsYXN0IGlmICRkIGVxIHN1YnN0ciBzaGEyNTZfaGV4KCRjLCRpKSwwLCRpZDskaSsrfXByaW50IlxuT3V0cHV0OlxuJEFSR1ZbMF0jJEFSR1ZbMV0jJEFSR1ZbMl0jJEFSR1ZbM10jJGlcbiI=" | base64 -d | perl -w - %s %s %s %s %s %s %s
</code>
<li>Paste the script output into the box and submit:
<form method="post">
<textarea name="pow_response" placeholder="script output" required></textarea>
<div><input type="submit" value="submit" /></div>
</form>
</ol>
</details>
]]
-- title with favicon and hostname
_M.site_name_section = [[
<h3 class="pt">
<img src="/favicon.ico" width="32" height="32" alt="icon">
%s
</h3>
]]
-- spinner animation for proof of work
_M.pow_section = [[
<h3>
Checking your browser for robots 🤖
</h3>
<div class="jsonly">
<div class="lds-ring"><div></div><div></div><div></div><div></div></div>
</div>
]]
-- message, captcha form and submit button
_M.captcha_section = [[
<h3>
Please solve the captcha to continue.
</h3>
<div id="captcha" class="jsonly">
<div class="%s" data-sitekey="%s" data-callback="onCaptchaSubmit"></div>
<script src="%s" async defer></script>
</div>
]]
return _M

View File

@ -1,10 +0,0 @@
package.path = package.path .. "./?.lua;/etc/haproxy/scripts/?.lua;/etc/haproxy/libs/?.lua"
local hcaptcha = require("hcaptcha")
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)