ALTCHA: Content Checks
ALTCHA spam protection for Sylius - disposable-mail and gibberish checks in detail: scope, matching, scoring, logging and a safe step-by-step rollout without false positives
ALTCHA: Content Checks
Live Demo: Try this and other plugins live — no installation, no risk: demo.markus-michalski.net
Two optional checks look at what a submission contains rather than how it arrived: the disposable-mail check and the gibberish check. You switch each on per channel in the settings, and both are off by default. They run after the rate limit, honeypot and minimum fill time and before the proof-of-work verification, so a rejected submission does not consume a challenge.
Both checks are aids, not guarantees: they lower the noise but never catch every spam message, and in rare cases they can hit a real customer.
Disposable-mail check
Rejects a submission whose e-mail address belongs to a known disposable-mail provider (Mailinator and similar throwaway-inbox services). Switch it on with the channel setting Reject e-mail addresses from disposable providers. With it off, nothing changes.
Where it applies
To every protected target whose submission carries an e-mail address: registration, contact, the newsletter and support-form signups, and a custom form that has one. It does not apply to login, checkout_login or password_reset_request, even though the last one has an e-mail field. Those act on an account that may already exist, and rejecting them by the address's domain reputation would lock a legitimate customer out of their own account instead of stopping new spam.
A rejected submission gets the Shop API reason disposable_email (HTTP 422, same {message, reason} shape as every other rejection) and a neutral message that never names the block list.
Data source and refresh
A snapshot of the CC0-licensed disposable-email-domains/disposable-email-domains list ships in data/disposable-email-domains.conf. No request ever calls out to the network: the file is read into memory once and kept for the rest of the request. A long-running worker (FrankenPHP, RoadRunner) re-reads it on the next request rather than keeping it across requests.
Refresh it from your host's own scheduler (weekly is plenty):
bin/console mmd:altcha:update-disposable-domains
The command downloads the upstream list into var/mmd_sylius_altcha/disposable-email-domains.conf and fails closed: a network error or an empty or unparseable response leaves the previous file (bundled or previously refreshed) in place rather than switching the check off by replacing it with nothing.
Matching
The submitted domain and every parent of it count (foo.mailinator.com matches because mailinator.com is listed), never the bare public suffix alone. This is an exact-level walk, not a full public-suffix-list lookup, so a registrable domain under a multi-label suffix (spam.co.uk) is also checked one level too high (co.uk). That is harmless in practice, since no one lists a public suffix itself as disposable. IDN domains are compared in their ASCII (punycode) form when ext-intl is available, and as given otherwise.
Your own overrides
Admin -> MMKreativ Plugins -> ALTCHA spam protection -> button ALTCHA disposable-mail overrides. Two kinds of entry, both matched the same way as the bundled list:
- Deny adds a domain the snapshot is missing.
- Allow always wins, at any level, even over a denied parent domain. Use it for a legitimate domain that wrongly ended up on the list. Relay and alias services (SimpleLogin, Apple's Hide My Email and similar) are privacy tools real customers use, not disposable mail: allow-list one if it ever gets flagged.
On the overrides page, the button ALTCHA blocked domains opens a read-only, searchable list of every domain the check currently blocks (the bundled or refreshed list plus your own deny entries). Entering a deny override for a domain that is already blocked, itself or through a parent (x.mailinator.com via mailinator.com), is rejected with the entry that already covers it. An allow entry for a domain nothing blocks is saved, with a warning that it has no effect yet.
Gibberish check
Scores the text fields of a submission and rejects what is very likely meaningless, such as keyboard mashing (fdsgsdfgsdf, asdfasdf, qwertzuiop). It is the layer after the honeypot and the minimum fill time for someone, or some script, that does solve the proof-of-work. Switch it on with the channel setting Reject meaningless free text. With it off, nothing changes.
What it looks at
Free-text fields only: a form's TextType and TextareaType fields (never e-mail, password, URL, telephone or choice fields). In a Shop API or event-enforced request these are the keys message, subject, name, firstName, lastName, fullName, comment, comments, text, body, description, content, note, notes, title, question and remarks, one level into nested objects. Case, hyphens and underscores in the key do not matter (first_name counts like firstName). It applies to every protected target that has such fields (contact, a custom form, the support form through its request) and, like the disposable-mail check, never to login, checkout_login or password_reset_request. Each field is scored on its own and the worst one decides.
How it scores
Every word is judged by three signals. The score (0 to 100) is the share of letters in flagged words among the letters of all judged words, weighted by how much evidence the text gives:
- Implausible letter sequences: the word's character trigrams are rare in every language the plugin ships a table for (German, English, French, Spanish, Italian, Dutch; built from public-domain books with
scripts/build-ngram-table.php, stored indata/gibberish-trigrams.php). - Keyboard runs: most letters of a word are neighbours on one keyboard row (
asdfgh; a four-letter word only if every letter is,asdf). - Repetition: a word is one short unit repeated (
hjkhjkhjk), or a short token typed over and over (asdf asdf asdf).
Not judged at all are URLs, e-mail addresses, code fences, every token that contains a digit, an underscore, a slash, a dot or another symbol (paths, class names, stack traces, hashes, license keys, SQL), and any word that is not written in Latin letters. Short text gives little evidence: a lone unusual word (a Polish surname looks implausible to a table without Polish) can raise the score a little but cannot reject on its own, whereas a long run of mashing can. A capitalised word counts for less, because it is most likely a name. A single three-letter word is never evidence (pdf, csv and sql look exactly like three mashed letters). Tokens shorter than five letters count only if they look implausible and, in addition, either occur at least three times (sdf sdf sdf) or are a four-letter keyboard run (asdf). The share is calculated from the letters in judged words, those of five letters and more (plus short tokens that are a keyboard run or repetition), so one mashed word between a few short honest ones is still seen.
Two thresholds and logging
At or above the reject score (default 85) the submission is rejected with the neutral message "Your message could not be accepted" and, on the Shop API, HTTP 422 with the reason gibberish. Between the log score (default 60) and the reject score nothing is blocked: the submission is only recorded.
Every submission at or above the log score, blocked or not, is recorded in two places, never with the text:
- the admin list ALTCHA gibberish log (button on the channel settings page): time, channel, form, result, score and reasons, newest first, filterable by result and form;
- the PSR logger on the Monolog channel
altcha(infofor a suspicious submission,warningfor a rejection).
The admin list is the place to look: a standard Sylius production Monolog setup wraps its handler in
fingers_crossedwithaction_level: error, which dropsinfoandwarningentries unless an error happens in the same request.
mmd:altcha:purge removes entries older than 30 days (--gibberish-log-retention=<seconds>). A host that has not run the migration yet loses the entries, not the submissions.
False positives and tuning
A false positive turns away a real customer, a false negative lets one spam message through, so the defaults are conservative and you should roll the check out in two steps:
- Switch it on with the reject score raised to
100(the highest value the field accepts), so it almost never blocks. A submission is rejected at a score greater than or equal to the reject score, and only extreme cases such as a very long text of pure keyboard mashing reach 100. - Watch the gibberish log for a while and lower the reject score only once what is logged is really spam.
The reasons in the log say why: pattern_words is the share of letters in keyboard runs and repetitions, implausible_letter_sequences the share in words no language table knows, letters the amount of prose that was judged.
Where the risk is real:
- Languages without a table. A long text in Polish, Turkish, Finnish or Czech scores higher than it should. The corpus test covers a sample of them passing, but a table per language is the real fix:
scripts/build-ngram-table.phpbuilds one from any plain-text books named<language>_<name>.txt. - Plain-letter codes and product names that no language knows.
The corpus in tests/Fixtures/gibberish/ is the acceptance test: add an honest sample that scored too high before changing a threshold or a signal.
A rollout example with concrete steps is under Configuration.