A configurable Rails gem to trap bots scanning your site for WordPress/PHP vulnerabilities.
It's a peaceful morning. You're sipping coffee, checking your Rails app logs, feeling like a mildly responsible developer. And then you see these again, for the 69420th time this month:
ActionController::RoutingError (No route matches [GET] "/wp-login.php")
ActionController::RoutingError (No route matches [GET] "/xmlrpc.php")
ActionController::RoutingError (No route matches [GET] "/wp-admin/admin-ajax.php")
ActionController::RoutingError (No route matches [GET] "/alfa.php")
ActionController::RoutingError (No route matches [GET] "/adminfuns.php")
ActionController::RoutingError (No route matches [GET] "/wp-content/plugins/pwnd/as.php")
IT'S. NOT. WORDPRESS.
It's a Rails app. It has always been a Rails app. There is no wp-login.php, and there never will be. And yet, every single day, some dumb*** bot from the depths of the internet decides that maybe this time there's a juicy WordPress installation hiding behind that Ruby code.
So instead of just ignoring these requests like a normal, well-adjusted person, I decided to waste a perfectly good morning writing this gem. Because if bots want to find WordPress so badly, let's give them the WordPress experience of their dreams:
- A fake login page that looks real enough (hopefully) to fool their scripts
- A tarpit that makes them wait for as long as your server allows
- An endless stream of garbage data to (hopefully) make their memory go boom
- Automatic IP banning because revenge
Is it over-engineered? Depends, but the fact that I spent more than 2 hours on this might be an indication that it is. Is it petty? 100%. Does it spark joy? Yeah.
This is v0.0.1. I built this in a fit of rage. It's not optimized, nor tested. It probably has bugs. But hey, it works on my machine™ 👨💻.
If you want to laugh at my code, improve it, or add more ways to mess with bots, PRs are welcome. Let's be chaotic together 😄.
- 🎣 Fake wp-login: Realistic WordPress login page that captures credential attempts
- ⏳ Progressive tarpit: The more they return, the longer they wait (2s → 30s)
- 🌊 Endless stream: Sends GBs of garbage data to crash bots
- 🔨 Automatic IP banning: IPs are banned after being trapped
- 🔍 Fingerprinting: Collects bot info (canvas, WebGL, timezone, etc.)
- 📊 Detailed logging: IP, User-Agent, headers, login attempts...
- 🚨 Sentry integration: Events sent automatically
- 🧪 Memory Mode: Persist trap records in DB with a beautiful dashboard
- ⚙️ Fully configurable: Every behavior can be customized
Add the gem to your Gemfile:
gem "pest_control", github: "ApaeP/pest_control"Then run:
bundle install
rails generate pest_control:installThis will:
- Create
config/initializers/pest_control.rb - Add
mount PestControl::Engine => "/"to your routes
The initializer contains all available options:
PestControl.configure do |config|
# ============================================================================
# BANNING
# ============================================================================
config.ban_duration = 24.hours # How long IPs stay banned
config.banning_enabled = true # Set false to disable (for testing)
# ============================================================================
# ENDLESS STREAM
# ============================================================================
config.endless_stream_enabled = true # Enable/disable feature
config.endless_stream_threshold = 5 # Visits before activating
config.endless_stream_random_chance = 15 # Random chance (0-100%)
config.max_stream_chunks = 50_000 # Max ~50MB per session
config.stream_chunk_size = 1024 # Bytes per chunk
config.stream_chunk_delay_min = 0.1 # Min delay between chunks
config.stream_chunk_delay_max = 0.5 # Max delay between chunks
# ============================================================================
# TARPIT
# ============================================================================
config.tarpit_enabled = true # Enable/disable feature
config.tarpit_base_delay = 2 # Base delay (seconds)
config.tarpit_max_delay = 30 # Maximum delay (seconds)
config.tarpit_increment_per_visit = 0.5 # Added per visit (seconds)
config.banned_ip_tarpit_min = 5 # Min delay for banned IPs
config.banned_ip_tarpit_max = 10 # Max delay for banned IPs
# ============================================================================
# VISIT TRACKING
# ============================================================================
config.visit_count_ttl = 1.hour # How long to remember visits
config.cache_key_prefix = "pest_control" # Cache key prefix
# ============================================================================
# LOGGING
# ============================================================================
config.log_level = :warn # :debug, :info, :warn, :error
config.sentry_enabled = true # Send events to Sentry
config.logger = Rails.logger # Custom logger
config.cache = Rails.cache # Custom cache
# ============================================================================
# CALLBACKS
# ============================================================================
# Called when a bot is trapped
config.on_bot_trapped = ->(data) {
BotAttempt.create!(data) # Save to database
SlackNotifier.ping("Bot: #{data[:ip]}")
}
# Called when an IP is banned
config.on_ip_banned = ->(ip, reason) {
Firewall.block_ip(ip) # Add to external blocklist
}
# Called when endless stream starts
config.on_endless_stream_start = ->(ip, visit_count) {
puts "💀 Destroying #{ip}..."
}
# Called when bot crashes during endless stream
config.on_bot_crashed = ->(ip, chunks_sent, error) {
puts "🎉 #{ip} crashed after #{chunks_sent}KB!"
}
# ============================================================================
# CREDENTIAL CAPTURE
# ============================================================================
config.capture_credentials = true # Log captured credentials
config.fingerprinting_enabled = true # Enable JS fingerprinting
# ============================================================================
# FAKE PAGES
# ============================================================================
config.fake_site_name = "WordPress" # Name shown on fake login
config.custom_blocked_html = nil # Custom 403 page HTML
config.custom_login_html = nil # Custom login page HTML
# ============================================================================
# PATTERNS
# ============================================================================
config.blocked_patterns << /\/custom/i # Add custom patterns
config.suspicious_user_agents << /bot/i # Add custom user agents
endBot requests /wp-login.php
│
▼
┌─────────────────┐
│ Rack::Attack │
│ Is IP banned? │
└────────┬────────┘
│
┌───────┴───────┐
│ │
YES NO
│ │
▼ ▼
┌─────────┐ ┌──────────────────┐
│ 403 │ │ HoneypotController│
│ Blocked │ │ │
│ +tarpit │ │ 1. Log attempt │
└─────────┘ │ 2. Count visits │
│ 3. Tarpit OR │
│ Endless stream│
│ 4. Serve fake │
│ login page │
│ 5. Ban IP │
└──────────────────┘
delay = base_delay + (visit_count × increment_per_visit)
delay = min(delay, max_delay)
Example with defaults:
| Visit | Delay |
|---|---|
| 1 | 2.5s |
| 2 | 3.0s |
| 5 | 4.5s |
| 10 | 7.0s |
| 56+ | 30s (max) |
After threshold visits (or random chance), the server:
- Starts HTTP streaming response
- Sends ~1KB chunks every 100-500ms
- Bot accumulates data in memory
- Eventually crashes or times out
Default patterns catch:
*.php(any PHP file)/wp-login.php,/wp-admin/*/wp-content/*,/wp-includes/*/xmlrpc.php/phpmyadmin,/phpMyAdmin/administrator,/admin.php/.env,/.git/*/backup,/bak,/old,/tmp- Known webshells:
/c99,/r57,/alfa,/wso
If your site was migrated from PHP, ASP, or another platform, you might have old backlinks (from Wikipedia, blogs, etc.) pointing to legacy URLs like /contact.php. By default, PestControl would ban these legitimate visitors.
Enable Legacy Redirects to handle them gracefully:
PestControl.configure do |config|
config.legacy_redirects_enabled = true
config.legacy_extensions = %w[php xml asp]
# Custom mappings (optional, takes priority)
config.legacy_mappings = {
"/periode3.php" => "/periode-3",
"/feed.xml" => "/rss"
}
# Auto-strip extension: /contact.php → /contact (default: true)
config.legacy_strip_extension = true
# Allow 5 visits on unmapped URLs before banning (default: 5)
config.legacy_tolerance = 5
# Log redirects as trap records (default: false)
config.legacy_log_redirects = false
endGET /contact.php arrives
│
▼
┌──────────────────────────┐
│ Extension in legacy list?│
└───────────┬──────────────┘
│
┌───────┴───────┐
NO YES
│ │
▼ ▼
Normal ┌─────────────────┐
PestControl │ Custom mapping? │
(ban) └────────┬────────┘
│
┌───────┴───────┐
YES NO
│ │
▼ ▼
301 to Strip extension
mapping 301 → /contact
| Request | Result |
|---|---|
GET /periode3.php (mapped) |
301 → /periode-3 |
GET /contact.php (strip enabled) |
301 → /contact |
GET /unknown.php (visits 1-5) |
404 (no ban) |
GET /unknown.php (visit 6+) |
Ban |
POST /contact.php |
Ban (only GET is tolerated) |
GET /wp-login.php (not in legacy_extensions) |
Normal PestControl behavior |
Want to analyze your pest collection? Enable Memory Mode to persist trap records and access a beautiful dashboard.
rails generate pest_control:memory
rails db:migratePestControl.configure do |config|
# Enable database persistence
config.memory_enabled = true
# Dashboard authentication - Option 1: HTTP Basic Auth
config.dashboard_username = "admin"
config.dashboard_password = ENV["PEST_CONTROL_DASHBOARD_PASSWORD"]
# Dashboard authentication - Option 2: Custom lambda (takes precedence)
# Example: only allow admin users
config.dashboard_auth = ->(controller) { controller.current_user&.admin? }
endAccess the dashboard at /pest-control/lab to see:
- 📊 Stats: Total specimens, unique IPs, credentials captured
- 🎯 Trap Types Distribution: Which traps are catching the most bots
- 🏆 Top Offenders: IPs with the most attempts (ban/unban from here)
- 📋 Recent Specimens: Detailed log of all trap records
- 🔒 Banned IPs: View and manage bans
The PestControl::TrapRecord model stores:
- IP, path, method, user agent, referer
- Headers and request params
- Captured credentials
- Browser fingerprint data
- Visit count and endless stream flag
# Query examples
PestControl::TrapRecord.today.count
PestControl::TrapRecord.by_ip("1.2.3.4")
PestControl::TrapRecord.with_credentials
PestControl::TrapRecord.stats
# Clean up old records (run periodically via cron/Sidekiq)
PestControl::TrapRecord.cleanup_expired!By default, trap records are kept for 3 years. You can configure this:
PestControl.configure do |config|
config.trap_records_retention = 3.years # Default
# config.trap_records_retention = 1.year
# config.trap_records_retention = nil # Keep forever
endTo clean up expired records, run periodically:
# In a Rake task, cron job, or Sidekiq scheduled job
PestControl::TrapRecord.cleanup_expired!If you're using Memory Mode, you should inform users in your privacy policy. Here's an example paragraph you can adapt:
Protection Against Automated Attacks
This website uses a security system to protect against malicious bots (PestControl). When a suspicious request is detected (attempts to access non-existent paths such as
/wp-login.php,/xmlrpc.php, or any.phpfile), the following data may be collected and retained for a maximum of 3 years:
- IP address
- Browser User-Agent
- Requested URL and HTTP headers
- Submitted credentials (in case of fraudulent login attempts)
This data is collected based on our legitimate interest (Article 6.1.f of GDPR) to protect our infrastructure against unauthorized access and intrusion attempts.
This information is not shared with third parties and is only used for security and threat analysis purposes. No data is collected by PestControl during normal browsing of the website.
# View all banned IPs
PestControl.banned_ips
# => {"1.2.3.4" => {banned_at: ..., reason: ..., expires_at: ...}}
# Check if an IP is banned
PestControl.banned?("1.2.3.4")
# => true
# Unban an IP
PestControl.unban_ip!("1.2.3.4")
# Unban all IPs
PestControl.clear_all_bans!
# Get visit count for an IP
PestControl.get_visit_count("1.2.3.4")
# => 3
# Reset visit count
PestControl.reset_visit_count("1.2.3.4")[PEST_CONTROL] 🍯 BOT TRAPPED: {"type":"FAKE_LOGIN_VIEW","ip":"4.217.237.222",...}
[PEST_CONTROL] ⏳ Tarpit: 4.217.237.222 - visit #1 - 2.5s delay
[PEST_CONTROL] 🔨 IP BANNED: 4.217.237.222 - Reason: honeypot:FAKE_LOGIN_VIEW
[PEST_CONTROL] 🍯 BOT TRAPPED: {"type":"CREDENTIAL_CAPTURE","credentials":{...},...}
[PEST_CONTROL] 🌊 ENDLESS STREAM ACTIVATED: 4.217.237.222 (visit #5)
[PEST_CONTROL] 💀 BOT CRASHED: 4.217.237.222 after 847 chunks (~847KB) - IOError
[PEST_CONTROL] 🚫 BLOCKED (banned IP): 4.217.237.222 -> /wp-login.php
To test without banning real IPs:
# config/initializers/pest_control.rb
PestControl.configure do |config|
config.banning_enabled = false # Disable banning
config.log_level = :debug # Verbose logging
end- Rails 7.0+
- Ruby 3.0+
- rack-attack gem
Found a bug? Want to add a feature? Have an even more diabolical way to mess with bots? I'm here for it.
This gem was born from frustration and built with spite. If that energy resonates with you, let's collaborate:
- Fork it
- Create your feature branch (
git checkout -b feature/even-more-chaos) - Commit your changes (
git commit -am 'Add rickroll redirect for repeat offenders') - Push to the branch (
git push origin feature/even-more-chaos) - Open a Pull Request
No contribution is too unhinged. Well, maybe some are. But let's find out together.
MIT