diff --git a/README.MD b/README.MD index 007609b..68afd9c 100644 --- a/README.MD +++ b/README.MD @@ -39,6 +39,7 @@ NOTE: Use either HCAPTCHA_ or RECAPTHCA_, not both. - 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) +- TOR_CONTROL_PORT_PASSWORD - the control port password for tor daemon #### Run in docker (for testing/development) @@ -69,6 +70,22 @@ sudo luarocks install argon2 If you have problems, read the error messages before opening an issue that is simply a bad configuration. +### Tor + +- Check the `bind` line comments. Switch to the one with `accept-proxy` and `option forwardfor` +- To generate a tor control port password: +``` +$ tor --hash-password example +16:0175C41DDD88C5EA605582C858BC08FA29014215F233479A99FE78EDED +``` +- Set `TOR_CONTROL_PORT_PASSWORD` env var to the same password (NOT the output hash) +- Add to your torrc (where xxxx is the output of `tor --hash-password`): +``` +ControlPort 9051 +HashedControlPassword xxxxxxxxxxxxxxxxx +``` +- Don't forget to restart tor + #### Screenshots ![nocaptcha](img/nocaptcha.png "no captcha mode") diff --git a/docker-compose.yml b/docker-compose.yml index 41d7075..6824c86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ services: - POW_TIME=2 - POW_KB=512 - POW_DIFFICULTY=25 + - TOR_CONTROL_PORT_PASSWORD=changeme nginx: ports: diff --git a/haproxy/haproxy.cfg b/haproxy/haproxy.cfg index de3bf2c..b353736 100644 --- a/haproxy/haproxy.cfg +++ b/haproxy/haproxy.cfg @@ -10,10 +10,13 @@ global tune.bufsize 51200 defaults + log global mode http + option httplog timeout connect 5000ms timeout client 50000ms timeout server 50000ms + timeout tarpit 5000ms #frontend stats-frontend # bind *:2000 @@ -47,6 +50,11 @@ frontend http-in 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 lua.kill-tor-circuit 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 is_excluded src,map_ip(/etc/haproxy/whitelist.map) -m found acl is_excluded path /favicon.ico #add more @@ -74,10 +82,11 @@ frontend http-in # 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 - http-request lua.decide-checks-necessary if !is_excluded !on_captcha_url ddos_mode_enabled - # global override enabled pow-check only, uncomment the OR to also do hcaptcha-check - http-request lua.hcaptcha-check if !is_excluded !on_captcha_url validate_captcha #OR !is_excluded !on_captcha_url ddos_mode_enabled_override - http-request lua.pow-check if !is_excluded !on_captcha_url validate_pow OR !is_excluded !on_captcha_url ddos_mode_enabled_override + + # 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 # 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) @@ -104,6 +113,9 @@ backend servers # use server based on hostname use-server %[req.hdr(host),lower,map(/etc/haproxy/backends.map)] if TRUE +backend bot_check_post_throttle + stick-table type ipv6 size 100k expire 60s store http_req_rate(60s) + backend hcaptcha mode http server hcaptcha hcaptcha.com:443 diff --git a/haproxy/js/challenge.js b/haproxy/js/challenge.js index f5e5347..1bd26a6 100644 --- a/haproxy/js/challenge.js +++ b/haproxy/js/challenge.js @@ -1,7 +1,9 @@ function insertError(str) { const ring = document.querySelector('.lds-ring'); - ring.insertAdjacentHTML('afterend', `

Error: ${str}

`); - ring.remove(); + const captcha = document.querySelector('#captcha'); + (ring || captcha).insertAdjacentHTML('afterend', `

Error: ${str}

