modshield / flarum
ModShield anti-spam connector for Flarum
Package info
github.com/mehdi-zaidi/ModShield-Flarum
Type:flarum-extension
pkg:composer/modshield/flarum
Requires
- php: >=8.0
- flarum/core: ^1.0
- guzzlehttp/guzzle: ^7.0
Requires (Dev)
- phpunit/phpunit: ^10.0
README
Anti-spam protection for Flarum forums powered by ModShield Core.
Installation
composer require modshield/flarum
Configuration
-
Enable the extension in Flarum admin panel
-
Go to the extension settings page
-
Enter your ModShield Core URL and API Key
-
Select the Mode:
- Disabled — Extension does nothing (no events sent)
- Active — Events are sent to ModShield Core and its decisions are enforced (spam posts are hidden)
Observe-only is controlled by ModShield Core, not the connector: keep the connector Active and set your ModShield Core site to shadow mode. Core then returns
allowand the connector hides nothing. -
Select the Fail Strategy (when ModShield Core is unavailable):
- Fail open — Posts are allowed through
- Fail closed — Posts are held for review
-
(Optional) Set the Moderator group ID — members of this group are reported to ModShield as moderators (Flarum's default "Mods" group is
4).
Performance
Calls to ModShield Core run through Flarum's queue, so they do not block the web request. With Flarum's default synchronous queue they execute inline (as before); configure a real queue driver (e.g. Redis) to process them in the background.
How It Works
When a user creates or edits a post, the extension:
- Builds a standardized payload with post content, user info, and links
- Sends it to your ModShield Core instance for analysis
- Applies the decision (allow, hide for review, or block)
When a moderator hides or deletes a post, feedback is sent to ModShield to improve future detection. When a moderator restores a hidden post, it's reported as a false positive.
Capture Signals
The forum JS (js/src/forum/index.ts) attaches lightweight, best-effort
instrumentation to the composer, never blocking submission:
- When the composer opens, it requests a one-time token from
GET /modshield/capture-tokenand records the time. This route is proxied through Flarum's own API — it forwards to ModShield Core's/api/v1/capture/tokenusing the connector's configured API key, which never reaches the browser. Requires an authenticated user. - It injects a hidden honeypot field (
ms_website, positioned off-screen rather thandisplay:none, so naive autofill bots still populate it) into the composer form. - On submit, it attaches three attributes to the post request:
modshieldToken(the fetched token, ornullif the fetch failed),modshieldFillMs(time elapsed since the composer opened), andmodshieldHoneypot(the honeypot field's value).
PostSavingListener reads these attributes off Flarum's Saving event and
stashes them on the post as modshieldCapture. PayloadBuilder::fromPost()
folds them into a capture block on the outbound payload: form_token,
fill_time_ms (only when a non-negative integer), and honeypot_filled
(boolean).
No-JS behaviour: if a post is created without the forum JS running (no-JS
client, or a direct API call), the capture attributes are simply absent.
PostSavingListener still emits a capture array, with a null token —
the absence of instrumentation is itself a signal ModShield Core can score.
Nothing is blocked or required.
Signed Callbacks
ModShield Core can call back into Flarum to recommend a decision after asynchronous scoring.
Setup:
- In your ModShield Core site settings, set the callback URL to
<forum apiUrl>/modshield/callback. - Copy the secret ModShield shows once at that point.
- Paste it into the Flarum admin panel's Callback secret setting.
Signature scheme (Stripe-style): requests carry an
X-ModShield-Signature header shaped like
t=<unix timestamp>,v1=<hex HMAC-SHA256 of "{t}.{raw body}" using the secret>.
Multiple v1= entries are accepted, to support secret rotation. The
timestamp must be within 300 seconds of the current time, and match one of
the v1 candidates.
The callback recommends, the forum stays authoritative: the
recommendation is only enforced when the extension mode is Active — in
any other mode the callback is recorded as observed and nothing changes. Even
in Active mode, an already-hidden post is left alone, and only a
recommended_action of block or send_to_review is enforced (anything
else, including allow, is a no-op). Enforcement hides the post, same as the
inline check.
Status responses (POST /modshield/callback):
503— no callback secret configured yet (ModShield Core should keep retrying)401— signature missing or invalid (no further detail is returned)200with{"status": "..."}for every signature-verified payload, so ModShield Core stops retrying.statusis one of:ignored— request body isn't valid JSONexpired— payload'sexpires_atis in the pastnot_found—content_external_iddidn't resolve to a known post (expects the formpost_<id>)observed— extension mode isn't Activenoop— recommended action isn't enforceable, or the post was already hiddenapplied— the post was hidden per the recommendation
Requirements
- Flarum 1.x
- PHP 8.0+
- A running ModShield Core instance
Development
# PHP composer install --ignore-platform-reqs php vendor/bin/phpunit # Front-end (TypeScript; rebuild js/dist after changing js/src) cd js npm install npm run check-typings # tsc type check (needs composer install for Flarum core typings) npm run format # prettier npm run build
License
MIT