Skip to content

phil/spam-protection

alpha

Spot spam in a form submission without a captcha, in plain PHP.

phil/spam-protection checks a submission against several signals. A signed token (HMAC-SHA256) says where and when the form was rendered, and TimingPolicy judges the time the filling took. ProofOfWork checks a small computation the browser has done. ContentRules looks at the text for bytes that are not UTF-8, too many links, forbidden terms and gibberish. Each check raises a Signal, and an Inspection collects them. Every signal is an observation the server makes itself, never a claim the client makes.

It is for any PHP code that receives forms from strangers and wants to keep spam out without making people solve a puzzle. It does not render forms, keep used tokens or count submissions. Those depend on a framework, so they stay out of the library.

In a Symfony application, use phil/spam-protection-bundle. It adds the form option, the Stimulus controller that solves the proof, the store of used tokens and the rate limit. The library itself needs only ext-sodium and symfony/string, and its classes are small values and rules that return signals.

Status

PartState
Inspection\Signal, Inspection\InspectionImplemented and tested
Token\SubmissionToken, Token\TokenCodec (HMAC-SHA256)Implemented and tested
Timing\TimingPolicyImplemented and tested
Proof\ProofOfWorkImplemented and tested
Content\ContentRulesImplemented and tested

Documentation

DocumentWhat it covers
InstallationRequirements.
SignalsEach check, the signal it raises, and how to run them.
Extension pointsThe ports, arguments and data an application chooses or replaces.
TestingHow the library is tested.
ReferenceEvery type.

Quick start

When the form is rendered, issue a token for it and give the browser its nonce to solve:

use Phil\SpamProtection\Token\SubmissionToken;
use Phil\SpamProtection\Token\TokenCodec;

$codec = new TokenCodec($secret);
$token = new SubmissionToken('contact', time(), bin2hex(random_bytes(16)));
$field = $codec->encode($token);        // a hidden field of the form; $token->nonce is the proof's challenge

When the form comes back, check every signal. decode() returns null for a missing, forged or malformed token, which is exactly what a robot sends, and it checks only the signature: comparing the token's scope with the form's is the caller's job.

use Phil\SpamProtection\Content\ContentRules;
use Phil\SpamProtection\Inspection\Inspection;
use Phil\SpamProtection\Inspection\Signal;
use Phil\SpamProtection\Proof\ProofOfWork;
use Phil\SpamProtection\Timing\TimingPolicy;
use Phil\SpamProtection\Token\SubmissionToken;

$encoded = $submitted['_token'] ?? '';
$token = '' === $encoded ? null : $codec->decode($encoded);
$inspection = Inspection::clean();

if ('' === $encoded) {
    $inspection = $inspection->with(Signal::TokenMissing);
} elseif (!$token instanceof SubmissionToken || 'contact' !== $token->scope) {
    $inspection = $inspection->with(Signal::TokenForged);   // unreadable, or issued for another form
} else {
    if (!new ProofOfWork()->verify($token->nonce, $submitted['_proof'] ?? '')) {
        $inspection = $inspection->with(Signal::ProofInvalid);
    }

    $inspection = $inspection->with(...array_filter([new TimingPolicy()->check($token->issuedAt, time())]));
    // Then claim $token->nonce in the application's store of used tokens: TokenReplayed if it was already there.
}

$inspection = $inspection->with(...new ContentRules(maximumLinks: 2)->inspect($submitted['message'] ?? ''));

if ($inspection->isSpam()) {
    // hold the submission: $inspection->signals says why
}

phil/spam-protection-bundle runs this whole flow, the store of used tokens and the rate limit included, in its SubmissionInspector.

Requirements

PHP 8.5 or later with ext-sodium; symfony/string 8.2.

Contributing

Contributions are welcome on the PHIL monorepo: read the contribution guide and the code of conduct. Report a vulnerability privately, as the security policy explains, never in a public issue.

License

MIT, © Philippe Gamache, doing business as Le Gnome Archiviste; a Parler Haut Interagir Librement project. See LICENSE.