WIP of configurable challenge settings per-domain with a ddos_config map and handling

master
Thomas Lynch 12 months ago
parent 14922d7e2f
commit 22b6b4795e
Signed by: fatchan
GPG Key ID: A7E5E8B7E11EE92D
  1. 2
      docker-compose.yml
  2. 8
      haproxy/haproxy.cfg
  3. 1
      haproxy/map/ddos.map
  4. 1
      haproxy/map/ddos_config.map
  5. 1
      haproxy/map/maintenance.map
  6. 11
      src/lua/libs/utils.lua
  7. 73
      src/lua/scripts/bot-check.lua

@ -31,7 +31,7 @@ services:
- CHALLENGE_EXPIRY=43200
- BACKEND_NAME=servers
- SERVER_PREFIX=websrv
- CHALLENGE_INCLUDES_IP=1
- CHALLENGE_INCLUDES_IP=true
- ARGON_TIME=2
- ARGON_KB=512
- POW_DIFFICULTY=24

@ -83,7 +83,8 @@ frontend http-in
# 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/map/maintenance.map) -m found
use_backend maintenance if maintenance_mode
http-request lua.set-lang-json 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
# rewrite specific domain+path to domain or domain+path
http-request redirect location https://%[base,map(/etc/haproxy/map/rewrite.map)] code 302 if { base,map(/etc/haproxy/map/rewrite.map) -i -m found }
@ -115,7 +116,6 @@ frontend http-in
http-request set-var(txn.path) path
acl can_cache var(txn.path) -i -m end .png .jpg .jpeg .jpe .ico .webmanifest .xml .apng .bmp .webp .pjpeg .jfif .gif .mp4 .webm .mov .mkv .svg .m4a .aac .flac .mp3 .ogg .wav .opus .txt .pdf .sid
# optional alt-svc header (done after cache so not set in cached responses
http-response set-header Alt-Svc %[var(txn.xcn),map(/etc/haproxy/map/alt-svc.map)]
@ -144,10 +144,6 @@ cache basic_cache
max-age 86400
process-vary on
backend maintenance
http-request lua.set-lang-json
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"
backend servers
balance leastconn

@ -1,2 +1,3 @@
localhost 1
127.0.0.1 1
127.0.0.1/captcha 2

@ -0,0 +1 @@
localhost {"pd":23,"pt":"sha256","cip":true,"cex":600}

@ -1 +0,0 @@
localhost admin

@ -1,8 +1,5 @@
local _M = {}
local sha = require("sha")
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")
-- get header from different place depending on action vs view
@ -18,20 +15,20 @@ function _M.get_header_from_context(context, header_name, is_applet)
end
-- generate the challenge hash/user hash
function _M.generate_challenge(context, salt, user_key, is_applet)
function _M.generate_challenge(context, salt, user_key, ddos_config, is_applet)
-- optional IP to lock challenges/user_keys to IP (for clearnet or single-onion aka 99% of cases)
local ip = ""
if challenge_includes_ip == "1" then
if ddos_config["cip"] == true then
ip = context.sf:src()
end
-- user agent to counter very dumb spammers
local user_agent = _M.get_header_from_context(context, 'user-agent', is_applet)
local user_agent = _M.get_header_from_context(context, "user-agent", is_applet)
local challenge_hash = sha.sha3_256(salt .. ip .. user_key .. user_agent)
local expiry = core.now()['sec'] + challenge_expiry
local expiry = core.now()["sec"] + ddos_config["cex"]
return challenge_hash, expiry

