SPAM protection methods N° 2: Timing is everything
As part of the "SPAM protection methods" series, we continue with part 2 and the ominous title "Timing is everything". It's based on the consideration that users filling out a web form don't fill out the entire form within a few seconds. According to the all-knowing Wikipedia, the number of keystrokes for a professional office manager is about 230 characters per minute. In a self-test I got 512 per minute (you can find that out very easily here). Let's assume a mean value and say that anyone who can type faster than 400 characters per minute is most likely a bot filling out the form automatically. Let's get to it.
1. The math (ew)
So first we have to work out how many keystrokes per second are allowed if we assume 400 characters per minute, and calculate 400 / 60 = 6.66666667 (so 7). More than 7 keystrokes per second should therefore be viewed critically. To get an average (sometimes you type faster, sometimes slower) we further say that anyone who makes more than 35 (= 5x7) keystrokes in 5 seconds could be a bot. That's it for math. Easy.
2. The flow
We set an interval that resets the keystroke counter every 5 seconds. On every "keypress" the counter is increased by 1, and if we exceed 35 we take suitable measures against the bot. These "suitable measures" look like this: we send a POST request to captcha.php (why it's called that, we'll see below. Little hint: it contains a captcha) and store there in the session that it's a bot. To keep it simple, we use jQuery as a helper.
3. Le Javascript
var typecounter = 0;
setInterval(function(){
typecounter = 0;
}, 5000);
$(document).keypress(function(){
typecounter++;
if (typecounter > 35){
$.post("captcha.php", { action: 'block' }).done(function(data) {
if (data == 'block') {
location.reload();
}
});
}
});
Every 5 seconds the typecounter is reset to 0 here. On a keypress it's increased by 1, and when it reaches 35 we reload the page and show an error message.
4. Le PHP (captcha.php)
session_start();
$_SESSION['is_bot'] = true;
die('block');
captcha.php does nothing more than set the session variable 'is_bot' to true and return the message 'block'.
5. Keep out!
So that the bot can no longer get onto our site (and immediately gets an error message), we have to append the following two lines to the beginning of every file that is to be protected:
session_start();
if (isset($_SESSION['is_bot']) && $_SESSION['is_bot'] === true) die("No access for bots");
This does nothing more than start the session and check the value of the session variable 'is_bot', which we may already have set in captcha.php. If it contains 'true', the output is terminated and the message 'No access for bots' is displayed.
6. Optimizations
It would of course be better if we asked for a captcha beforehand. Maybe it's a human after all, who just types fast? So - let's go.
7. Captcha
We add two small division layers (aka DIVs) to our file and write a fancy text into them. I skipped moving the styles out or putting them in the header, to keep things clearer.
<div id="captcha_wrapper" style="display:none; width:100vw; height:100vh; background-color:rgba(0,0,0,.8); align-items: center; justify-content: center;">
<div style="background-color:#fff; padding:1em;">
<h2>Are you human?</h2>
Hoooman! You type awfully fast - unfortunately that leaves me no choice but to conclude that you're an evil bot. So I ask you to solve the following simple captcha. What text do you see in the image?
<img src='captcha.php' id='captcha_out'>
<input type="text" id="txtCaptcha">
<button id="checkCaptcha">Check</button>
</div>
</div>
The captcha from the image has to be entered into the input '#txtCaptcha', and the button '#checkCaptcha' later causes the entered value to be checked.
8. Le Javascript - for the captcha
Of course we now modify the JavaScript a bit too. Instead of "location.reload" we now show the captcha DIV.
var typecounter = 0;
setInterval(function(){
typecounter = 0;
}, 5000);
$(document).keypress(function(){
typecounter++;
if (typecounter > 35){
$.post("captcha.php", { action: 'block' }).done(function(data) {
if (data == 'block') {
$("#captcha_wrapper").css('display', 'flex');
}
});
}
});
Basically the same as above, only the line
$("#captcha_wrapper").css('display', 'flex');
is new. I chose 'display: flex' so that the captcha box is centered.
9. Captcha.php
The PHP code of course has to be adjusted now as well. We block the user once (temporarily at first), but then we also want to return a captcha and verify it in a further step.
session_start();
if (isset($_POST['action']) && $_POST['action'] == 'block'){
$_SESSION['blocked'] = true;
die('block');
}else if (isset($_POST['action']) && $_POST['action'] == 'check'){
if ($_POST['value'] == $_SESSION['captcha']){
$_SESSION['human'] = true;
$_SESSION['is_bot'] = false;
die('unblock');
}
}else{
header('Content-Type: image/png');
$width = 200;
$height = 30;
$im = imagecreatetruecolor($width, $height);
$white = imagecolorallocate($im, 255, 255, 255);
imagefilledrectangle($im, 0, 0, 399, 29, $white);
$characters = '123456789abcdefghijklmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ';
$randomString = '';
for ($i = 0; $i < 6; $i++) {
$char = $characters[rand(0, strlen($characters) - 1)];
imagestring($im, '2', 5+$i*20+rand(0, 10), 10, $char, imagecolorallocate($im, rand(0,100), rand(0, 100), rand(0, 100)));
$randomString .= $char;
}
$_SESSION['captcha'] = $randomString;
imagepng($im);
imagedestroy($im);
}
You can experiment a bit with the code. I chose 6 characters from the range [0-9a-Z] without the letters o, O and 0, so that older users don't mix them up :-)
10. Checking the captcha
Finally we check the value of the captcha. For that we add the following JavaScript:
$("#checkCaptcha").click(function(){
$.post("captcha.php", { action: 'check', value: $('#txtCaptcha').val() }).done(function(data) {
if (data == 'unblock') {
$("#captcha_wrapper").css("display", 'none');
}else{
$("#captcha_out").attr("src", "captcha.php");
}
});
});
When the #checkCaptcha button is clicked, we post the value 'check' as the action and the entered string from #txtCaptcha. If we get 'unblock' back, we hide the captcha wrapper again and the user can continue as normal. Otherwise a new captcha is shown.
11. Value check
Finally we change the check at the beginning of every protected file and also check that $_SESSION['human'] is not true. The purpose is that a captcha that has already been entered means future "too fast typers" are no longer treated as bot activity.
session_start();
session_destroy();
$_SESSION['is_bot'] = false;
if (isset($_SESSION['is_bot']) && $_SESSION['is_bot'] === true && !$_SESSION['human'] === true) die("No access for bots");
The message "No access for bots" can of course also be made a bit funnier, and anyone who really wants to be on the safe side can ask for a captcha here again - possibly the user didn't enter the captcha correctly on the first prompt (or didn't even try), closed the browser and now wants to try the captcha again - then they would theoretically have to wait until the session has expired (or cookies have been deleted).
TL;DR
- User types faster than 35 characters in 5 seconds? Maybe a bot?
- Show a DIV with a captcha - temporarily lock the user out via session right away
- Ask for the captcha - if correct, set the session variable; if not, generate a new captcha.