bdsa / wafy
A Laravel package to automatically ban IP addresses and detect malicious requests.
Requires
- php: >=7.4
- illuminate/support: ^8.0|^9.0|^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^6.0|^7.0|^8.0|^9.0
- phpunit/phpunit: ^9.0|^10.0
This package is not auto-updated.
Last update: 2026-08-14 13:03:12 UTC
README
Wafy is a robust Laravel package developed by Bdsa designed to automatically ban IP addresses and detect malicious requests, including SQL Injection, XSS, and more.
Features
- 🛡️ IP Banning: Automatically block IPs engaging in suspicious activity.
- 🕵️ Malicious Request Detection: SQLi, XSS, LFI, RCE, SSTI, JNDI/Log4Shell, SSRF, NoSQL, XXE, deserialization…
- ⚖️ Weighted scoring engine: block on accumulated risk, not a single trigger — far fewer false positives.
- 🐢 Velocity & honeypot detection: catch scanners that never match a pattern.
- 🌍 GeoIP & bot filtering: allow/deny by country/ASN; flag known scanner user-agents.
- 📈 Exponential-backoff bans: repeat offenders escalate automatically.
- ⏱️ Temporary & Permanent Bans: Configurable durations, IPv6
/64aware. - 🔔 Notifications: Mail, Slack, Discord, Teams (+
wafy:test-notification). - 🧩 Rule management: bundled OWASP-CRS-inspired pack,
wafy:rules:import, per-rule enable/disable & severity. - 📊 Observability: structured JSON/SIEM logging +
wafy:statstelemetry dashboard. - 🖼️ Response variety: JSON, HTML challenge page, or content-negotiated
auto, with an optional tarpit. - ⚙️ Customizable: your own scored rules, configurable/localizable messages & status codes.
- 🖥️ Artisan Commands: manage bans, prune, rules, stats, notifications via CLI.
Installation
1. Require with Composer
Add the package to your project:
composer require bdsa/wafy
2. Publish Configuration
Publish the configuration file and migrations:
php artisan vendor:publish --provider="Bdsa\Wafy\WafyServiceProvider"
3. Run Migrations
Create the banned_ips table:
php artisan migrate
Usage
Middleware
Wafy provides two key middlewares : BlockBannedIp & DetectMaliciousRequests.
Protecting Routes
Apply the middleware to your routes or groups:
use Bdsa\Wafy\Middleware\BlockBannedIp; use Bdsa\Wafy\Middleware\DetectMaliciousRequests; Route::group(['middleware' => ['block.banned.ip', 'detect.malicious.requests']], function () { Route::get('/', function () { return view('welcome'); }); // Your protected routes });
Artisan Commands
Manage banned IPs directly from the terminal:
-
Ban an IP manually:
php artisan wafy:ban {ip_address} [--reason="Your reason"] -
Unban an IP:
php artisan wafy:unban {ip_address} -
List all banned IPs:
php artisan wafy:list
-
Enable/Disable WAF:
php artisan wafy:mode {enable|disable} -
Set Action Mode (Block or Log-Only):
php artisan wafy:action {block|log} -
Send a test notification (verify your mail/Slack/Discord/Teams wiring):
php artisan wafy:test-notification # all configured channels php artisan wafy:test-notification --channel=discordReports success/failure per channel and skips channels with no destination configured.
-
Prune expired / old bans (also enforces GDPR retention + stats retention):
php artisan wafy:prune # delete expired temporary bans php artisan wafy:prune --days=90 # also delete bans older than 90 days
Schedule it in
app/Console/Kernel.php:$schedule->command('wafy:prune')->daily();Setretention_daysin config to apply a default retention without--days. -
Manage detection rules (severity, runtime enable/disable):
php artisan wafy:rule list php artisan wafy:rule disable sqli.union_select # runtime, cache-backed php artisan wafy:rule enable sqli.union_select
Enable the bundled OWASP-CRS-inspired pack with
rule_packs => ['owasp-crs']. -
Import external rules into the active rule set:
php artisan wafy:rules:import path/to/rules.php # .php | .json | .txt (one regex/line) -
Stats dashboard (requires
stats.enabled):php artisan wafy:stats --days=7 --top=10 [--json]
ℹ️ Note —
wafy:modeandwafy:actionare temporary runtime overrides. These two commands store their state in the cache, so they are meant for momentary situations (testing, incident response). Any cache flush (php artisan cache:clear,config:cache, a deploy, a Redis restart…) resets them, and Wafy falls back to the values inconfig/wafy.php. To change the behaviour permanently, editconfig/wafy.php(or the matchingWAFY_*environment variables) — that is the source of truth.
⚠️ Running behind a reverse proxy / CDN (read this first)
Wafy identifies clients by IP ($request->ip()) and can ban them. If your
application runs behind a reverse proxy, load balancer or CDN (Nginx, Traefik,
Cloudflare, AWS ALB…), you must configure Laravel's TrustProxies
middleware so that $request->ip() returns the real client IP.
- If you don't configure trusted proxies, every request appears to come from the proxy. The first malicious request then bans your own proxy, cutting off all traffic.
- If you trust proxies with a blanket
*, theX-Forwarded-Forheader becomes attacker-controlled: an attacker can spoof a clean IP to bypass bans, or forge a victim's IP to get it banned. Only trust the specific proxy ranges you use.
Also add your proxy / CDN ranges and any critical infrastructure to
wafy.allowed_ips (CIDR ranges are supported) so they can never be banned.
✅ Production checklist
Wafy ships safe by default — every high-risk feature is off, ban_threshold
is 3, private/proxy IPs are never banned, and the ban store fails open. Three
environment-level settings are the operator's responsibility and unlock its full
value:
- Trust your proxies. Behind Nginx/Cloudflare/ALB, configure Laravel's
TrustProxiesso$request->ip()is the real client IP (see the section above). Add proxy/CDN ranges towafy.allowed_ips. - Use a shared, persistent cache (
redis,database, orfile). Strike counting, runtime toggles (wafy:mode/action/rule), velocity and stats dedup all rely on it. With thearray/nulldriver Wafy logs a warning and these silently no-op. - Schedule pruning for expired bans + GDPR/stats retention:
// app/Console/Kernel.php $schedule->command('wafy:prune')->daily();
Optional hardening to enable after tuning to your traffic (all off by default):
rate_limit (velocity/scanner detection), geoip (country/ASN filtering),
rule_packs => ['owasp-crs'], flag_empty_user_agent, multipart.scan_file_contents,
stats.enabled (telemetry for wafy:stats), and response.tarpit_seconds
(⚠ bounded — a tarpit sleep pins a PHP-FPM worker).
Verify your notification wiring with php artisan wafy:test-notification, and
review active rules with php artisan wafy:rule list.
Configuration
The configuration file is located at config/wafy.php. Besides the detection
patterns, the following options control how bans are applied:
| Key | Default | Description |
|---|---|---|
ban_threshold |
3 |
Number of detections from one IP (within strike_window minutes) before it is banned. Kept above 1 so a single false positive doesn't lock out a legitimate (shared/NAT/mobile) IP. The offending request is always blocked regardless. |
strike_window |
60 |
Minutes over which strikes accumulate. |
ban_duration |
1440 |
Automatic ban lifetime in minutes (24h). Set to null for permanent bans. Manual wafy:ban bans are always permanent. |
ipv6_ban_prefix |
64 |
Prefix length used to ban/count IPv6 clients. Default 64 bans the whole /64 network (what an ISP typically hands a single customer), so an attacker can't dodge a ban by rotating within their allocation. Set 128 to ban the exact address. No effect on IPv4. |
ban_lookup_cache_ttl |
0 |
Seconds to cache a negative ban lookup ("this IP has no ban"), sparing a DB query per middleware for legitimate repeat traffic. 0 = disabled (default). Only clean results are cached and they are invalidated on ban creation; use a small value (10–30s) with a shared cache. |
score_threshold |
4 |
Minimum accumulated rule score before a request is blocked (403). See Detection scoring below. |
ban_score_threshold |
= score_threshold |
Score at/above which a blocked request becomes eligible for a persistent ban (the actual escalation is still gated by ban_threshold strikes). Raise it to block but not ban medium threats; combine low thresholds with ban_threshold=1 for strict ban on first match. |
ban_private_ips |
false |
When false (default), Wafy refuses to ban private/reserved/loopback IPs — a strong sign that TrustProxies is misconfigured and $request->ip() is the proxy, so banning it would take down all traffic. Set to true only if your clients legitimately have private IPs (internal network, no proxy). |
max_scan_length |
16384 |
Max characters inspected per field — caps regex CPU cost (ReDoS protection). |
fail_open |
true |
If the ban database is unreachable, let requests through (true) instead of returning 503 for everyone (false). |
scan_headers |
['User-Agent', 'Referer', 'Cookie', 'X-Forwarded-For', 'X-Forwarded-Host', 'Origin', 'X-Api-Version'] |
Request headers inspected for patterns. Only header names appear in logs, never their values. |
sensitive_keys |
passwords, tokens, card fields… | Key substrings whose values are redacted before a request is stored or notified — password also covers user_password, card covers billingCardNumber (body and query-string). |
max_stored_value_length |
2048 |
Long input values are truncated to this many characters in a stored ban record (prevents DB bloat/overflow). |
retention_days |
null |
Max age (days) of a ban record before wafy:prune deletes it (GDPR). null = keep until manually removed. |
allowed_ips |
[] |
IPs / CIDR ranges (IPv4 & IPv6) that bypass Wafy entirely. |
Default protection covers:
- SQL Injection (SQLi):
UNION SELECT, contextualSELECT … FROM/DML, boolean tautologies / auth bypass (' OR '1'='1,admin'--), DDL (DROP/ALTER/TRUNCATE TABLE), stacked queries,INTO OUTFILE, error-based (extractvalue/updatexml), time-based (sleep/benchmark), hex literals in SQL context. - Local File Inclusion (LFI): Directory traversal (
../), system files (/etc/passwd,/etc/shadow), PHP wrappers (php://,phar://). - Cross-Site Scripting (XSS): Script tags, all inline
on*event handlers (whitespace-tolerant), dangerous tags,javascript:, executable data URIs. - Remote Code Execution (RCE): Shell commands & separators (
;id,|whoami), command substitution ($(...)), PHP execution functions. - Template injection (SSTI): Twig/Jinja/Blade gadgets (
{{7*7}},_self), expression-language / Freemarker (${T(java.lang.Runtime)…}). - Log4Shell / JNDI:
${jndi:ldap://…}and character-substitution obfuscation (${${lower:j}ndi…}). - SSRF: cloud metadata endpoints (
169.254.169.254,metadata.google.internal),gopher:///dict://. - NoSQL injection: Mongo operators as JSON/array keys (
{"$ne":…},param[$ne]=). - XXE: external entities /
<!DOCTYPE … SYSTEM>. - Deserialization: PHP (
O:8:"…":), Java (rO0AB…), Python pickle opcodes. - Also: LDAP filter injection, prototype pollution (
__proto__), CRLF header splitting, known scanner user-agents (sqlmap, nikto, nuclei…), and honeypot trap paths.
Uploaded file names are scanned too (multipart); scanning file contents is
opt-in (multipart.scan_file_contents, off by default to avoid FPs on legit
code/SQL uploads). A rawurldecode variant is also checked so +-bearing
payloads aren't lost.
Several layers are opt-in (tune to your traffic before enabling): velocity
rate_limit, geoip country/ASN filtering, multipart.scan_file_contents,
flag_empty_user_agent, and the bot.generic_http_client UA rule. Repeat-offender
backoff and honeypot paths are on by default. Rules can be field-scoped
(e.g. the scanner-UA rule only inspects User-Agent). See config/wafy.php.
Detection scoring
Instead of banning on the first matching pattern, Wafy assigns each rule a
score and only acts once the accumulated score of all rules that match a
request reaches score_threshold (default 4). Each rule counts once, no matter
how many fields it matches. This is the core defence against false positives: a
lone ambiguous signal (e.g. the word select … from in a support message) never
blocks legitimate traffic, while a strong rule (score ≥ threshold) or several
corroborating weak signals do.
Indicative scale: 5 = unambiguous attack (blocks alone) · 4 = strong · 3 =
medium (needs one more signal) · 2 = weak · 1 = hint.
Rules live in the rules array, each ['id' => …, 'score' => …, 'pattern' => …].
Disable a rule by removing it, tune sensitivity via its score or the global
score_threshold. Ban reasons/logs reference the stable rule id and the
total score (e.g. WAF score 8/4 in RequestBody (rules: xss.script_tag, …)).
Blocking vs banning. score_threshold decides when a request is blocked
(403); ban_score_threshold (default = score_threshold) decides when a blocked
request may escalate to a persistent IP ban — the escalation itself still needs
ban_threshold strikes. This lets you dial the whole spectrum:
| Goal | Settings |
|---|---|
| Strict — ban on the first match | score_threshold=1, ban_score_threshold=1, ban_threshold=1 |
| Balanced (default) | score_threshold=4, ban_score_threshold=4, ban_threshold=3 |
| Block medium threats but only ban strong/repeat ones | score_threshold=4, ban_score_threshold=8 |
Velocity / rate detection
Signature rules miss scanners that walk many URLs that don't match any pattern
(/.git/config, /backup.zip, /admin.php, …) — usually a burst of 404s. The
optional velocity layer counts requests and 404s per IP over a sliding window and
treats a breach like any other detection (block, then ban per your policy):
'rate_limit' => [ 'enabled' => env('WAFY_RATE_LIMIT_ENABLED', false), // opt-in 'window' => 60, // seconds 'max_requests' => 300, // total requests / IP / window (0 = off) 'max_404' => 40, // 404s / IP / window (0 = off) ],
It's off by default — tune the limits to your traffic first (a busy SPA or a
shared NAT IP can be high-volume). It counts per ban identity (so IPv6 is
aggregated per /64), is skipped for private/reserved IPs you don't ban (so a
misconfigured proxy never self-DoSes), and needs a shared, persistent cache.
Backward compatibility: a config still using the old flat
patternsarray (list of regex strings) keeps working — each pattern is scored at the threshold, preserving the pre-scoring "block on first match" behaviour until you migrate torules.
Example config/wafy.php:
return [ 'enabled' => env('WAFY_ENABLED', true), 'score_threshold' => env('WAFY_SCORE_THRESHOLD', 4), 'rules' => [ ['id' => 'sqli.union_select', 'score' => 5, 'pattern' => '/(union(\s+all)?\s+select)/i'], ['id' => 'sqli.tautology', 'score' => 4, 'pattern' => '/\b(or|and)\s+([\'"`]?)(\w+)\2\s*(=|<>|!=|<|>|\blike\b)\s*([\'"`]?)\3\b/i'], ['id' => 'xss.script_tag', 'score' => 5, 'pattern' => '/(<script.*?>.*?<\/script>)/is'], // Add your own rules here… ], 'allowed_ips' => [ '127.0.0.1', // Localhost '192.168.1.1', // Office IP ], 'notifications' => [ 'enabled' => env('WAFY_NOTIFICATIONS_ENABLED', false), 'channels' => ['mail'], // any of: 'mail', 'slack', 'discord', 'teams' 'email' => env('WAFY_NOTIFICATION_EMAIL', 'admin@example.com'), 'slack_webhook' => env('WAFY_SLACK_WEBHOOK', ''), 'discord_webhook' => env('WAFY_DISCORD_WEBHOOK', ''), 'teams_webhook' => env('WAFY_TEAMS_WEBHOOK', ''), ], ];
Notification channels
Wafy ships four channels, selected via notifications.channels:
| Channel | Destination config | Notes |
|---|---|---|
mail |
notifications.email |
Uses your app's mailer. |
slack |
notifications.slack_webhook |
Requires laravel/slack-notification-channel. |
discord |
notifications.discord_webhook |
Discord Incoming Webhook URL; posts a rich embed. No extra package. |
teams |
notifications.teams_webhook |
Microsoft Teams Incoming Webhook URL; posts a MessageCard. No extra package. |
Discord and Teams POST directly to their webhook via Laravel's HTTP client, so no
third-party notification package is needed. Notifications are queued
(ShouldQueue) — a slow webhook never blocks the request. Verify your setup with
php artisan wafy:test-notification.
Testing
To run the package tests:
vendor/bin/phpunit
License
This project is licensed under the MIT License.