Move POW proof checking to POST and sign a cookie there, kinda like captcha flow, so we can do a more intensive one without it happening on every request. We just check the hmac now.

Still TODO actually converting it to argon, but should be straightforward from this point.
Another advantage of making POW check use POST is a better noscript experience. We now provide a box and "submit" button, so they don't have to mess with setting a cookie.
This commit is contained in:
Thomas Lynch
2022-09-23 00:26:20 +10:00
parent 521f9742c1
commit d019440bc0
2 changed files with 117 additions and 51 deletions

View File

@ -2,18 +2,30 @@ function finishRedirect() {
window.location=location.search.slice(1)+location.hash || "/"; window.location=location.search.slice(1)+location.hash || "/";
} }
function finishPow(combined, answer) { function postResponse(powResponse, captchaResponse) {
document.cookie='z_ddos_pow='+combined+'#'+answer+';expires=Thu, 31-Dec-37 23:55:55 GMT; path=/; SameSite=Strict; '+(location.protocol==='https:'?'Secure=true; ':''); const body = {
const hasCaptchaForm = document.querySelector('form'); 'pow_response': powResponse,
if (!hasCaptchaForm) { };
finishRedirect(); if (captchaResponse) {
body['h-captcha-response'] = captchaResponse;
body['g-recaptcha-response'] = captchaResponse;
} }
fetch('/bot-check', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(body),
redirect: 'manual',
}).then(res => {
finishRedirect();
})
} }
const powFinished = new Promise((resolve, reject) => { const powFinished = new Promise((resolve, reject) => {
window.addEventListener('DOMContentLoaded', (event) => { window.addEventListener('DOMContentLoaded', (event) => {
const combined = document.querySelector('[data-pow]').dataset.pow; const combined = document.querySelector('[data-pow]').dataset.pow;
const [_userkey, challenge, _signature] = combined.split("#"); const [userkey, challenge, signature] = combined.split("#");
const start = Date.now(); const start = Date.now();
if (window.Worker && crypto.subtle) { if (window.Worker && crypto.subtle) {
const threads = Math.min(2,Math.ceil(window.navigator.hardwareConcurrency/2)); const threads = Math.min(2,Math.ceil(window.navigator.hardwareConcurrency/2));
@ -26,8 +38,7 @@ const powFinished = new Promise((resolve, reject) => {
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); const dummyTime = 5000 - (Date.now()-start);
window.setTimeout(() => { window.setTimeout(() => {
finishPow(combined, answer); resolve(`${combined}#${answer}`);
resolve();
}, dummyTime); }, dummyTime);
} }
const workers = []; const workers = [];
@ -53,31 +64,24 @@ const powFinished = new Promise((resolve, reject) => {
} }
const dummyTime = 5000 - (Date.now()-start); const dummyTime = 5000 - (Date.now()-start);
window.setTimeout(() => { window.setTimeout(() => {
finishPow(combined, i); resolve(`${combined}#${i}`);
resolve();
}, dummyTime); }, dummyTime);
} }
}); });
}).then((powResponse) => {
const hasCaptchaForm = document.getElementById('captcha');
if (!hasCaptchaForm) {
postResponse(powResponse);
}
return powResponse;
}); });
function onCaptchaSubmit(callback) { 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(() => { powFinished.then((powResponse) => {
fetch('/bot-check', { postResponse(powResponse, captchaResponse);
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
'h-captcha-response': callback,
'g-recaptcha-response': callback,
}),
redirect: 'manual',
}).then(res => {
finishRedirect();
})
}); });
} }

View File

