Rails应用的日志里,每天都能看到成百上千次针对/users/sign_in的POST请求,用户名密码组合五花八门,全部来自几个固定的IP段。手动在Nginx层拉黑显然治标不治本,IP换一批又来了。真正有效的做法是在应用层构建一套动态黑名单:谁在短时间内产生了足够多的失败行为,谁就被自动封禁一段时间,到期自动解除。Rack::Attack正是实现这套机制最顺手的工具。

Rack::Attack的基础结构与工作原理
Rack::Attack是一个Rack中间件,它工作在请求进入Rails路由和控制器之前的位置。这意味着被它拦截的恶意请求根本不会消耗业务层的资源,不需要实例化控制器、不需要查询数据库,直接在中间件层面就被返回429或403了。对于暴力破解这种高频低成本的攻击,把拦截点前置到中间件层是性价比最高的方案。
它的核心概念有三个:safe_list(白名单)、blocklist(黑名单)和throttle(限流)。黑名单和限流的区别在于:限流统计的是请求次数,超过阈值就临时拒绝;而黑名单可以基于任意条件直接拒绝,条件可以是静态的IP段,也可以是动态变化的,比如Redis里某个键是否存在。动态黑名单正是利用了这一点——当限流规则检测到某个IP的失败登录次数超标时,把它写入Redis,黑名单规则每次请求都检查Redis,命中即拒绝。
config/initializers/rack_attack.rb中,Gemfile里加入gem 'rack-attack'后执行bundle即可。Rails会自动加载这个initializer,中间件链的挂载在新版本中已经默认开启,老版本需要手动config.middleware.use Rack::Attack。构建失败计数与动态封禁机制
光靠限流还不够精细。暴力破解的特征不是请求多,而是失败多,正常的用户可能连续输错两三次密码,但不会一分钟内失败二十次。所以我们需要在业务层记录失败次数,再交给Rack::Attack判断。思路是:登录失败时往Redis里对一个以IP为维度的计数器自增并设置过期时间,Rack::Attack的blocklist规则读取这个计数器,超过阈值就把IP加入封禁集合。
# config/initializers/rack_attack.rb
class Rack::Attack
# 配置Redis作为存储(默认是进程内存,多进程部署必须换Redis)
cache.store = ActiveSupport::Cache::RedisCacheStore.new(
url: ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/1")
)
### 动态黑名单:检查封禁集合 ###
blocklist("banned_ips") do |req|
# 只对登录、注册等敏感路径生效
if req.path =~ %r{^/(users/sign_in|admin/login)}
Rack::Attack::BANNED_IPS.include?(req.ip)
end
end
### 限流规则:登录接口每分钟最多5次请求 ###
throttle("logins/ip", limit: 5, period: 60) do |req|
req.post? && req.path == "/users/sign_in"
end
class << self
BANNED_IPS = Set.new
# 由业务层调用的封禁方法
def ban_ip!(ip, duration = 30.minutes)
cache.write("ban:#{ip}", true, expires_in: duration)
BANNED_IPS << ip
Rails.logger.warn("[Rack::Attack] banned #{ip} for #{duration}s")
end
def banned?(ip)
cache.read("ban:#{ip}").present?
end
end
end为了让规则真正读Redis而非进程内存集合,blocklist块内应该改为Rack::Attack.cache.read("ban:#{req.ip}").present?这样的动态查询。上面代码保留Set只是为了演示结构,实际生产中封禁状态必须放在Redis里,否则Puma多进程部署时每个进程各有一份内存缓存,封禁会时灵时不灵,这是最常见的一个坑。
业务层的挂钩同样关键。以Devise为例,可以覆写控制器的failed_attempts逻辑,或者在SessionsController#create里rescue认证失败异常,调用Rack::Attack.ban_ip!(request.remote_ip, 30.minutes)。更彻底的做法是结合计数:失败三次触发验证码,失败十次直接封禁半小时,封禁时长随失败次数递增,形成指数退避式的惩罚。
误伤防护、解封策略与生产调优
动态黑名单最大的风险是误伤。大公司出口、校园网、机场WiFi往往成百上千人共享一个公网IP,一个人输错密码可能连累整个网络段被锁。缓解手段有几条:第一,白名单先行,把已知的企业出口IP、健康检查探针IP放进safelist,白名单规则优先级最高,命中直接放行;第二,封禁键值里同时绑定IP和登录目标账号,只针对可疑组合限流,而不是一刀切封IP的所有访问;第三,登录页保留合理的限流阈值,正常用户很难一分钟提交五次表单。
# 白名单:内部网络与监控探针永不拦截
safelist("allow_localhost") do |req|
req.ip == "127.0.0.1" || req.ip.start_with?("10.0.", "192.168.")
end
# 封禁时长递增:第N次被封,时长为 2^N 分钟
def ban_duration_for(ip)
count = cache.read("ban_count:#{ip}").to_i
[2**count, 1440].min.minutes # 最长24小时
end解封策略上,推荐全部依赖Redis的expires_in自动过期,不要维护手动解封后台,除非安全团队有明确需求。自动过期让封禁具备自愈性,即使攻击者换IP回来,计数器重新累积,又会触发新一轮封禁,整个闭环无需人工介入。如果确实需要人工干预,可以做一个仅管理员可见的接口,删除ban:某IP这个键即可。
最后是可观测性。Rack::Attack提供ActiveSupport::Notifications钩子,订阅rack_attack事件可以把每次拦截记录到日志或监控系统中。建议把封禁触发量做成监控指标,突然飙升往往意味着撞库攻击正在进行,这时候可以临时收紧阈值或延长封禁时长。配合日志里记录的req.ip、命中的规则名,排查误伤也只需要几分钟。整体而言,这套方案零额外基础设施成本,一个Redis加几十行配置,就能挡住绝大多数脚本化的暴力破解。
Rack::Attack黑名单暴力破解防护修改时间:2026-09-06 05:44:35