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

This commit is contained in:
Thomas Lynch
2023-05-21 19:50:38 +10:00
parent 14922d7e2f
commit 22b6b4795e
7 changed files with 56 additions and 39 deletions

View File

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

View File

@@ -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 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 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 # 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 } 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 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 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 # 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)] 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 max-age 86400
process-vary on 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 backend servers
balance leastconn balance leastconn

View File

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

View File

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

View File

@@ -1 +0,0 @@
localhost admin

View File

@@ -1,8 +1,5 @@
local _M = {} local _M = {}
local sha = require("sha") 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") local tor_control_port_password = os.getenv("TOR_CONTROL_PORT_PASSWORD")
-- get header from different place depending on action vs view -- 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 end
-- generate the challenge hash/user hash -- 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) -- optional IP to lock challenges/user_keys to IP (for clearnet or single-onion aka 99% of cases)
local ip = "" local ip = ""
if challenge_includes_ip == "1" then if ddos_config["cip"] == true then
ip = context.sf:src() ip = context.sf:src()
end end
-- user agent to counter very dumb spammers -- 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 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 return challenge_hash, expiry

View File

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