Prevent form spamming
It's an often-heard complaint that online forms can be accessed by spammers (like spambots), resulting in lots of unwanted and unpleasant form entries. The main reason is that it's often relatively easy for spammers to understand the content and structure of simple online forms. As soon as a spammer knows this, it can start posting spam entries automatically.
To prevent spam entries, you often see 'solutions' that are difficult for spammers to fill out, like decoding unreadable CAPTCHAs, identifying traffic signs, or validating you're no robot.
Those solutions technically may work to prevent most spam entries, but it also works very well for something else: a highly decreased response rate! Nobody likes to fill out those questions, and often it takes longer to solve the puzzle than to fill out the form itself. Not very good for your user's experience, nor your response rate.
Tripetto contains an alternative solution to prevent (multiple) automatic submissions from spambots. It works by increasing the posting difficulty, making it harder or even impossible for a spambot to submit data. This guide shows how to implement this mechanism.
🎓 How it works​
Tripetto's antispam solution works by requiring a client (that wants to submit response data to your endpoint) to solve a cryptographic puzzle. Only when the client takes the time to solve the puzzle and include the right answer with the response, the data is accepted by the endpoint. The trick here is that the puzzle's complexity (or difficulty) can be adjusted based on heuristics defined by the endpoint. For example, we could increase complexity if many submissions are made by a certain client (IP address) or in a certain timeframe. This allows implementing an effective rate limiter without requiring work on the server-side (endpoint). Verifying the answer of the puzzle is very lightweight and does not consume much server resources. On the other hand, solving the puzzle is very resource expensive (especially when the difficulty raises). That's not a problem for a single valid response. But it is an issue for spambots making it less likely they target a Tripetto form.
A typical submission to an endpoint that supports this antispam technique follows these steps:
- Step 1: A client wants to submit response data to an endpoint. Therefore it generates an
announcement
request that includes a checksum calculation of the response data; - Step 2: The endpoint receives the announcement, stores the checksum for later reference, and returns a response with an identifier, a difficulty number, and a timestamp. The difficulty number is essential here, as it specifies the complexity of the cryptographic puzzle. In a typical setup, the difficulty begins at 10 and then increases when necessary (for example, when the same IP address already submitted another response in a specific timeframe);
- Step 3: The client receives the announcement response and needs to solve the cryptographic puzzle. The returned identifier, timestamp, and response data are all part of this puzzle. When the puzzle is solved, its outcome (a cryptographic hash) is submitted to the endpoint together with the actual response data;
- Step 4: The endpoint then checks the answer to the puzzle. It also calculates the checksum based on the received response data to verify that the client submits the same data it announced in the announcement request (step 1). If all of these checks pass, the response data is stored by the endpoint. Optionally, the identifier (or reference) of the data on the server can be returned to the runner (more info about that here).
Besides the steps above, you should also always validate the response data using the data stencil hash as described in the Validating response data guide.
🤖 Implementation​
The following code shows how to implement the mechanism described above. On the client it uses the powSolve
function to solve the cryptographic puzzle. If you are using Node.js on the server endpoint, you can use the checksum
and powVerify
functions from the Runner library.
- Client
- Server
import { run } from "@tripetto/runner-autoscroll";
import { Export, checksum, powSolve } from "@tripetto/runner";
run({
definition: /* Supply your form definition here */,
onSubmit: (instance) =>
new Promise((resolve, reject) => {
// Prepare the response data
const data = {
exportables: Export.exportables(instance),
actionables: Export.actionables(instance),
};
// Post the announcement to the server
fetch("/announcement", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
// Generate the checksum for the data and announce it
checksum: checksum(data, true),
}),
}).then((response) => {
if (response.ok) {
const announcement = await response.json();
// Retrieve the id, difficulty and timestamp from the server announcement response
const id = announcement.id;
const difficulty = announcement.difficulty;
const timestamp = announcement.timestamp;
// Solve the puzzle
try {
const answer = powSolve(
data,
difficulty,
id,
16,
1000 * 60 * 5,
timestamp
);
} catch {
// Puzzle not solved, reject the promise
reject();
}
// Submit the response to the server
fetch("/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...data,
id,
answer,
}),
}).then((response) => {
if (response.ok) {
// All good, resolve the promise
resolve();
} else {
// Not so good, reject the promise
reject();
}
});
}
});
}),
});
// This example assumes you are using Node.js at the server-side
import { checksum, powVerify } from "@tripetto/runner";
// This server-side function should get called for each announcement request
function announce() {
// Retrieve the checksum
const checksum = req.body.checksum;
// Calculate the desired difficulty here
const difficulty = 10;
// You should store the checksum and difficulty somewhere, for example, in a database
const id = /* Announcement id from store/database */;
// Return data back to the client
return {
id,
difficulty,
timestamp: Date.now(),
};
}
// This server-side function should get called for each submit request
function submit() {
// Retrieve all the data posted by the client
const exportables = req.body.exportables;
const actionables = req.body.actionables;
const answer = req.body.answer;
const announcementId = req.body.id;
// Retrieve the previously stored announcement data using the id
const announcementChecksum = /* Checksum stored in announcement store. */;
const announcementDifficulty = /* Difficulty stored in announcement store. */;
// Check if the checksum is equal by calculing it from the data and comparing it
if (
checksum(
{
exportables,
actionables,
},
false
) === announcementChecksum
) {
// Next check the answer
if (
powVerify(
answer,
announcementDifficulty,
1000 * 60 * 15,
{
exportables,
actionables,
},
announcementId
)
) {
// All good!
return true;
}
}
// Submit failed
return false;
}
👀 Examples​
If you want to see examples of this mechanism in production, have a look here:
- Tripetto Studio (Node.js / TypeScript)
- Tripetto WordPress plugin (PHP)