@ -11,6 +11,8 @@ local cookie = require("cookie")
local json = require("json")
local randbytes = require("randbytes")
local templates = require("templates")
-- load locales
local locales_path = "/etc/haproxy/locales/"
local locales_table = {}
local locales_strings = {}
@ -26,28 +28,30 @@ for file_name in io.popen('ls "'..locales_path..'"*.json'):lines() do
end
-- POW
local pow_type = os.getenv("POW_TYPE") or "argon2"
local pow_difficulty = tonumber(os.getenv("POW_DIFFICULTY") or 18)
-- argon2
local sha = require("sha")
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)
local argon_kb = tonumber(os.getenv("ARGON_KB") or 6000)
argon2.parallelism(1)
argon2.hash_len(32)
argon2.variant(argon2.variants.argon2_id)
-- sha2
local sha = require("sha")
-- environment variables
argon2.t_cost(argon_time)
argon2.m_cost(argon_kb)
local ddos_config_map = Map.new("/etc/haproxy/map/ddos_config.map", Map._str)
local ddos_default_config = {
["pt"] = os.getenv("POW_TYPE") or "argon2",
["pd"] = tonumber(os.getenv("POW_DIFFICULTY") or 18),
["cip"] = (os.getenv("CHALLENGE_INCLUDES_IP") ~= nil and true or false),
["cex"] = tonumber(os.getenv("CHALLENGE_EXPIRY")),
}
-- captcha 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")
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/map/ddos.map", Map._str);
local captcha_provider_domain = ""
@ -90,7 +94,7 @@ end
-- read first language from accept-language in applet (note: does not consider q values)
local default_lang = "en-US"
function _M.get_first_language(context, is_applet)
local accept_language = utils.get_header_from_context(context, 'accept-language', is_applet)
local accept_language = utils.get_header_from_context(context, "accept-language", is_applet)
if #accept_language > 0 and #accept_language < 100 then -- length limit preventing abuse
for lang in accept_language:gmatch("[^,%s]+") do
if not lang:find(";") then
@ -100,8 +104,23 @@ function _M.get_first_language(context, is_applet)
end
end
-- get ddos config from map or take default
function _M.get_ddos_config(context, is_applet)
local host = utils.get_header_from_context(context, "host", is_applet)
local ddos_config = ddos_config_map:lookup(host)
if ddos_config ~= nil then
ddos_config = json.decode(ddos_config)
else
ddos_config = ddos_default_config
end
return ddos_config
end
function _M.view(applet)
-- host header
local host = applet.headers['host'][0]
-- set the ll and ls language var based off header or default to en-US
local lang = _M.get_first_language(applet, true)
local ll = locales_table[lang]
@ -115,12 +134,15 @@ function _M.view(applet)
local response_body = ""
local response_status_code
-- get the config from ddos_config.map
local ddos_config = _M.get_ddos_config(applet, true)
-- if request is GET, serve the challenge page
if applet.method == "GET" then
-- get the user_key#challenge#sig
local user_key = sha.bin_to_hex(randbytes(16))
local challenge_hash, expiry = utils.generate_challenge(applet, pow_cookie_secret, user_key, true)
local challenge_hash, expiry = utils.generate_challenge(applet, pow_cookie_secret, user_key, ddos_config, 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
@ -132,7 +154,6 @@ 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 /.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
@ -144,7 +165,7 @@ function _M.view(applet)
-- return simple json if they send accept: application/json header
local accept_header = applet.headers['accept']
if accept_header ~= nil and accept_header[0] == 'application/json' then
local_pow_combined = string.format('%s#%d#%s#%s', pow_type, math.ceil(pow_difficulty/8), argon_time, argon_kb)
local_pow_combined = string.format('%s#%d#%s#%s', ddos_config["pt"], math.ceil(ddos_config["pd"]/8), argon_time, argon_kb)
response_body = "{\"ch\":\""..combined_challenge.."\",\"ca\":"..(captcha_enabled and "true" or "false")..",\"pow\":\""..local_pow_combined.."\"}"
applet:set_status(403)
applet:add_header("content-type", "application/json; charset=utf-8")
@ -174,7 +195,7 @@ function _M.view(applet)
)
local noscript_extra
local noscript_prompt
if pow_type == "argon2" then
if ddos_config["pt"] == "argon2" then
noscript_extra = templates.noscript_extra_argon2
noscript_prompt = ll["Run this in a linux terminal (requires <code>argon2</code> package installed):"]
else
@ -189,7 +210,7 @@ function _M.view(applet)
challenge_hash,
expiry,
signature,
math.ceil(pow_difficulty/8),
math.ceil(ddos_config["pd"]/8),
argon_time,
argon_kb,
ll["Paste the script output into the box and submit:"]
@ -203,10 +224,10 @@ function _M.view(applet)
ls,
ll["Hold on..."],
combined_challenge,
pow_difficulty,
ddos_config["pd"],
argon_time,
argon_kb,
pow_type,
ddos_config["pt"],
site_name_body,
pow_body,
captcha_body,
@ -252,7 +273,7 @@ function _M.view(applet)
if number_expiry ~= nil and number_expiry > core.now()['sec'] then
-- regenerate the challenge and compare it
local generated_challenge_hash = utils.generate_challenge(applet, pow_cookie_secret, given_user_key, true)
local generated_challenge_hash = utils.generate_challenge(applet, pow_cookie_secret, given_user_key, ddos_config, true)
if given_challenge_hash == generated_challenge_hash then
@ -263,7 +284,7 @@ function _M.view(applet)
-- do the work with their given answer
local hex_hash_output = ""
if pow_type == "argon2" then
if ddos_config["pt"] == "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));
@ -271,7 +292,7 @@ function _M.view(applet)
hex_hash_output = sha.sha256(given_user_key .. given_challenge_hash .. given_answer)
end
if utils.checkdiff(hex_hash_output, pow_difficulty) then
if utils.checkdiff(hex_hash_output, ddos_config["pd"]) 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)
@ -337,7 +358,7 @@ function _M.view(applet)
-- the response was good i.e the captcha provider says they passed, give them a cookie
if api_response.success == true then
local user_key = sha.bin_to_hex(randbytes(16))
local user_hash = utils.generate_challenge(applet, captcha_cookie_secret, user_key, true)
local user_hash = utils.generate_challenge(applet, captcha_cookie_secret, user_key, ddos_config, 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(
@ -422,7 +443,8 @@ function _M.check_captcha_status(txn)
return
end
-- regenerate the user hash and compare it
local generated_user_hash = utils.generate_challenge(txn, captcha_cookie_secret, given_user_key, false)
local ddos_config = _M.get_ddos_config(txn, false)
local generated_user_hash = utils.generate_challenge(txn, captcha_cookie_secret, given_user_key, ddos_config, false)
if generated_user_hash ~= given_user_hash then
return
end
@ -454,7 +476,8 @@ function _M.check_pow_status(txn)
return
end
-- regenerate the challenge and compare it
local generated_challenge_hash = utils.generate_challenge(txn, pow_cookie_secret, given_user_key, false)
local ddos_config = _M.get_ddos_config(txn, false)
local generated_challenge_hash = utils.generate_challenge(txn, pow_cookie_secret, given_user_key, ddos_config, false)
if given_challenge_hash ~= generated_challenge_hash then
return
end

Loading…
Cancel
Save