广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何用Rack::Attack::Allow2Ban实现恶意IP临时封禁与自动解封?

把恶意 IP 永久写进黑名单,看起来干脆,实际会带来两个麻烦:一是攻击者换一个地址就能继续,二是被误伤的动态出口可能长期无法访问。Rack::Attack 的 Allow2Ban 规则采用相反策略:先允许少量尝试,一旦同一 IP 在指定时间窗口内触发了足够多次危险动作,就临时封禁该 IP;封禁时间一到,自动从黑名单移除。这样既能在攻击高峰挡住恶意流量,又不用手动维护一套永久黑名单。

如何用Rack::Attack::Allow2Ban实现恶意IP临时封禁与自动解封?

Allow2Ban 的触发机制与参数

Allow2Ban 借鉴了 fail2ban 的“错误次数达到阈值后封禁”思路,但它不是独立守护进程,而是嵌入在 Rack 请求链中。核心方法是 Rack::Attack::Allow2Ban.filter,第一个参数称为 discriminator,通常传 req.ip。它会在每个匹配请求上执行判断块。当块返回真值时,Rack::Attack 以 discriminator 为键增加计数;一旦计数在 findtime 窗口内超过 maxretry,这个 discriminator 对应的 IP 请求会被拦截并返回 403 状态。

这里有三个参数需要区分清楚。maxretry 表示触发封禁前允许的最大失败次数,比如设置为 5,表示第 6 次触发封禁。findtime 是计数窗口,例如 1.minute 表示只看最近一分钟内的失败次数。bantime 是封禁时长,例如 1.hour 表示从达到阈值开始封禁一小时。计数窗口和封禁时长是独立的,攻击者如果在窗口内没有达到阈值,计数会随缓存过期自动归零。

如果想对不同入口分开计数,可以改变第一个参数。例如登录接口传 "#{req.ip}-login",密码重置接口传 "#{req.ip}-reset"。这样同一个 IP 在登录入口的失败次数不会影响密码重置入口的计数,粒度更细。

在 Rails 中配置 Allow2Ban

Rack::Attack 本身是一个 Rack 中间件,在 Rails 中可以直接通过 Gemfile 引入。安装后建议先配置缓存存储,因为 Allow2Ban 依赖共享缓存来维持计数和封禁状态。对于单进程应用,可以暂时使用内存缓存;但生产环境通常有多个 Web 进程,内存缓存会导致不同进程之间的计数互相不可见,因此要换成 Redis 或 Memcached。

# Gemfile
gem 'rack-attack'

接着在初始化文件中设置缓存并编写规则。下面是一个保护登录接口的典型示例:同一 IP 在 1 分钟内只要有 5 次 POST 到 /login,就封禁该 IP 1 小时。这里没有判断密码是否正确,而是把每次登录尝试都看作一次计数,因此阈值不宜设置过低,否则正常用户输错几次密码也会被封。

# config/initializers/rack-attack.rb
class Rack::Attack
  Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
    url: ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379/1')
  )
  Rack::Attack.blocklist('allow2ban login brute force') do |req|
    Rack::Attack::Allow2Ban.filter(
      req.ip,
      maxretry: 5,
      findtime: 1.minute,
      bantime: 1.hour
    ) do
      req.path == '/login' && req.post?
    end
  end
end

如果应用还提供密码重置接口,可以再增加一条独立规则。下面代码为 /password_resets 设置更严格的阈值:10 分钟内最多 3 次 POST,超过就封禁 2 小时。通过 discriminator 加入 -reset 后缀,计数键会与登录规则分开,互不影响。

Rack::Attack.blocklist('allow2ban password reset abuse') do |req|
  Rack::Attack::Allow2Ban.filter(
    "#{req.ip}-reset",
    maxretry: 3,
    findtime: 10.minutes,
    bantime: 2.hours
  ) do
    req.path == '/password_resets' && req.post?
  end
end

值得注意的是,Rack::Attack.blocklist 中的匹配顺序会影响执行效率,但通常不会成为瓶颈。每个请求到来时,Rack::Attack 会按声明顺序检查所有 blocklist 规则,一旦任一条规则返回真值,后续规则不再执行,请求被立即拒绝。Allow2Ban 的计数写入发生在块返回真值时,因此如果前面的封禁规则已经拦截了请求,这条规则就不会再重复计数。

自动解封与缓存键的生命周期

Allow2Ban 的自动解封不是由定时任务完成的,而是依赖缓存键的过期机制。Rack::Attack 在触发封禁时,会把封禁状态写入一个带 TTL 的缓存键。TTL 的长度就是 bantime。当缓存键到期后,后续请求不再命中封禁条件,IP 自然恢复访问。

计数键同样使用缓存存储,并且带有 findtime 的过期时间。例如 findtime: 1.minute 会让失败计数在 1 分钟后自动清理。如果攻击者在一分钟内只失败 4 次,没有达到 maxretry: 5 的阈值,一分钟后计数归零,攻击者必须重新积累次数。这种基于过期的窗口能减少长时间误封,也让历史失败数据不会无限堆积。

不过要明白,这种缓存键过期策略并不是精确到毫秒的滑动窗口。Rack::Attack 内部采用缓存过期来近似窗口,在高并发下可能存在轻微边界误差。对于大多数 Web 应用的防护场景,这点误差不会影响安全性。如果需要更精确的限流算法,可以考虑 Redis 的 sorted set 自行实现,但 Rack::Attack 提供的默认方案已经足够轻量且易于维护。

生产环境需要注意的细节

第一个要注意的是客户端 IP 的获取。Rack::Attack 的 req.ip 默认读取 REMOTE_ADDR,当应用部署在反向代理、负载均衡或 CDN 后面时,这个值可能是代理服务器 IP,而不是真实客户端 IP。如果所有用户都显示为同一个代理地址,Allow2Ban 会误封整个代理流量。解决办法是在应用层配置信任的代理,或者使用 request.remote_ip 并确保上游设置了正确的 X-Forwarded-For 头。

第二个是缓存共享。如果 Rails 应用使用多进程服务器,例如 Puma 的多个 worker 或 Kubernetes 的多个副本,ActiveSupport::Cache::MemoryStore 只能作用于单个进程内部。不同 worker 分别维护自己的计数,攻击者可以通过平均分散请求绕过阈值。因此生产环境务必使用 Redis、Memcached 等外部缓存,并保证所有实例连接同一缓存服务。

第三个是内部可信 IP 的排除。监控系统、健康检查或办公室出口可能频繁请求登录接口,触发封禁后会影响内部使用。可以添加 safelist 规则,把可信 IP 或特定网段排除在 Allow2Ban 之外。比如 Rack::Attack.safelist_ip('127.0.0.1') 或者 Rack::Attack.safelist_ip('192.168.0.0/24')。注意这里的 192.168.0.0/24 是地址段写法,配置时要写成字符串。

最后,测试封禁功能时不要直接用生产流量验证。可以在开发环境调低阈值,比如 maxretry: 2、bantime: 30.seconds,然后从同一 IP 连续请求几次,观察第 3 次是否返回 403。测试完成后通过 Rack::Attack.reset! 或清空 Redis 键来解除当前封禁状态。临时封禁虽然会自动解封,但如果你需要立即恢复测试环境,手动清理缓存比等待更直接。

广告位 · 文章底部(后台-模块管理替换此区域)