Files
growstuff/config/initializers/rack_attack.rb
T
Brenda WallaceandClaude Opus 5 bd545b5ce6 Throttle /activities and /api/v1/activities per IP
/api/v1/activities is the single busiest route in production: 216 of 1101
requests in a 14 minute log window, 19.6% of all traffic and 21.7% of
non-asset traffic. It was the only high-volume route with no rate limit,
since the throttle matched plantings, harvests and members only.

Collapses the two alternations into one regex with an optional /api/v1
prefix, so the HTML and API routes stay in step instead of being listed
twice. Verified equivalent to the old pair across both route families
plus their edge cases: /plantingsfoo, /memberships, /foo/plantings and
/api/v2/activities are still unmatched.

Note this does not by itself stop the client currently polling that
endpoint. It makes 189 requests over 21 minutes, about 9 per minute,
which is under the 15 per minute limit, and Rack::Attack counters live
in per-worker memory so at WEB_CONCURRENCY=2 each worker sees only half
of them. This closes the hole rather than fixing today's load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 21:51:52 +12:00

70 lines
2.7 KiB
Ruby

# frozen_string_literal: true
class Rack::Attack
### Cache Config ###
# Count requests in this process's memory, not in Rails.cache (memcached).
# When memcached times out, the counters silently stop working and
# crawlers are never throttled or banned.
# Each Puma worker keeps its own counters, so the effective limits are
# per worker.
cache.store = ActiveSupport::Cache::MemoryStore.new(size: 8.megabytes)
### Throttle Config ###
if Rails.env.production?
# Throttle requests for members' data to 15 per minute per IP, on both the
# HTML routes and their /api/v1 equivalents.
restricted_routes = %r{^(/api/v1)?/(activities|plantings|harvests|members)(/|$)}
throttle('req/ip/restricted_routes', limit: 15, period: 1.minute) do |req|
req.ip if req.path.match?(restricted_routes)
end
### Fail2Ban Config ###
# Block IPs that make too many requests to suspicious paths
# After 5 "bad" requests in 10 minutes, block the IP for 1 hour
blocklist('fail2ban/pentesters') do |req|
Fail2Ban.filter("pentesters-#{req.ip}", maxretry: 5, findtime: 10.minutes, bantime: 1.hour) do
# The count for the IP is incremented if the return value is truthy.
req.path.include?('wp-admin') ||
req.path.include?('wp-login') ||
req.path.include?('cgi-bin') ||
req.path.end_with?('.php', '.asp', '.aspx', '.jsp', '.exe', '.env', '.git')
end
end
end
# Abusive services
blocklist('block Semrush crawler') do |request|
request.user_agent.to_s.downcase.include?('semrush')
end
# Block IPs listed in the BLOCKED_IPS environment variable (comma separated).
# This lets us stop a crawler that is overloading the site without
# committing its IP address to the repository.
blocklist('block configured IPs') do |request|
ENV.fetch('BLOCKED_IPS', '').split(',').map(&:strip).include?(request.ip)
end
# Honeypot: block IPs that request disallowed route /dont-crawl-me for 7 days (1 week)
blocklist('fail2ban/honeypot') do |req|
Fail2Ban.filter("honeypot-#{req.ip}", maxretry: 1, findtime: 1.day, bantime: 7.days) do
req.path == '/dont-crawl-me' || req.path == '/dont-crawl-me/'
end
end
# Ban crawlers that request more than 500 pages in a day for 1 week (7 days)
blocklist('allow2ban/excessive_crawling') do |req|
Allow2Ban.filter("excessive-crawling-#{req.ip}", maxretry: 500, findtime: 1.day, bantime: 1.week) do
req.get? && !req.path.match?(%r{\.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?|eot|ttf|otf)$})
end
end
### Custom Response Headers ###
# Add Retry-After header to throttled responses
self.throttled_response_retry_after_header = true
end