@ -116,8 +116,11 @@ local noscript_extra_template = [[
<code style="word-break: break-all;"> <code style="word-break: break-all;">
echo "Q0g9IiQyIjtCPSIwMDQxIjtJPTA7RElGRj0kKCgxNiMke0NIOjA6MX0gKiAyKSk7d2hpbGUgdHJ1ZTsgZG8gSD0kKGVjaG8gLW4gJENIJEkgfCBzaGEyNTZzdW0pO0U9JHtIOiRESUZGOjR9O1tbICRFID09ICRCIF1dICYmIGVjaG8gJDEjJDIjJDMjJEkgJiYgZXhpdCAwOygoSSsrKSk7ZG9uZTs=" | base64 -d | bash -s %s %s %s echo "Q0g9IiQyIjtCPSIwMDQxIjtJPTA7RElGRj0kKCgxNiMke0NIOjA6MX0gKiAyKSk7d2hpbGUgdHJ1ZTsgZG8gSD0kKGVjaG8gLW4gJENIJEkgfCBzaGEyNTZzdW0pO0U9JHtIOiRESUZGOjR9O1tbICRFID09ICRCIF1dICYmIGVjaG8gJDEjJDIjJDMjJEkgJiYgZXhpdCAwOygoSSsrKSk7ZG9uZTs=" | base64 -d | bash -s %s %s %s
</code> </code>
<li>Set a cookie named <code>z_ddos_pow</code> with the value as the script output, and path <code>/</code>. <li>Paste the output from the script into the box and submit:
<li>Remove <code>/bot-check?</code> from the url, and reload the page. <form method="POST">
<textarea type="text" name="pow_response"></textarea>
<input type="submit" value="submit" />
</form>
</ol> </ol>
</details> </details>
]] ]]
@ -145,25 +148,26 @@ local captcha_section_template = [[
<h3> <h3>
Please solve the captcha to continue. Please solve the captcha to continue.
</h3> </h3>
<form class="jsonly" method="POST"> <div id="captcha" class="jsonly">
<div class="%s" data-sitekey="%s" data-callback="onCaptchaSubmit"></div> <div class="%s" data-sitekey="%s" data-callback="onCaptchaSubmit"></div>
<script src="%s" async defer></script> <script src="%s" async defer></script>
</form> </div>
]] ]]
function _M.view(applet) function _M.view(applet)
-- set response body and declare status code
local response_body = "" local response_body = ""
local response_status_code local response_status_code
-- 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 = utils.generate_secret(applet, pow_cookie_secret, user_key, true) local challenge_hash = utils.generate_secret(applet, pow_cookie_secret, user_key, true)
local signature = sha.hmac(sha.sha256, hmac_cookie_secret, user_key .. challenge_hash) local signature = sha.hmac(sha.sha256, hmac_cookie_secret, user_key .. challenge_hash)
local combined_challenge = user_key .. "#" .. challenge_hash .. "#" .. signature local combined_challenge = user_key .. "#" .. challenge_hash .. "#" .. signature
-- print_r(user_key)
-- print_r(challenge_hash)
-- print_r(signature)
-- print_r(combined_challenge)
-- define body sections -- define body sections
local site_name_body = "" local site_name_body = ""
@ -194,19 +198,34 @@ function _M.view(applet)
-- sub in the body sections -- sub in the body sections
response_body = string.format(body_template, combined_challenge, site_name_body, pow_body, captcha_body, noscript_extra_body, ray_id) response_body = string.format(body_template, combined_challenge, site_name_body, pow_body, captcha_body, noscript_extra_body, ray_id)
response_status_code = 403 response_status_code = 403
-- if request is POST, check the answer to the pow/cookie
elseif applet.method == "POST" then elseif applet.method == "POST" then
-- parsed POST body
local parsed_body = url.parseQuery(applet.receive(applet)) local parsed_body = url.parseQuery(applet.receive(applet))
-- whether to set cookies sent as secure or not
local secure_cookie_flag = " Secure=true;"
if applet.sf:ssl_fc() == "0" then
secure_cookie_flag = ""
end
-- handle setting the captcha cookie
local user_captcha_response = parsed_body["h-captcha-response"] or parsed_body["g-recaptcha-response"] local user_captcha_response = parsed_body["h-captcha-response"] or parsed_body["g-recaptcha-response"]
if user_captcha_response then if user_captcha_response then
-- format the url for verifying the captcha response
local captcha_url = string.format( local captcha_url = string.format(
"https://%s%s", "https://%s%s",
core.backends[captcha_backend_name].servers[captcha_backend_name]:get_addr(), core.backends[captcha_backend_name].servers[captcha_backend_name]:get_addr(),
captcha_siteverify_path captcha_siteverify_path
) )
-- construct the captcha body to send to the captcha url
local captcha_body = url.buildQuery({ local captcha_body = url.buildQuery({
secret=captcha_secret, secret=captcha_secret,
response=user_captcha_response response=user_captcha_response
}) })
-- instantiate an http client and make the request
local httpclient = core.httpclient() local httpclient = core.httpclient()
local res = httpclient:post{ local res = httpclient:post{
url=captcha_url, url=captcha_url,
@ -216,42 +235,90 @@ function _M.view(applet)
[ "content-type" ] = { "application/x-www-form-urlencoded" } [ "content-type" ] = { "application/x-www-form-urlencoded" }
} }
} }
-- try parsing the response as json
local status, api_response = pcall(json.decode, res.body) local status, api_response = pcall(json.decode, res.body)
if not status then if not status then
api_response = {} api_response = {}
end end
-- 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
-- for captcha, they dont need to solve a POW but we check the user_hash and sig later
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_secret(applet, captcha_cookie_secret, user_key, true)
local signature = sha.hmac(sha.sha256, hmac_cookie_secret, user_key .. user_hash) local signature = sha.hmac(sha.sha256, hmac_cookie_secret, user_key .. user_hash)
local combined_cookie = user_key .. "#" .. user_hash .. "#" .. signature local combined_cookie = user_key .. "#" .. user_hash .. "#" .. signature
local secure_cookie_flag = " Secure=true;"
if applet.sf:ssl_fc() == "0" then
secure_cookie_flag = ""
end
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=/; SameSite=Strict;", "z_ddos_captcha=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; SameSite=Strict;%s",
combined_cookie, combined_cookie,
secure_cookie_flag secure_cookie_flag
) )
) )
end end
end end
-- if failed captcha, will just get sent back here so 302 is fine
-- 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.sha256, hmac_cookie_secret, given_user_key .. given_challenge_hash)
if given_signature == generated_signature then
-- do the work with their given answer
local completed_work = sha.sha256(generated_challenge_hash .. given_answer) -- (TODO: replace this bit with argon2)
-- check the output is correct
local challenge_offset = tonumber(generated_challenge_hash:sub(1,1),16) * 2
if completed_work:sub(challenge_offset+1, challenge_offset+4) == '0041' then
-- the answer was good, give them a cookie
local signature = sha.hmac(sha.sha256, 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=/; SameSite=Strict;%s",
combined_cookie,
secure_cookie_flag
)
)
end
end
end
end
end
-- redirect them to their desired page in applet.qs (query string)
-- if they didn't get the appropriate cookies they will be sent back to the challenge page
response_status_code = 302 response_status_code = 302
applet:add_header("location", applet.qs) applet:add_header("location", applet.qs)
-- else if its another http method, just 403 them
else else
-- other methods
response_status_code = 403 response_status_code = 403
end end
-- finish sending the response
applet:set_status(response_status_code) applet:set_status(response_status_code)
applet:add_header("content-type", "text/html; charset=utf-8") applet:add_header("content-type", "text/html; charset=utf-8")
applet:add_header("content-length", string.len(response_body)) applet:add_header("content-length", string.len(response_body))
applet:start_response() applet:start_response()
applet:send(response_body) applet:send(response_body)
end end
-- 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
@ -273,6 +340,7 @@ end
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["z_ddos_captcha"] or ""
-- 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 ~= 3 then
return return
@ -303,22 +371,16 @@ function _M.check_pow_status(txn)
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_signature = split_cookie[3] local given_answer = split_cookie[3]
local given_nonce = split_cookie[4] local given_signature = split_cookie[4]
-- 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_secret(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.sha256, hmac_cookie_secret, given_user_key .. given_challenge_hash) local generated_signature = sha.hmac(sha.sha256, hmac_cookie_secret, given_user_key .. given_challenge_hash .. given_answer)
if given_signature ~= generated_signature then if given_signature == generated_signature then
return
end
-- check the work
local completed_work = sha.sha256(generated_challenge_hash .. given_nonce)
local challenge_offset = tonumber(generated_challenge_hash:sub(1,1),16) * 2
if completed_work:sub(challenge_offset+1, challenge_offset+4) == '0041' then -- i dont know lua properly :^)
return txn:set_var("txn.pow_passed", true) return txn:set_var("txn.pow_passed", true)
end end
end end