Web Application Firewall for SPIP. Blocks malicious requests and bans repeat offenders.
Installation
SPIP WAF is installed like any other SPIP plugin.
Background
SPIP is a secure CMS with a strong track record in terms of security and stability. At the same time, webmasters see an increased flood of malicious server attacks from whom they need to protect themselves. This plugin will block malicious requests and help protect sites from malicious traffic.
How it works
The WAF has two aspects.
1. RULES BASED BLOCKING:
- Rules check every request for suspicious patterns (SQL injection, path traversal, CMS probes, etc.)
- Each violation is blocked and logged. The user can see a block screen with a link to unblock and return to the homepage (useful for false positives).
- After several violations, the IP gets banned.
- Banned visitors see an unblock form (useful for false positives). Via the button they can unblock and return to the homepage.
- If they keep triggering rules after unblocking, they get banned again.
- After too many bans, the IP is permanently hard-blocked. In this case, users (bots) see a plain 403 error.
Flow: Blocked → Banned → Hard-blocked
2. PUBLIC IP LIST BLOCKING:
The plugin also integrates with public IP blocklists, like FireHOL and Spamhaus, which can be enabled in the config form. Once enabled, the WAF will block all IPs on those lists, as thoses IPs are already listed as known malicious IPs.
Flow: IP detected on enabled blocklist → Hard-blocked
Features
Dashboard
The WAF dashboard in the SPIP back-office (Feedback › SPIP WAF) gives an overview of recent activity. It shows blocked requests, banned IPs, and hard-blocked IPs, along with the rules and blocklists that triggered them. Use it to monitor traffic, spot false positives, and review which IPs have been blocked or banned.
Screenshot: Top section of the dashboard

Screenshot: Detailed section of the dashboard

Config
The config form (Configuration › SPIP WAF) is where you enable the WAF and tune its behaviour.
Screenshot: Configuration form, IP header

Screenshot: Configuration, Lists

