Rack::Attack 的 Fail2Ban 不是独立封禁引擎,而是在 Rack::Attack.cache 中维护计数,再由 blocklist 规则返回 true 来实现拦截。标题中的 Filter::Blocklist 并不对应实际的类路径,正确组合是 Rack::Attack.blocklist 块内调用 Rack::Attack::Fail2Ban.filter。因此配置黑名单的关键,是让 filter 的缓存 key、失败判定条件和封禁时长保持一致,否则会出现计数失效或误封。

Fail2Ban 过滤与 blocklist 的协作机制
Rack::Attack 中间件在处理请求时会依次执行 safelist、blocklist、throttle 和 track 规则。blocklist 块如果返回真值,请求会被立即拒绝,默认响应状态码是 403。这种短路特性非常适合做 IP 黑名单,因为被封禁地址不需要继续进入业务控制器,节省大量资源。
Fail2Ban.filter 方法承担了计数和封禁状态判断的工作。它接收一个缓存 key 以及三个时间相关参数:findtime 是统计失败次数的时间窗口,maxretry 是该窗口内允许的最大失败次数,bantime 是达到阈值后封禁的时长。当同一 IP 在 findtime 内失败次数达到 maxretry 时,filter 会在缓存中写入 banned 标记并返回 true,后续请求再进入 blocklist 时直接命中封禁状态,不会再执行业务代码。
因此,必须给 Rack::Attack 配置一个可用的缓存存储。Rails 中可以使用 ActiveSupport::Cache::MemoryStore 快速验证,但多进程或多实例部署时需要切换为 Redis、Memcached 等共享存储,否则每个进程的计数器互相独立,封禁规则会失去意义。
基础配置:初始化器与可疑路径过滤
先从一个最实用的场景入手:封禁那些频繁扫描敏感路径的 IP。正常用户不会在短时间内大量访问 .env、phpmyadmin、wp-admin 这类路径,所以可以直接在 blocklist 内使用请求特征作为失败条件。
以下初始化器写入 config/initializers/rack_attack.rb,配置了内存缓存和一条诱捕规则。filter 块返回 true 时记录一次失败,10 分钟内超过 3 次就封禁 2 小时。
# config/initializers/rack_attack.rb
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
Rack::Attack.blocklist('fail2ban suspicious paths') do |req|
Rack::Attack::Fail2Ban.filter("suspicious-paths-#{req.ip}", maxretry: 3, findtime: 10.minutes, bantime: 2.hours) do
sensitive_path = ['/wp-admin', '/.env', '/phpmyadmin', '/actuator']
sensitive_path.any? { |path| req.path.start_with?(path) }
end
end
这个规则之所以有效,是因为它不依赖登录是否成功,只要请求目标本身足够可疑就应该被计数。注意缓存 key 中必须包含 IP,可以通过字符串插值生成,例如这里的 suspicious-paths-192.168.1.10。不要把 key 写成固定值,否则所有请求会共用一个计数器和封禁状态,导致一个攻击源触发后所有用户被误伤。
另外,Rack::Attack 的 blocklist 名称 fail2ban suspicious paths 只用于日志和调试,不会影响缓存 key。需要修改名称时不会影响已有封禁状态,但修改 key 前缀则会让旧计数失效。
登录失败计数:让 Fail2Ban 真正感知认证结果
上一节的规则针对路径特征,适合拦截扫描器。如果要对登录失败进行封禁,直接在 blocklist 中判断 req.path 和请求方法会带来问题:正常用户输错密码也会被计数,多次错误后可能被封禁。为了减少误伤,应当只在应用确认认证失败时调用 Fail2Ban.filter 来记录一次失败。
下面是在 SessionsController 中记录失败的代码。authenticate 方法返回 false 或 nil 时执行 filter,传 true 作为失败信号。maxretry、findtime 和 bantime 必须与 blocklist 端规则保持一致。
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
session[:user_id] = user.id
redirect_to root_path
else
Rack::Attack::Fail2Ban.filter("login-failures-#{request.ip}", maxretry: 5, findtime: 10.minutes, bantime: 1.hour) do
true
end
render :new, status: :unprocessable_entity
end
end
end
这里需要理解封禁生效的时机。控制器中调用 filter 会把当前 IP 的失败计数加一,如果达到阈值则写入 banned 状态,但当前请求已经进入控制器,无法再被 Rack::Attack 拦截。被封禁的 IP 会从下一次请求开始触发 blocklist 规则。这是一种轻微延迟,但通常足够阻止自动化攻击继续尝试。
为了让后续请求真正被拦截,必须在 blocklist 中配置同一条 key。下面这段规则放在初始化器中,当请求携带的 IP 已经处于 banned 状态时,filter 会直接返回 true,不需要再运行内部块。
Rack::Attack.blocklist('fail2ban login failures') do |req|
Rack::Attack::Fail2Ban.filter("login-failures-#{req.ip}", maxretry: 5, findtime: 10.minutes, bantime: 1.hour) do
false
end
end
内部块返回 false 表示不额外增加计数,因为计数只在控制器认证失败时进行。这样 blocklist 端只负责读取已封禁状态,不会因为普通访问登录页而误加次数。
多规则拆分与误封防御
实际项目中不应该把所有失败场景混在一个缓存 key 中。管理后台登录、API 令牌认证、敏感路径扫描的攻击成本和业务影响不同,应当拆分为多个 blocklist 规则,并设置不同的阈值和封禁时长。例如 API 认证失败可以设置 maxretry 为 10,findtime 为 5 分钟,bantime 为 30 分钟;管理后台登录失败可以设置 maxretry 为 5,封禁 1 小时。
缓存 key 的命名建议包含规则用途和 IP,例如 admin-login-failures-203.0.113.5。这样运维人员可以通过缓存客户端快速定位某个 IP 在哪条规则下被封禁。不要使用 IP 作为 key 的唯一组成部分,否则不同规则之间会共享计数,一条规则触发封禁后可能影响其它规则的判断。
为降低误封,可以配置 safelist 放行可信地址。比如公司内网、办公 VPN 出口、健康检查用户代理等。safelist 返回 true 时请求会直接跳过后续所有 blocklist 和 throttle 规则。
Rack::Attack.safelist('allow internal network') do |req|
req.ip == '203.0.113.10' || req.ip.start_with?('10.0.')
end
如果希望给被封禁用户更友好的响应,可以自定义 blocklisted_response,返回纯文本或 JSON,避免默认的 403 页面让用户误以为服务故障。
Rack::Attack.blocklisted_response = lambda do |env|
[403, {'Content-Type' => 'text/plain'}, ['您的请求过于频繁,IP 已被临时封禁']]
end
日常验证中,可以在 Rails console 里读取对应缓存 key,查看当前失败次数和过期时间。使用内存缓存时直接读取即可;使用 Redis 时可以通过 redis-cli 或 Rails.cache.read 检查。手动解封只需删除该 key,但要确认 key 名的完整拼写和 IP 格式。
生产环境缓存与中间件顺序
前面示例使用的 MemoryStore 只适合开发环境和单进程测试。如果应用通过 Puma 多进程或多容器部署,每个进程都会维护一份独立的内存计数,攻击者只需把请求分散到不同进程就能绕过封禁。生产环境应当换成共享缓存。以 Redis 为例,配合 redis-store 或 Rails 自带的 RedisCacheStore 配置 Rack::Attack。
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
url: ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379/0'),
expires_in: 2.hours
)
共享缓存还有另一个好处:所有实例看到同一份封禁列表,解封和排查也更简单。不过 Redis 故障时 Rack::Attack 可能无法计数,建议在缓存层配置超时和降级策略,避免安全模块拖垮整个请求链路。
中间件顺序也需要关注。Rack::Attack 默认插入 Rack 栈的较外层,如果希望依赖 Warden 或 Devise 的认证结果来判定失败,需要把 Rack::Attack 移到这些中间件之后。但在很多场景下,通过在控制器中调用 filter 记录失败可以绕开中间件顺序问题,因为此时认证过程已经完成,无需让 Rack::Attack 提前读取认证状态。采用这种方式后,中间件顺序保持默认即可,不需要对 Rails 默认栈做额外调整。
最后还要根据业务容忍度调整参数。findtime 太短会让慢速暴力破解逃过计数,太长又可能把几天前的失败累积到今天。bantime 太短起不到威慑作用,太长容易积累误封。建议先观察正常登录失败分布,再去设置阈值,并保留日志和手动解封通道。
Rack AttackFail2BanIP封禁修改时间:2026-10-03 01:35:24