SPIP Web Application Firewall (WAF)

Web Application Firewall for SPIP. Blocks malicious requests and bans repeat offenders.

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:

  1. Rules check every request for suspicious patterns (SQL injection, path traversal, CMS probes, etc.)
  2. 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).
  3. After several violations, the IP gets banned.
  4. Banned visitors see an unblock form (useful for false positives). Via the button they can unblock and return to the homepage.
  5. If they keep triggering rules after unblocking, they get banned again.
  6. 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

Top section of the dashboard

Screenshot: Detailed section of the dashboard

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

Configuration form, IP header

Screenshot: Configuration, Lists

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_violation pipeline, which only skips individual rule violations. If an IP is already banned or blocklisted, waf_handle_violation won’t help, the request is blocked before rules even run. Use this waf_bypass when 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

Sample screenshots

The config page, where we must set the request header that lists the correct IP
The stats page, where we can see the blocked requests
The stats page, where we can see the blocked requests

Discussion

3 discussions

  • 1

    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):

      define('_WAF_ALLOWLIST', '203.0.113.42');
      

      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:

      define('_WAF_ALLOWLIST',
      '203.0.113.42
      198.51.100.7
      198.51.100.8'
      );
      

      Once saved, the WAF lets these IPs through.

      **As a last resort**, to allow all IPs:

      define('_WAF_ALLOWLIST',
      '0.0.0.0/0
      ::/0'
      );
      

      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

  • 1

    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 :

      /**
      * Bypass WAF for authenticated SPIP authors in back-office
      */
      function waf_bypass_logged_in_editors($flux)

      include_spip(’inc/session’);

      $statut = session_get(’statut’);

      // Bypass WAF for logged-in authors (1comite or higher)
      if (in_array($statut, [’0minirezo’, ’1comite’], true))
      return true;

      return $flux;

      // Register the bypass pipeline
      $GLOBALS[’spip_pipeline’][’waf_bypass’] .= ’|waf_bypass_logged_in_editors’;

      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 :

  • Désactiver tous les plugins que vous ne voulez pas tester afin de vous assurer que le bug vient bien du plugin X. Cela vous évitera d’écrire sur le forum d’une contribution qui n’est finalement pas en cause.
  • Cherchez et notez les numéros de version de tout ce qui est en place au moment du test :
    • version de SPIP, en bas de la partie privée
    • version du plugin testé et des éventuels plugins nécessités
    • version de PHP (exec=info en partie privée)
    • version de MySQL / SQLite
  • Si votre problème concerne la partie publique de votre site, donnez une URL où le bug est visible, pour que les gens puissent voir par eux-mêmes.
  • En cas de page blanche, merci d’activer l’affichage des erreurs, et d’indiquer ensuite l’erreur qui apparaît.

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.

Who are you?
[Log in]

To show your avatar with your message, register it first on gravatar.com (free et painless) and don’t forget to indicate your Email addresse here.

Enter your comment here

This form accepts SPIP shortcuts {{bold}} {italic} -*list [text->url] <quote> <code> and HTML code <q> <del> <ins>. To create paragraphs, just leave empty lines.

Add a document

Follow the comments: RSS 2.0 | Atom