Here you have to set the client IP header for the WAF to start working.
You can also tweak the strike and block thresholds, the allowlist and denylist, and select the public IP blocklists to enforce. See the Configuration section below for the full list of available settings.
Locked out?
If you got hard-blocked (e.g. after testing with malicious payloads), allowlist your IP via mes_options.php.
Find your IP at https://iplocation.net (looks like 203.0.113.42).
Tip: If you can’t access the site at all, switch to a different network (e.g. phone hotspot), log into /ecrire from there, and add your IP in the WAF config form. Then switch back.
To allowlist via code, add this to config/mes_options.php (create the file if needed):
define('_WAF_ALLOWLIST', '203.0.113.42');
Multiple IPs, one per line:
define('_WAF_ALLOWLIST',
'203.0.113.42
198.51.100.7
198.51.100.8'
);
Once saved, the WAF lets these IPs through.
ADVANCED
Everything below here is advanced configuration only needed in special cases.
Configuration
Most settings are available in the WAF config form in the SPIP back-office (Configuration › SPIP WAF). They are stored in the SPIP meta table and can be overridden by defining the corresponding _WAF_* constant in config/mes_options.php before the plugin loads.
Config form settings
| Setting | Constant override | Default | Description |
|---|---|---|---|
| Client IP header | _WAF_CLIENT_IP_HEADER |
(none) | HTTP header that contains the real client IP (e.g. HTTP_X_FORWARDED_FOR). Must be set for the WAF to activate. |
| Trusted proxies | _WAF_TRUSTED_PROXIES |
(empty) | Trusted proxy IPs/CIDRs, one per line. Used to walk the X-Forwarded-For chain from the right. |
| Strike threshold | _WAF_STRIKE_THRESHOLD |
3 |
Number of violations before an IP is banned. |
| Block threshold | _WAF_BLOCK_THRESHOLD |
10 |
Number of bans before an IP is permanently hard-blocked (no unblock form). |
| Max GET param length | _WAF_MAX_GET_PARAM_LENGTH |
2000 |
Maximum allowed length of a single GET parameter value. |
| Unblock token TTL | _WAF_UNBLOCK_TOKEN_TTL |
900 |
Validity of the self-service unblock token, in seconds. |
| Show unblock form | _WAF_UNBLOCK_FORM |
true |
Whether banned IPs see a self-service unblock form or a plain 403. |
| Allowlist | _WAF_ALLOWLIST |
(empty) | IPs/CIDRs that bypass all WAF checks, one per line. |
| Denylist | _WAF_DENYLIST |
(empty) | IPs/CIDRs that are always blocked, one per line. |
| Login strike | _WAF_LOGIN_STRIKE |
false |
Count failed login attempts as WAF strikes. |
| Cache TTL | _WAF_CACHE_TTL |
86400 |
WAF cache TTL in seconds (strike counts, ban flags, etc.). |
| Enabled blocklists | _WAF_ENABLED_BLOCKLISTS |
firehol_level1,firehol_level2 |
Comma-separated IDs of public IP blocklists to enforce (managed via checkboxes in the form). |
Advanced configuration
The following constants can only be set in config/mes_options.php. They are not exposed in the config form.
Code-only constants
| Constant | Default | Description |
|---|---|---|
_WAF_TARPIT_DELAY |
0 |
Seconds to delay before sending the 403 response (0 = disabled). See Tarpit delay below. |
_WAF_TARPIT_DELAY_BLOCKED |
null |
Seconds to delay before showing the unblock form (null = no extra delay). See Tarpit delay below. |
_WAF_MESSAGE_403 |
(minimal HTML) | HTML body sent for all 403 block responses. Override to customise the block page. |
_WAF_UNBLOCK_REDIRECT_URL |
/ |
URL to redirect to after a successful unblock. Override if SPIP lives in a subfolder (e.g. /cms/). |
_WAF_RETENTION_BLOCKED_DAYS |
90 |
Days to keep blocked-request events in the database. |
_WAF_RETENTION_BAN_DAYS |
180 |
Days to keep ban/unblock events in the database. |
_WAF_EVENTS_DIR |
tmp/waf_events/ |
Directory for deferred .jsonl event log files (batch-flushed to DB by cron). |
_WAF_FLUSH_BATCH_SIZE |
200 |
Maximum number of event files processed per cron run. |
_WAF_DEBUG_ARCHIVE_EVENTS |
false |
Keep flushed .jsonl event files as .archive instead of deleting them. |
_WAF_BLOCKLISTS |
(FireHOL 1–3, Spamhaus DROP/DROPv6) | Array of blocklist definitions. Add entries to extend coverage. |
_WAF_DEFAULT_BLOCKLISTS |
firehol_level1,firehol_level2 |
Comma-separated IDs enabled by default before the admin changes the selection. |
_WAF_TI_CACHE_DIR |
tmp/waf_ti/ |
Directory for downloaded blocklist data. |
_WAF_TI_CACHE_TTL |
90000 (25 h) |
TTL for cached blocklist data and per-IP lookup results. |
_WAF_CSS |
(not set) | Custom CSS for the unblock-form page. When set, used as an inline <style> instead of the default stylesheet. |
_WAF_PROFILE |
(not set) | Set to microtime(true) to enable per-request profiling. See Profiling. |
Tarpit delay
Denylisted, blocklisted, and hardblocked IPs are blocked immediately by default. You can add a deliberate delay to waste scanner time, at the cost of holding a PHP worker open for that duration.
Disabled by default. Enable with care: if all PHP workers are sleeping simultaneously, your site becomes unavailable. Avoid on servers with few workers or behind proxies with low connection limits.
// Delay in seconds before sending the 403 to denylisted, blocklisted, and hardblocked IPs (0 = disabled)
define('_WAF_TARPIT_DELAY', 25);
A separate, shorter delay can be applied before the unblock form is shown to legitimate users who got caught by a false positive. This deters automated form submissions while keeping the wait tolerable for humans:
// Delay in seconds before showing the unblock form (null = no delay)
define('_WAF_TARPIT_DELAY_BLOCKED', 4);
When _WAF_TARPIT_DELAY_BLOCKED is null (the default), no delay is applied before the unblock form — even if _WAF_TARPIT_DELAY is set. The two delays are independent.
Recommended values: _WAF_TARPIT_DELAY at 25 s+ (bots), _WAF_TARPIT_DELAY_BLOCKED at 3–5 s (humans).
The plugin already sends a 403 Forbidden response to the client. If you are behind a reverse proxy, the safer choice is to implement a tarpit at the proxy level, detecting the 403 and delaying the response there.
Custom rules
The plugin exposes a waf_custom_rules pipeline. Define your rule function in mes_options.php and hook it into the pipeline.
Block a specific GET parameter:
function waf_custom_rule__block_foo_bar($ctx) {
if (isset($ctx['get']['foo']) && $ctx['get']['foo'] === 'bar') {
waf_handle_violation('CUSTOM_RULE', 'foo=bar', $ctx['client_ip']);
}
}
$GLOBALS['spip_pipeline']['waf_custom_rules'] = '|waf_custom_rule__block_foo_bar';
Block e.g. ?page=spipdf (when the SPIPDF plugin is not installed):
function waf_custom_rule__block_spipdf($ctx) {
if (isset($ctx['get']['page']) && $ctx['get']['page'] === 'spipdf') {
waf_handle_violation('CUSTOM_RULE', 'blocked_param=page, value=spipdf', $ctx['client_ip']);
}
}
$GLOBALS['spip_pipeline']['waf_custom_rules'] .= '|waf_custom_rule__block_spipdf';
Disabling rules
The waf_handle_violation pipeline lets you ignore specific violations without bypassing the entire WAF. The IP still goes through denylist, blocklist, and ban checks. Only the individual rule trigger is skipped.
Set $flux['data'] = true and return $flux to skip the violation. The function receives the standard SPIP flux array with args (violation details) and data (initially false).
Example: allow <script> tags when editing articles in the back-office:
function waf_override__allow_script_in_articles($flux) {
$args = $flux['args'];
if (
isset($args['get']['exec']) && $args['get']['exec'] === 'article_edit'
&& $args['reason'] === 'PATTERN_MATCH'
&& (
$args['pattern'] == '/<script/i'
|| $args['pattern'] == '/<\/script>/i'
)
) {
$flux['data'] = true;
}
return $flux;
}
$GLOBALS['spip_pipeline']['waf_handle_violation'] = '|waf_override__allow_script_in_articles';
Bypassing the WAF
The waf_bypass pipeline lets external code skip all WAF checks for a request, including denylist, blocklist, ban, and rule checks. It runs right after the allowlist check. It is useful to completely disable the WAF for certain requests.
Set $flux['data'] = true and return $flux to bypass. The function receives the standard SPIP flux array with args (containing client_ip) and data (initially false).
Bypass the WAF for logged-in users (editors, admins, etc.):
function waf_bypass__logged_in($flux) {
if (!empty($GLOBALS['auteur_session']['id_auteur'])) {
$flux['data'] = true;
}
return $flux;
}
$GLOBALS['spip_pipeline']['waf_bypass'] .= '|waf_bypass__logged_in';
This checks the $GLOBALS['auteur_session']['id_auteur'] variable, which is set by SPIP after a successful login.
Note: This is different from the
waf_handle_violationpipeline, which only skips individual rule violations. If an IP is already banned or blocklisted,waf_handle_violationwon’t help, the request is blocked before rules even run. Use thiswaf_bypasswhen you need to let someone through regardless of their IP’s ban status.
Profiling
Add to config/mes_options.php:
define('_WAF_PROFILE', microtime(true));
define('_LOG_FILTRE_GRAVITE', _LOG_INFO);
The second line is required if _LOG_FILTRE_GRAVITE is not defined, in order to set the proper log leve. Otherwise, no log output is written.
Each request logs one line to tmp/log/waf.log:
WAF_PROFILE PASS total:3.42ms fn:1.18ms
WAF_PROFILE VIOLATION total:5.01ms fn:2.73ms
- total : time from the
define()to the WAF exit (includes SPIP bootstrap) - fn : time inside
waf()only - Outcomes:
PASS,ALLOWLISTED,BYPASS,NO_IP,DENYLISTED,BLOCKLISTED,HARDBLOCKED,BANNED,UNBLOCK_REDIRECT,VIOLATION
Remove both lines when done.
Debug: archive event files
By default, .jsonl event files are deleted after they are flushed to the database. To keep them for inspection, enable archiving:
define('_WAF_DEBUG_ARCHIVE_EVENTS', true);
Flushed files are renamed to .archive in the tmp/waf_events/ directory instead of being deleted. Disable this in production. Archived files accumulate and are never cleaned up automatically.
CLI stats
Stats for a given outcome (replace PASS with any outcome) for MIN, AVERAG; MAX:
For basic min, average, max stats:
grep 'WAF_PROFILE PASS ' tmp/log/waf.log | grep -oP 'fn:\K[0-9.]+' | awk '{s+=$1;n++;if(!min||$1<min)min=$1;if($1>max)max=$1} END{printf "n=%d min=%.2fms avg=%.2fms max=%.2fms\n",n,min,s/n,max}'
For Percentiles (p50/p95/p99): (This bash code is run in the the root directory of the SPIP install).
#go to root directory of SPIP install:
cd /var/www/html
It loops over the possible outcomes and lists the stats.
#Output percentiles per outcome:
for outcome in PASS ALLOWLISTED NO_IP DENYLISTED BLOCKLISTED HARDBLOCKED BANNED UNBLOCK_REDIRECT VIOLATION; do
echo ""
echo "--- $outcome ---"
grep "WAF_PROFILE $outcome " tmp/log/waf.log \
| grep -oP 'fn:\K[0-9.]+' \
| sort -n \
| awk '{a[NR]=$1} END{
printf "n=%d p50=%.2fms p95=%.2fms p99=%.2fms\n",
NR,
a[int(NR*0.50)+1],
a[int(NR*0.95)+1],
a[int(NR*0.99)+1]
}'
done
Overview of all outcome types in the log:
grep -oP 'WAF_PROFILE \K\S+' tmp/log/waf.log | sort | uniq -c | sort -rn



