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 || "/";
}
function finishPow(combined, answer) {
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 hasCaptchaForm = document.querySelector('form');
if (!hasCaptchaForm) {
finishRedirect();
function postResponse(powResponse, captchaResponse) {
const body = {
'pow_response': powResponse,
};
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) => {
window.addEventListener('DOMContentLoaded', (event) => {
const combined = document.querySelector('[data-pow]').dataset.pow;
const [_userkey, challenge, _signature] = combined.split("#");
const [userkey, challenge, signature] = combined.split("#");
const start = Date.now();
if (window.Worker && crypto.subtle) {
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');
const dummyTime = 5000 - (Date.now()-start);
window.setTimeout(() => {
finishPow(combined, answer);
resolve();
resolve(`${combined}#${answer}`);
}, dummyTime);
}
const workers = [];
@ -53,31 +64,24 @@ const powFinished = new Promise((resolve, reject) => {
}
const dummyTime = 5000 - (Date.now()-start);
window.setTimeout(() => {
finishPow(combined, i);
resolve();
resolve(`${combined}#${i}`);
}, 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]');
captchaElem.insertAdjacentHTML('afterend', `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`);
captchaElem.remove();
powFinished.then(() => {
fetch('/bot-check', {
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();
})
powFinished.then((powResponse) => {
postResponse(powResponse, captchaResponse);
});
}

View File

@ -116,8 +116,11 @@ local noscript_extra_template = [[
<code style="word-break: break-all;">
echo "Q0g9IiQyIjtCPSIwMDQxIjtJPTA7RElGRj0kKCgxNiMke0NIOjA6MX0gKiAyKSk7d2hpbGUgdHJ1ZTsgZG8gSD0kKGVjaG8gLW4gJENIJEkgfCBzaGEyNTZzdW0pO0U9JHtIOiRESUZGOjR9O1tbICRFID09ICRCIF1dICYmIGVjaG8gJDEjJDIjJDMjJEkgJiYgZXhpdCAwOygoSSsrKSk7ZG9uZTs=" | base64 -d | bash -s %s %s %s
</code>
<li>Set a cookie named <code>z_ddos_pow</code> with the value as the script output, and path <code>/</code>.
<li>Remove <code>/bot-check?</code> from the url, and reload the page.
<li>Paste the output from the script into the box and submit:
<form method="POST">
<textarea type="text" name="pow_response"></textarea>
<input type="submit" value="submit" />
</form>
</ol>
</details>
]]
@ -145,25 +148,26 @@ local captcha_section_template = [[
<h3>
Please solve the captcha to continue.
</h3>
<form class="jsonly" method="POST">
<div id="captcha" class="jsonly">
<div class="%s" data-sitekey="%s" data-callback="onCaptchaSubmit"></div>
<script src="%s" async defer></script>
</form>
</div>
]]
function _M.view(applet)
-- set response body and declare status code
local response_body = ""
local response_status_code
-- 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 = utils.generate_secret(applet, pow_cookie_secret, user_key, true)
local signature = sha.hmac(sha.sha256, hmac_cookie_secret, user_key .. challenge_hash)
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
local site_name_body = ""
@ -194,19 +198,34 @@ function _M.view(applet)
-- 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_status_code = 403
-- if request is POST, check the answer to the pow/cookie
elseif applet.method == "POST" then
-- parsed POST body
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"]
if user_captcha_response then
-- format the url for verifying the captcha response
local captcha_url = string.format(
"https://%s%s",
core.backends[captcha_backend_name].servers[captcha_backend_name]:get_addr(),
captcha_siteverify_path
)
-- construct the captcha body to send to the captcha url
local captcha_body = url.buildQuery({
secret=captcha_secret,
response=user_captcha_response
})
-- instantiate an http client and make the request
local httpclient = core.httpclient()
local res = httpclient:post{
url=captcha_url,
@ -216,42 +235,90 @@ function _M.view(applet)
[ "content-type" ] = { "application/x-www-form-urlencoded" }
}
}
-- try parsing the response as json
local status, api_response = pcall(json.decode, res.body)
if not status then
api_response = {}
end
-- the response was good i.e the captcha provider says they passed, give them a cookie
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_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 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(
"set-cookie",
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,
secure_cookie_flag
)
)
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
applet:add_header("location", applet.qs)
-- else if its another http method, just 403 them
else
-- other methods
response_status_code = 403
end
-- finish sending the response
applet:set_status(response_status_code)
applet:add_header("content-type", "text/html; charset=utf-8")
applet:add_header("content-length", string.len(response_body))
applet:start_response()
applet:send(response_body)
end
-- 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)
local parsed_request_cookies = cookie.get_cookie_table(txn.sf:hdr("Cookie"))
local received_captcha_cookie = parsed_request_cookies["z_ddos_captcha"] or ""
-- split the cookie up
local split_cookie = utils.split(received_captcha_cookie, "#")
if #split_cookie ~= 3 then
return
@ -303,22 +371,16 @@ function _M.check_pow_status(txn)
end
local given_user_key = split_cookie[1]
local given_challenge_hash = split_cookie[2]
local given_signature = split_cookie[3]
local given_nonce = split_cookie[4]
local given_answer = split_cookie[3]
local given_signature = split_cookie[4]
-- regenerate the challenge and compare it
local generated_challenge_hash = utils.generate_secret(txn, pow_cookie_secret, given_user_key, false)
if given_challenge_hash ~= generated_challenge_hash then
return
end
-- 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
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 :^)
local generated_signature = sha.hmac(sha.sha256, hmac_cookie_secret, given_user_key .. given_challenge_hash .. given_answer)
if given_signature == generated_signature then
return txn:set_var("txn.pow_passed", true)
end
end