ALTCHA: Technical Details
ALTCHA spam protection for Shopware 6.7 - check order as a diagram, registration protection, gibberish score and deprecated content validation, database tables, logging, security concept, plugin structure
ALTCHA Spam Protection: Technical Details
This documentation always refers to the latest published release.
Live demo: Try this and other plugins live, no installation, no risk: demo.markus-michalski.net
Check order
All three protected forms run through the same SpamProtectionFacade. The order is sorted by cost: cheap in-memory checks first, the cryptographic ALTCHA check after that, the rate limit with database access last.
graph TD
A["Submission"] --> M{"Master switch on?"}
M -->|"No"| OK["Form is processed"]
M -->|"Yes"| B{"Honeypot field filled?"}
B -->|"Yes"| R1["Rejected: honeypot_triggered"]
B -->|"No"| C{"Faster than minimum fill time?"}
C -->|"Yes"| R2["Rejected: too_fast"]
C -->|"No"| D{"Disposable check on and match?"}
D -->|"Yes"| R3["Rejected: disposable_email"]
D -->|"No"| E{"Gibberish on and score too high?"}
E -->|"Yes"| R4["Rejected: gibberish"]
E -->|"No"| F{"Content validation on, not replaced, 2+ hits?"}
F -->|"Yes"| R5["Rejected: content_validation_failed"]
F -->|"No"| G{"ALTCHA valid?"}
G -->|"No"| R6["Rejected: altcha_failed or altcha_missing"]
G -->|"Yes"| H{"Rate limit ok?"}
H -->|"No"| R7["Rejected: rate_limited"]
H -->|"Yes"| I["Form is processed"]
A layer that is switched off is skipped. Content validation is additionally skipped as soon as gibberish detection or the disposable e-mail check is on. The rate limit counts only submissions that passed all layers before it.
Registration protection
Registration is protected through a route decorator, not an event. The check therefore runs before the account is created.
sequenceDiagram
participant B as Browser
participant D as RegisterRouteDecorator
participant F as SpamProtectionFacade
participant R as RegisterRoute
B->>D: submit registration
D->>D: protection active?
D->>F: validate
F-->>D: result
alt spam detected
D-->>B: rejected
else no spam
D->>R: register
R-->>B: account created
end
Contact form and newsletter run through event subscribers on contact_form.send and NewsletterRegisterEvent (priority 500), using the same facade.
Gibberish score
Gibberish detection scores every text field and takes the worst value. The score is the share of letters in conspicuous words, weighted by the amount of evidence:
| Signal | Description |
|---|---|
| Letter sequences | The trigrams of a word are implausible in every language of the bundled table (German, English, French, Spanish, Italian, Dutch) |
| Keyboard runs | Almost all letters of a word are neighbours in one keyboard row (asdfgh, qwertz) |
| Repetition | A word consists of a short repeated unit (asdfasdf) |
Skipped are URLs, e-mail addresses, code, words containing digits or symbols, and words in non-Latin scripts. Capitalised words count less (names), and short words rarely trigger a rejection on their own. A long lowercase word made of impossible letter sequences, however, can reach a high score by itself. The table is at src/Resources/data/gibberish-trigrams.php. If it is missing or corrupt, the check scores nothing and logs a single warning.
The deprecated content validation, in contrast, uses fixed rules (consonant ratio, keyboard sequences, repetition, consonant clusters, letter pairs) on first name, last name, street and city as well as the e-mail domain, and blocks from two conspicuous fields.
Database tables
mmd_altcha_rate_limit_log
Stores individual timestamps for a sliding time window.
| Column | Type | Description |
|---|---|---|
id |
BINARY(16) |
Primary key |
ip_hash |
CHAR(64) |
HMAC-SHA256 hash of the IP address |
action |
VARCHAR(50) |
contact_form, newsletter, registration, challenge |
sales_channel_id |
BINARY(16) |
optional, foreign key to sales_channel |
requested_at |
DATETIME(3) |
Time of the request |
Indexes: idx_rate_check (ip_hash, action, requested_at), idx_rate_check_channel (adds sales_channel_id) and idx_cleanup (requested_at).
mmd_altcha_used_challenge
Prevents replay attacks.
| Column | Type | Description |
|---|---|---|
challenge_hash |
CHAR(64) |
Primary key, nonce of the challenge |
expires_at |
DATETIME(3) |
Expiry time |
created_at |
DATETIME(3) |
Creation time |
INSERT IGNORE claims a challenge atomically: only the first writer wins.
The disposable-domain list and the trigram table are files in the plugin or under var/mmd_altcha/, not in the database.
Logging and rejection reasons
Blocked attempts appear in the Shopware log, never with the submitted text:
[INFO] Spam attempt blocked {"reason":"honeypot_triggered","ip_hash":"...","sales_channel_id":"..."}
[INFO] Registration spam attempt blocked {"reason":"disposable_email","ip_hash":"...","sales_channel_id":"..."}
reason |
Meaning |
|---|---|
honeypot_triggered |
Honeypot field was filled |
too_fast |
Submission faster than the minimum fill time |
disposable_email |
Disposable e-mail address detected |
gibberish |
Gibberish score reaches the reject threshold |
content_validation_failed |
Two or more fields conspicuous in the deprecated content validation |
altcha_missing |
No ALTCHA payload in the request |
altcha_failed |
ALTCHA solution invalid, expired or already used |
rate_limited |
IP rate limit exceeded |
Security concept
graph TD
A["APP_SECRET"] -->|"signs"| B["ALTCHA challenge"]
A -->|"signs"| C["Form token"]
A -->|"hashes"| D["IP addresses"]
B -->|"solved and verified"| E["Nonce is claimed"]
E -->|"INSERT IGNORE"| F["mmd_altcha_used_challenge"]
D --> G["mmd_altcha_rate_limit_log"]
H["Task rate_limit_cleanup"] -->|"every 6 hours"| F
H --> G
- HMAC-SHA256 signs challenges, form tokens and IP hashes. The key is the environment variable
APP_SECRET. If it is empty, verifying a solution fails closed (the submission is rejected). The challenge endpoint, in contrast, throws an exception and answers with HTTP 500, and outputting the token field (form_token_field) causes an error on the form page. - The form token is compared with
hash_equals(). - Single-use solution: A challenge is accepted only while its nonce has not been claimed. A challenge without an expiry or with an algorithm other than PBKDF2 is rejected.
- On activation the plugin backs up the native captcha configuration and switches it off; on deactivation it restores it.
Plugin structure
MmdAltchaIntegration/
βββ Altcha/ # bundled altcha-org/altcha library (MIT)
βββ Command/UpdateDisposableDomainsCommand # mmd:altcha:update-disposable-domains
βββ Controller/Api/BlockedDomainsController # GET /api/_action/mmd-altcha/blocked-domains
βββ Core/
β βββ Checkout/Customer/SalesChannel/RegisterRouteDecorator
β βββ Content/RateLimitLog/ # entity definition, entity, collection
βββ DisposableEmail/ # checker, list reader, updater, catalog, parser
βββ Exception/ # SpamDetectedException, SpamRejection
βββ Gibberish/ # guard, scorer, score, TrigramModel
βββ Migration/ # rate-limit and used-challenge tables
βββ ScheduledTask/ # rate_limit_cleanup, disposable_domains_refresh
βββ Service/ # challenge, honeypot, form token, rate limit, facade
βββ Storefront/Controller/AltchaChallengeController
βββ Subscriber/ # ContactFormSubscriber, NewsletterSubscriber
βββ Twig/AltchaExtension # Twig functions for templates