ALTCHA: Technical Details
ALTCHA spam protection for Sylius - check chain as a diagram, the three enforcement paths (form, route, event), plugin structure and the six entities with their own tables
ALTCHA: Technical Details
Live Demo: Try this and other plugins live β no installation, no risk: demo.markus-michalski.net
How the checks run
graph TD
A["Submission / Request"] --> B{"Channel enabled?"}
B -->|"No"| C["Unprotected"]
B -->|"Yes"| D{"Target ticked?"}
D -->|"No"| C
D -->|"Yes"| E["Rate limit"]
E -->|"exceeded"| F["429 / rate_limited"]
E -->|"ok"| G["Honeypot + fill time"]
G -->|"failed"| H["Reject - form error / 422"]
G -->|"ok"| I{"Disposable-mail / gibberish active?"}
I -->|"flagged"| H
I -->|"off or passed"| J["Verify ALTCHA proof"]
J -->|"invalid or reused"| H
J -->|"valid"| K["Submission accepted"]
Rate limit, honeypot, minimum fill time and the content checks run ahead of the proof-of-work verification (cheapest first) through all three enforcement mechanisms alike - a target is checked the same way regardless of which path reaches it.
The three enforcement paths
graph LR
A["Twig form (Storefront)"] --> D["Form validation"]
B["Route (login / checkout / Shop API)"] --> E["Route check on kernel.request"]
C["Plugin event (support form, newsletter)"] --> F["Event listener"]
D --> G["Submission guard chain"]
E --> G
F --> G
G --> H["Channel settings"]
| Mechanism | Used for | Runs |
|---|---|---|
| Form validation | registration, contact, forgotten password, custom forms | After the regular constraints, so a form that is invalid for another reason does not consume a challenge. Live Component re-renders are skipped. |
| Route check | login, checkout sign-in, Shop API operations | On kernel.request, before the firewall. For storefront routes the locale is already set by then; for API routes Sylius sets the request locale from the channel later, so the check reads the locale from Sylius' locale context there instead. |
| Event listener | support form, newsletter, and any target that declares events | On the other plugin's own rejectable event (storefront and API alike) |
A form rejection is added as a form error and also flashed, because Sylius' shop templates render fields individually and never a form's own errors.
Plugin structure
src/
βββ Challenge/ # Challenge generation and signing
βββ Command/ # SetupCommand, PurgeCommand, UpdateDisposableDomainsCommand
βββ Controller/ # ChallengeController, BlockedDomainsController
βββ DependencyInjection/
βββ DisposableEmail/ # Disposable-mail guard, domain list, override entities
βββ Enforcement/ # Form type extension, route listener, event listener/registrar
βββ Entity/ # ChannelSettings, CustomTarget, DisposableDomainOverride, GibberishLogEntry, RateLimitHit, UsedChallenge
βββ Form/Type/ # Admin form types
βββ Gibberish/ # Scorer, trigram tables, build script
βββ Guard/ # SubmissionGuard orchestrating all checks, Rejection enum
βββ Menu/ # Admin sidebar entry
βββ Migrations/ # Seven Doctrine migrations
βββ RateLimit/ # Rate limiter
βββ Request/
βββ Security/ # Secret cipher (libsodium)
βββ Settings/ # Channel settings provider/factory
βββ Submission/
βββ Target/ # ProtectionTarget, registry, built-in targets
βββ Twig/ # mmd_altcha_widget() function
βββ Validator/
βββ Verification/ # Proof verification and replay guard
config/
βββ grids/ # Four admin grids
βββ routes/ # admin.yaml, shop.yaml
βββ twig_hooks/ # admin.yaml (title-block and action hooks in the admin), shop.yaml (storefront widget hooks, incl. mmd_altcha.widget*)
Entities
ChannelSettings, CustomTarget, DisposableDomainOverride, GibberishLogEntry, RateLimitHit and UsedChallenge: six tables named mmd_sylius_altcha_*. There are seven migrations:
- The first creates two tables (
used_challengeandrate_limit_hit). - The second creates
channel_settings, the thirdcustom_target. - The fourth adds the disposable-mail switch to
channel_settingsand createsdisposable_domain_override. - The fifth only adds the gibberish-check columns to
channel_settings. - The sixth creates
gibberish_log. - The seventh only adds the challenge endpoint's call limit to
channel_settings.
Operating notes
- Client address. Rate limits are per form and client. IPv6 clients are counted per /64. Behind a reverse proxy or CDN, set Symfony's
framework.trusted_proxies(andtrusted_headers), or every visitor shares one bucket and a burst from a single person locks the form for everybody. - Checkout sign-in. The returning-customer login inside the checkout (
sylius_shop_json_login_check) posts through its own JavaScript, which cannot carry a proof. It is therefore only rate limited (targetcheckout_login), never asked for a proof. The limit counts every attempt, successful ones included, and is shared by everyone behind one IPv4 address: for customers behind a carrier-grade NAT or an office address, raise the rate limit or untick the target. - Newsletter checkbox. The newsletter signup that piggybacks on the registration form's checkbox is not checked a second time, because registration has its own check. The public signup forms and the API are checked.
- Two targets, one event. Only one target enforces a given event (the first one registered). A second target declaring the same event is ignored with a warning, because both would check the same submission and the second would find the proof already consumed.
- Live Components. The registration form is a Live Component that re-validates on every field change. Those re-renders are not checked. A custom LiveAction that really submits a protected form is checked and must send the proof (
X-Altcha-Payloadheader or analtchafield). - Logging. When the database cannot answer (for example before the migration ran), forms run unprotected rather than failing. A warning is logged each time.