bdsa/wafy

A Laravel package to automatically ban IP addresses and detect malicious requests.

Maintainers

Package info

github.com/tomakakwark/wafy

pkg:composer/bdsa/wafy

Transparency log

Statistics

Installs: 53

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

2.0.0 2026-07-17 11:55 UTC

README

License PHP Laravel

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 /64 aware.
  • 🔔 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:stats telemetry 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=discord

    Reports 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(); Set retention_days in 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:mode and wafy:action are 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 in config/wafy.php. To change the behaviour permanently, edit config/wafy.php (or the matching WAFY_* 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 *, the X-Forwarded-For header 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:

  1. Trust your proxies. Behind Nginx/Cloudflare/ALB, configure Laravel's TrustProxies so $request->ip() is the real client IP (see the section above). Add proxy/CDN ranges to wafy.allowed_ips.
  2. Use a shared, persistent cache (redis, database, or file). Strike counting, runtime toggles (wafy:mode/action/rule), velocity and stats dedup all rely on it. With the array/null driver Wafy logs a warning and these silently no-op.
  3. 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, contextual SELECT … 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 patterns array (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 to rules.

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.