- 应用安全
- 后端
【免费下载链接】rack-attack
Rack middleware for blocking & throttling
Rack::Attack 提供了开箱即用的限流(throttle)、封禁(blocklist)与白名单(safelist)能力,但当业务场景超出常规配置(例如需要指数退避限流、动态维护黑名单、按控制器动作匹配请求)时,就需要用到本文所讲的进阶配置技巧。本文以仓库中的 docs/advanced_configuration.md 为骨架,逐例讲解 8 种高级防护方案,并结合 lib/rack/attack 下的真实源码与 spec 测试,解释每个方案背后的实现原理与适用边界。读完本文,你将能够:用分层 throttle 模拟指数退避、自定义请求辅助方法、从 ENV 与 Rails.cache 动态维护黑名单、精确重置某个限流计数、用 Allow2Ban 拦截 Basic Auth 爆破,以及在 Rails 中按controller#action优雅地匹配路由。
写在前面:进阶配置的定位与前提
本文对应的官方文档是 docs/advanced_configuration.md。官方对这份文档的定位非常明确——"进阶(Advanced)"意味着它们不是常规用法,文档开头甚至自嘲式地警告:"Much of this code is untested. Copy-paste at your own risk!"(大部分代码未经测试,复制粘贴风险自负)。因此,动手使用前请务必先跑通自己的测试用例,并在生产环境谨慎评估。
如果你还没有基础配置,建议先阅读 docs/example_configuration.md(它提供了可直接复制到config/initializers/rack_attack.rb的基础防护配置),再回头阅读本文。
理解进阶配置前,需要先明确 Rack::Attack 中间件的判定顺序。在 lib/rack/attack.rb 的call方法中,每个请求按如下优先级依次判定:
- 命中任意safelist→ 直接放行,跳过后续所有检查;
- 否则命中任意blocklist→ 返回
blocklisted_responder(默认 403); - 否则命中任意throttle→ 返回
throttled_responder(默认 429); - 否则执行所有track检查(只记录、不影响请求),放行。
下面的每个进阶方案,本质上都是围绕这四类规则做"组合、扩展或数据源替换",理解了判定顺序,就能理解为什么某个进阶配置要写成 blocklist 而不是 throttle。
一、指数退避(Exponential Backoff)限流
思路与代码
普通限流是"固定周期内固定次数",而指数退避要求"失败越频繁、等待时间越长"。Rack::Attack 本身没有内置退避算法,但可以通过叠加多层 throttle:limit 线性增长、period 指数增长来近似模拟。原文档给出的完整代码如下:
# Allows 20 requests in 8 seconds # 40 requests in 64 seconds # ... # 100 requests in 0.38 days (~250 requests/day) (1..5).each do |level| throttle("logins/ip/#{level}", :limit => (20 * level), :period => (8 ** level).seconds) do |req| if req.path == '/login' && req.post? req.ip end end end参数演算
这段代码共注册了 5 层 throttle,各层的参数如下:
| level | limit(每次周期内允许次数) | period(周期时长) | 换算 |
|---|---|---|---|
| 1 | 20 | 8 秒 | 约 9000 次/天 |
| 2 | 40 | 64 秒 | 约 54000 次/天 |
| 3 | 60 | 512 秒(≈8.5 分钟) | — |
| 4 | 80 | 4096 秒(≈68 分钟) | — |
| 5 | 100 | 32768 秒(≈9.1 小时 ≈ 0.38 天) | 约 250 次/天 |
同一 IP 对/login的 POST 请求会同时计入这 5 层计数器:第 21 次请求(20 秒内)就触发第 1 层,第 41 次请求(64 秒内)触发第 2 层……请求越密集,被拦截的门槛越低,从而近似实现退避效果。
计数语义的源码依据
要理解"为什么 20 次之内放行、第 21 次拦截",需要看 lib/rack/attack/throttle.rb 中matched_by?的判定:(count > current_limit),即计数器严格大于limit 时才判定为超限。对应测试 spec/rack_attack_throttle_spec.rb 也验证了limit: 1时第 2 个请求即返回 429。另外注意,throttle 块的返回值是计数器的discriminator(区分键),req.ip表示按 IP 分别计数;如果返回 falsy(如这里非/login的 POST),则跳过该层计数(lib/rack/attack/throttle.rb)。
进阶补充:动态 limit/period
如果你希望退避参数随请求动态变化,Rack::Attack 的 throttle 还支持limit 和 period 传 proc(详见 README.md 的 Throttling 一节)。例如认证通过的用户放宽到 100 次/秒、普通用户收紧为 1 次/60 秒:
limit_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 100 : 1 } period_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 1 : 60 } Rack::Attack.throttle('request per ip', limit: limit_proc, period: period_proc) do |request| request.ip end源码层面,lib/rack/attack/throttle.rb 的period_for/limit_for会优先调用 proc,否则使用常量值。这与"分层固定值"的退避方案互为补充:前者适合基于用户状态的差异化限流,后者适合基于暴力破解强度的全局退避。
二、扩展 Rack::Attack::Request:自定义请求辅助方法
问题场景
在编写 safelist / blocklist / throttle 规则时,判断逻辑往往需要在多个规则里复用(例如"是否来自 localhost""是否属于某个子域")。与其在每个规则里重复写req.ip == "127.0.0.1",不如把这些判断封装成请求对象的辅助方法。
官方推荐做法:Monkey-patch
原文档给出的方案是对Rack::Attack::Request做 monkey-patch:
class Rack::Attack::Request < ::Rack::Request def localhost? ip == "127.0.0.1" end end Rack::Attack.safelist("localhost") { |req| req.localhost? }这并非社区野路子,而是官方推荐的扩展点。看 lib/rack/attack/request.rb 的源码注释,文件开头就明确写道:
"This is a safe place to add custom helper methods to the request object through monkey patching",并在注释中给出了与文档完全一致的
localhost?示例。
原理与调用链
Rack::Attack 中间件在 lib/rack/attack.rb 中构造request = Rack::Attack::Request.new(env),所有规则块收到的req都是这个对象;由于Rack::Attack::Request < ::Rack::Request(lib/rack/attack/request.rb),它天然具备ip、path、post?、params、env等 Rack::Request 的全部能力。你 patch 上去的辅助方法,在任意 safelist / blocklist / throttle / track 块中都能直接调用。
注意事项
- 加载时机:patch 必须发生在 Rack::Attack 构造中间件实例之前,也就是放在
config/initializers/rack_attack.rb中、所有规则定义之前即可;若放在 Rails 应用的其他 initializer 里,需确认其加载顺序早于请求处理。 - 命名空间隔离:只 patch
Rack::Attack::Request,不会影响应用中其他使用Rack::Request的地方,副作用可控。
三、从环境变量(ENV)构建黑名单
问题场景
黑名单列表(如垃圾 Referer 域名)如果硬编码在代码里,每次增删都要发版。将其迁移到环境变量,可以让运维直接修改部署配置。
完整示例
原文档给出了一个"把 ENV 中逗号分隔的域名列表转成黑名单正则"的示例:
class Rack::Attack # Split on a comma with 0 or more spaces after it. # E.g. ENV['HEROKU_VARIABLE'] = "foo.com, bar.com" # spammers = ["foo.com", "bar.com"] spammers = ENV['HEROKU_VARIABLE'].split(/,\s*/) # Turn spammers array into a regexp spammer_regexp = Regexp.union(spammers) # /foo\.com|bar\.com/ blocklist("block referer spam") do |request| request.referer =~ spammer_regexp end end关键细节剖析
split(/,\s*/):按逗号分割,并允许逗号后跟 0 个或多个空白,兼容"foo.com, bar.com"这种带空格写法。Regexp.union:把字符串数组编译为单个正则。需要注意Regexp.union会自动转义字符串中的正则元字符,所以foo.com变成/foo\.com/,不会把.误当作通配符(文档注释中已标明转换结果)。- 匹配语义:
request.referer =~ spammer_regexp只做子串匹配,即 Referer 中任意位置包含任一黑名单域名都会命中。若要精确匹配域名(避免notfoo.com误伤),可在正则两侧补边界:Regexp.new("(?:#{spammers.join('|')})")或使用Regexp.union(spammers.map { |d| /(?:^|\\.)#{Regexp.escape(d)}/ })这类变体——这部分属于可选的加固思路,需自行测试验证。
这个方案在 lib/rack/attack/configuration.rb 的blocklist注册机制上运行:规则块返回 truthy 即命中黑名单并返回 403。由于spammers与spammer_regexp在class Rack::Attack上下文中求值一次,适合"部署时确定、运行期不常变"的列表;如需运行期动态调整,请参考本文第五节从Rails.cache读取黑名单。
四、精确重置某个限流计数(Reset Specific Throttles)
问题场景
用户触发了某个 throttle(例如登录限流)后,可能因为"验证了手机号"或"管理员人工解封"而需要立即恢复访问。文档提示这类需求可以通过 monkey-patching 添加Rack::Attack.reset_throttle "logins/email", "user@example.com"这样的辅助方法。需要说明的是:当前仓库源码中并没有内置reset_throttle(全仓库检索仅见于该文档),它需要你自己基于 Cache 的原语能力来实现。
计数器的 key 结构:实现的基础
要实现"精确重置",必须先理解 Rack::Attack 的计数器 key 结构。在 lib/rack/attack/cache.rb 的key_and_expiry中:
["#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in]其中prefix默认是'rack::attack'(lib/rack/attack/cache.rb),unprefixed_key即"#{throttle_name}:#{discriminator}"(lib/rack/attack/throttle.rb)。因此logins/email对user@example.com的计数器完整 key 形如:
rack::attack:#{当前时间/周期取整}:logins/email:user@example.com推荐实现:直接用官方原语
与其自己拼 key,更稳妥的做法是利用 Cache 提供的reset_count(unprefixed_key, period)(lib/rack/attack/cache.rb),它内部会按同样规则算出时间桶并delete对应 key:
module Rack::Attack # 需要知道目标 throttle 的 period(此处仅支持固定 period 的 throttle) def self.reset_throttle(name, discriminator) throttle = throttles[name] period = throttle.period.to_i cache.reset_count("#{name}:#{discriminator}", period) end end Rack::Attack.reset_throttle "logins/email", "user@example.com"其中Rack::Attack.throttles通过 Forwardable 委托到配置(lib/rack/attack.rb),返回以名称为 key 的 throttle 哈希;throttle.period是公开的 reader(lib/rack/attack/throttle.rb)。若 period 是 proc(见第一节),则需要额外计算当前请求对应的周期值。
官方内置的同类能力:Fail2Ban.reset
如果你的"解封"需求针对 Fail2Ban/Allow2Ban 的封禁,则无需自己实现——lib/rack/attack/fail2ban.rb 内置了Fail2Ban.reset(discriminator, options),它会同时删除计数 key 与 ban 标志 key。对应测试 spec/fail2ban_spec.rb 验证了 reset 后计数归零、封禁解除、请求恢复 200。
此外,若要在测试套件中全局清空Rack::Attack 状态,官方提供Rack::Attack.reset!(见 lib/rack/attack.rb 与 spec/rack_attack_reset_spec.rb),它通过 cache store 的delete_matched删除所有rack::attack前缀 key——注意这要求 store 支持delete_matched,否则抛出Rack::Attack::IncompatibleStoreError。
五、从 Rails.cache 动态维护黑名单
问题场景
前三节的黑名单都是"启动时确定"的静态数据。如果希望应用运行期间(例如用户举报后、后台任务扫描后)能随时封禁某个 IP,可以把黑名单的判定数据源换成Rails.cache。
完整示例
原文档给出的实现:
# Block attacks from IPs in cache # To add an IP: Rails.cache.write("block 1.2.3.4", true, expires_in: 2.days) # To remove an IP: Rails.cache.delete("block 1.2.3.4") Rack::Attack.blocklist("block IP") do |req| Rails.cache.read("block #{req.ip}") end运行期维护方式:
- 封禁一个 IP:
Rails.cache.write("block 1.2.3.4", true, expires_in: 2.days)—— 2 天后自动过期,天然支持"临时封禁"; - 解封一个 IP:
Rails.cache.delete("block 1.2.3.4"); - 查询封禁状态:
Rails.cache.read("block 1.2.3.4")。
原理与边界
- 为什么可行:blocklist 规则块(
Blocklist继承自Check,见 lib/rack/attack/blocklist.rb)只是在每个请求上执行一次块并检查 truthy(lib/rack/attack/check.rb),完全不经过计数器。所以把块内的判断换成"读缓存"后,黑名单就变成了运行期可写的动态数据源。 - 与 throttle 存储无关:这一点很重要——blocklist/safelist不使用
Rack::Attack.cache(Rack::Attack.cache仅服务于 throttle、track、fail2ban、allow2ban,参见 README.md 的 Cache store configuration 一节),所以这里直接读写Rails.cache即可,无需关心Rack::Attack.cache.store配置成什么。 - 多进程一致性:只要
Rails.cache是 Redis / Memcached 等共享存储,所有应用进程都能读到同一份黑名单,这正是该方案相比进程内 MemoryStore 的核心优势。
六、用 Allow2Ban 拦截 Basic Auth 爆破
问题场景
Basic Auth 只有用户名 + 密码两层校验,是最容易被爆破的目标之一。文档给出的方案是:5 次认证失败(1 分钟内)后,封禁该 IP 1 小时。
完整示例
# After 5 requests with incorrect auth in 1 minute, # block all requests from that IP for 1 hour. Rack::Attack.blocklist('basic auth crackers') do |req| Rack::Attack::Allow2Ban.filter(req.ip, :maxretry => 5, :findtime => 1.minute, :bantime => 1.hour) do # Return true if the authorization header is incorrect auth = Rack::Auth::Basic::Request.new(req.env) auth.credentials != [my_username, my_password] end endAllow2Ban 的行为语义(源码级)
Allow2Ban继承自Fail2Ban(lib/rack/attack/allow2ban.rb),关键差异在fail!方法:
- Fail2Ban:
fail!返回true(lib/rack/attack/fail2ban.rb),即第一次失败请求就立即被 blocklist 拦截(403),同时累计计数,达到maxretry后写入 ban 标志; - Allow2Ban:
fail!只累计计数、返回false(lib/rack/attack/allow2ban.rb),源码注释写得很直白:"we may not block them this time, but they're banned for next time"——在达到maxretry之前请求照常放行(交给应用返回认证失败),达到后 IP 被 ban,后续所有请求(无论认证是否成功)一律 403。
对应测试 spec/allow2ban_spec.rb 验证了完整状态机:未达 maxretry 时失败请求返回 200 且计数递增、不写入 ban key;达到 maxretry 后写入allow2ban:ban:key,此后即使正常请求也返回 403,且不再递增计数。fail2ban 的行为差异可对照 spec/fail2ban_spec.rb:失败请求在未达 maxretry 时就已返回 403。
参数说明与注意点
| 参数 | 含义 | 本例取值 |
|---|---|---|
maxretry | findtime 内允许的最大失败次数,超过即封禁 | 5 |
findtime | 计数观察窗口 | 1.minute |
bantime | 封禁时长(ban key 的过期时间) | 1.hour |
实现细节上:Rack::Auth::Basic::Request.new(req.env)是 Rack 标准库对 Basic Auth 请求头的解析器,auth.credentials返回[username, password]数组;请求头缺失或格式非法时返回 nil,nil != [my_username, my_password]为 true,同样计为一次失败。另注意filter的三个选项都是必填的,缺少会直接抛ArgumentError(lib/rack/attack/fail2ban.rb)。
多规则区分:若应用里配置了多个 Fail2Ban/Allow2Ban 规则,务必在 discriminator 中加入作用域前缀(如"pentest:#{req.ip}"),否则各规则的计数与 ban 标志会互相串扰(详见 README.md 的 Fail2Ban 一节)。
七、在 Rails 中按控制器动作(controller#action)匹配
问题场景
用正则匹配 URL 往往脆弱且难以维护(路由带参数、嵌套资源、i18n 路径等),而 Rails 应用本身就有精确的路由解析能力。文档建议直接借用Rails.application.routes.recognize_path把 URL 还原成控制器动作,再与规则比较。
完整示例
Rack::Attack.safelist('unlimited requests') do |request| safelist = [ 'controller#action', 'another_controller#another_action' ] route = (Rails.application.routes.recognize_path request.url rescue {}) || {} action = "#{route[:controller]}##{route[:action]}" safelist.any? { |safe| action == safe } end机制解读
recognize_path request.url:返回匹配到的路由参数哈希,其中包含:controller与:action;拼成"controller#action"字符串后与白名单数组比对。rescue {}的必要性:对未注册路由(404 场景)recognize_path会抛出路由错误,兜底为空哈希,此时action为"#{}",不会命中白名单,请求继续走正常判定流程。- 为什么放在 safelist:safelist 优先级最高(见导读部分的执行顺序),命中的请求无条件放行,适合"内部接口免限流"类需求。同样的技巧也可以放进 blocklist 实现"某动作限流/封禁"。
相关实现佐证
Rack::Attack 在进入规则判定前会把请求路径规范化:env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])(lib/rack/attack.rb)。在 Rails 环境下PathNormalizer使用ActionDispatch::Journey::Router::Utils(去除尾部斜杠等,见 lib/rack/attack/path_normalizer.rb),这与recognize_path的路由语义保持一致,避免"路径末尾斜杠差异导致正则失配"的经典坑。
结语:用好进阶配置的三条原则
回顾本文 8 个方案,可以提炼出三条实用原则:
- 数据源可替换:blocklist/throttle 的判定逻辑本质是"块 + 返回值",因此数据源可以灵活替换为 ENV(第三节)、Rails.cache(第五节)、Rack::Auth 解析结果(第六节)或 Rails 路由(第七节),替换时注意区分哪些走
Rack::Attack.cache(throttle/fail2ban/allow2ban),哪些不走(safelist/blocklist)。 - 组合优于造轮子:指数退避(第一节)用"多层 throttle"组合实现;封禁阈值(第六节)用现成的 Allow2Ban 原语实现;重置计数(第四节)应优先基于
Cache#reset_count/Fail2Ban.reset等官方原语,而非手工拼 key。 - 进阶配置需自测:原文档明确标注这些代码"未经测试",尤其 monkey-patch(第二节、第四节)与跨进程缓存(第五节)方案,务必在接入生产前用 spec 目录下的测试风格(如 spec/allow2ban_spec.rb、spec/rack_attack_throttle_spec.rb)补充你自己的回归用例,并确认缓存 store 满足
increment/write等接口要求(lib/rack/attack/cache.rb)。
更完整的入门配置、Fail2Ban 参数细节、自定义响应与 RateLimit 响应头等能力,可继续查阅 docs/example_configuration.md 与 README.md。
- 应用安全
- 后端
【免费下载链接】rack-attack
Rack middleware for blocking & throttling
相关推荐
Rack::Attack 高级配置:应对复杂攻击场景的10个策略
Rack::Attack 是 Ruby 和 Rails 应用中用于阻止和限制恶意请求的强大中间件。在当今复杂的网络安全环境中,简单的防护策略往往不足以应对各种攻
应用安全后端学之思开源考试系统XZS二次开发指南:零基础扩展新题型和功能模块的完整教程
学之思开源考试系统XZS二次开发指南:零基础扩展新题型和功能模块的完整教程 学之思开源考试系统XZS是一款功能强大的在线考试平台,支持多种题型和灵活的部署方式。
教育后端前端小程序Rack::Attack实战:构建高效的Web应用防护层
Rack::Attack实战:构建高效的Web应用防护层 什么是Rack::Attack Rack::Attack是一个基于Rack中间件的Ruby安全防护工具
应用安全后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考