Discussions by date of activity
3 discussions
Hello,
I have the case of a webmaster being locked out. Is there a way for another webmaster to unlock his IP ? of course I did add its IP to the whitelist but he is still unable to login ... or where to look in the db ?
And another question: who is receiving the unlock message ? the user on its email address or the site webmaster email ?
Hello Pierrot,
Yes I’ve included instructions here in the documentation: SPIP Web Application Firewall (WAF)
The simplest is to switch to another network (if you are on your regular WiFi switch to your mobile hotspot, reconnect, and add both your IP’s to the Allowlist. Then switch back to your usual network.
To unblock via editing a file on the server:
**Find your IP:** go to https://iplocation.net (it looks like
203.0.113.42) or search for “What’s my IP”.**To allowlist an IP via code**, add this to
config/mes_options.php(create the file if needed):If mes_options.php does not exist yet in your config/ folder, create it. Don’t forget to add the “< ? php” at the start of it (without spaces between the characters).
If you want to add multiple IPs, add one per line:
Once saved, the WAF lets these IPs through.
**As a last resort**, to allow all IPs:
This allows all IPv4 and IPv6 addresses. So remove it again after you’ve logged into SPIP and added your IP to the whitelist in the configuration form.
Regarding your question about the unlock message: It is shown to the user/bot doing the violation. They can unblock themselves for x times after which they are hardblocked (configurable in config form).
And: Don’t hesitate to post any follow up questions in the main forum, we are more active over there: https://discuter.spip.net/
Kind regards,
Urs
Reply to this message
Hi,
I’ve installed this plugin on several sites and the filtering seems to be efficient for BLOCKLISTED_IP / CMS_PROBE > BANNED IP
But a bit too efficient for administrators : on one site I had do de-activate it quickly since the admin could no more access the site to publish (with “a server error”). The user told me he used his usual work PC.
I fear with VPN / moving locations, etc, it may be difficult to avoid hick up.
Thanks
dd
Hello dd,
Very true, I had the same issue on one of my sites: Authors couldn’t post certain articles, that contained certain texts like “A great System (try it)” or “”Il faut sélectionner toutes les données de la table“. The WAF triggers on anything similar to system(”reboot") and SELECT and then prevents that.
The solution is to add thier IP to the Allowlist in the configuration form. Or to disable the WAF entirely rules for logged-in users:
Add to config/mes_options.php :
I think this rule could be a great feature to be added as a checkbox to the config form: Disable WAF for Editors and Admins. I could add that if it’s interesting.
What do you think?
About VPN: I haven’t had an issue before.
One solution there would be to have a secure form where it is easier to whitelist an IP on-the-go. And with the above change, if you are still logged-in, changing to a blocked IP will still let you through (we let logged-in users through).
And sorry for the late reply. We are more active on the forum here: https://discuter.spip.net/
Reply to this message
Bonjour,
J’utilise le plugin pour tous mes sites. Depuis l’espace occupé dans hébergement a doublé (de 83 à 150 Go) et je n’arrête pas de racheter de l’espace. Y-a-t-il un moyen de limiter ce phénomène.
Merci
Claude
Reply to this message
Add a comment
Avant de faire part d’un problème sur un plugin X, merci de lire ce qui suit :
Merci d’avance pour les personnes qui vous aideront !
Par ailleurs, n’oubliez pas que les contributeurs et contributrices ont une vie en dehors de SPIP.
Follow the comments:
|
