☰
Rack::Attack 进阶配置实战:8 个高级防护场景与源码级解析
2026/10/12 4:30:27 网站建设 项目流程
  • 应用安全
  • 后端

【免费下载链接】rack-attack

Rack middleware for blocking & throttling

项目地址:https://gitcode.com/gh_mirrors/ra/rack-attack
点击查看免费下载

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方法中,每个请求按如下优先级依次判定:

  1. 命中任意safelist→ 直接放行,跳过后续所有检查;
  2. 否则命中任意blocklist→ 返回blocklisted_responder(默认 403);
  3. 否则命中任意throttle→ 返回throttled_responder(默认 429);
  4. 否则执行所有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,各层的参数如下:

levellimit(每次周期内允许次数)period(周期时长)换算
1208 秒约 9000 次/天
24064 秒约 54000 次/天
360512 秒(≈8.5 分钟)—
4804096 秒(≈68 分钟)—
510032768 秒(≈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 里,需确认其加载顺序早于请求处理。
  • 命名空间隔离:只 patchRack::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 end

Allow2Ban 的行为语义(源码级)

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。

参数说明与注意点

参数含义本例取值
maxretryfindtime 内允许的最大失败次数,超过即封禁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 个方案,可以提炼出三条实用原则:

  1. 数据源可替换:blocklist/throttle 的判定逻辑本质是"块 + 返回值",因此数据源可以灵活替换为 ENV(第三节)、Rails.cache(第五节)、Rack::Auth 解析结果(第六节)或 Rails 路由(第七节),替换时注意区分哪些走Rack::Attack.cache(throttle/fail2ban/allow2ban),哪些不走(safelist/blocklist)。
  2. 组合优于造轮子:指数退避(第一节)用"多层 throttle"组合实现;封禁阈值(第六节)用现成的 Allow2Ban 原语实现;重置计数(第四节)应优先基于Cache#reset_count/Fail2Ban.reset等官方原语,而非手工拼 key。
  3. 进阶配置需自测:原文档明确标注这些代码"未经测试",尤其 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

项目地址:https://gitcode.com/gh_mirrors/ra/rack-attack
点击查看免费下载

相关推荐

上一篇:toyDB与GraphQL:构建API的新方式
下一篇:ts-toolbelt社区贡献指南:如何为TypeScript最大类型库提交PR

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询