`); + ring && ring.remove(); + captcha && captcha.remove(); } function finishRedirect() { diff --git a/src/libs/utils.lua b/src/libs/utils.lua index a7ace91..d1ef1f9 100644 --- a/src/libs/utils.lua +++ b/src/libs/utils.lua @@ -3,7 +3,9 @@ local _M = {} local sha = require("sha") local secret_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 @@ -30,14 +32,16 @@ function _M.generate_secret(context, salt, user_key, is_applet) end +-- split string by delimiter function _M.split(inputstr, sep) local t = {} - for str in string.gmatch(inputstr, "([^"..sep.."]+)") do + for str in string.gmatch(inputstr, "([^"..sep.."]*)") do table.insert(t, str) end return t end +-- return true if hash passes difficulty function _M.checkdiff(hash, diff) local i = 1 for j = 0, (diff-8), 8 do @@ -51,5 +55,15 @@ function _M.checkdiff(hash, diff) return (lnm & msk) == 0 end +-- connect to the tor control port and instruct it to close a circuit +function _M.send_tor_control_port(circuit_identifier) + local tcp = core.tcp(); + tcp:settimeout(1); + tcp:connect("127.0.0.1", 9051); + -- 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() +end + return _M diff --git a/src/scripts/hcaptcha.lua b/src/scripts/hcaptcha.lua index 1e4d564..1eee098 100644 --- a/src/scripts/hcaptcha.lua +++ b/src/scripts/hcaptcha.lua @@ -133,10 +133,10 @@ local noscript_extra_template = [[ echo "Q0g9IiQyIjtCPSQocHJpbnRmICcwJS4wcycgJChzZXEgMSAkNCkpO2VjaG8gIldvcmtpbmcuLi4iO0k9MDt3aGlsZSB0cnVlOyBkbyBIPSQoZWNobyAtbiAkQ0gkSSB8IGFyZ29uMiAkMSAtaWQgLXQgJDUgLWsgJDYgLXAgMSAtbCAzMiAtcik7RT0ke0g6MDokNH07W1sgJEUgPT0gJEIgXV0gJiYgZWNobyAiT3V0cHV0OiIgJiYgZWNobyAkMSMkMiMkMyMkSSAmJiBleGl0IDA7KChJKyspKTtkb25lOwo=" | base64 -d | bash -s %s %s %s %s %s %s -
  • Paste the output from the script into the box and submit: -
    - - +
  • Paste the script output into the box and submit: + + +
  • @@ -145,7 +145,7 @@ local noscript_extra_template = [[ -- title with favicon and hostname local site_name_section_template = [[

    - + favicon %s

    ]] @@ -171,6 +171,24 @@ local captcha_section_template = [[ ]] +-- kill a tor circuit +function _M.kill_tor_circuit(txn) + local ip = txn.sf:src() + if ip:sub(1,19) ~= "fc00:dead:beef:4dad" then + return -- not a tor circuit id/ip. we shouldn't get here, but just in case. + end + -- split the IP, take the last 2 sections + local split_ip = utils.split(ip, ":") + local aa_bb = split_ip[5] or "0000" + local cc_dd = split_ip[6] or "0000" + aa_bb = string.rep("0", 4 - #aa_bb) .. aa_bb + cc_dd = string.rep("0", 4 - #cc_dd) .. cc_dd + -- convert the last 2 sections to a number from hex, which makes the circuit ID + local circuit_identifier = tonumber(aa_bb..cc_dd, 16) + print('Closing Tor circuit ID: '..circuit_identifier..', "IP": '..ip) + utils.send_tor_control_port(circuit_identifier) +end + function _M.view(applet) -- set response body and declare status code @@ -223,6 +241,9 @@ function _M.view(applet) -- if request is POST, check the answer to the pow/cookie elseif applet.method == "POST" then + -- if they fail, set a var for use in ACLs later + local valid_submission = false + -- parsed POST body local parsed_body = url.parseQuery(applet.receive(applet)) @@ -232,9 +253,58 @@ function _M.view(applet) secure_cookie_flag = "" end + -- handle setting the POW cookie + local user_pow_response = parsed_body["pow_response"] + 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 + 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] + + -- 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 + + -- 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 + + -- 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_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 + ) + ) + valid_submission = true + + end + end + end + end + end + -- handle setting the captcha cookie local user_captcha_response = parsed_body["h-captcha-response"] or parsed_body["g-recaptcha-response"] - if user_captcha_response then + if valid_submission and user_captcha_response then -- only check captcha if POW is already correct -- format the url for verifying the captcha response local captcha_url = string.format( "https://%s%s", @@ -277,56 +347,13 @@ function _M.view(applet) secure_cookie_flag ) ) + valid_submission = valid_submission and true end end - -- handle setting the POW cookie - local user_pow_response = parsed_body["pow_response"] - 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 - 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] - - -- 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 - - -- 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 - - -- 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, '$')[5]: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_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 - ) - ) - - end - end - end - end + if not valid_submission then + _M.kill_tor_circuit(applet) end -- redirect them to their desired page in applet.qs (query string) diff --git a/src/scripts/register.lua b/src/scripts/register.lua index ec438b1..ec408ee 100644 --- a/src/scripts/register.lua +++ b/src/scripts/register.lua @@ -6,4 +6,5 @@ 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)