速率限制是一个东西感觉解决时刻你写令牌桶并看测试通过。我的通过了每个测试我为它写。在生产中,在 Caddy 后面,网站的每个单一访问者分享了一个桶,十个请求一分钟,对于整个网站组合。不每人。总计。

限制器本身从不是 bug
真正的桶逻辑是一个小的,在内存中,每个钥匙令牌桶,什么都不花里胡哨,补充超时间达到爆裂等于每分钟的速率:
func (l *Limiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
b, ok := l.buckets[key]
if !ok {
l.buckets[key] = &bucket{tokens: l.burst - 1, lastSeen: now}
return true
}
elapsed := now.Sub(b.lastSeen).Seconds()
b.tokens += elapsed * l.rate
if b.tokens > l.burst {
b.tokens = l.burst
}
b.lastSeen = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}那个代码是正确的并且没有改变。这个 bug 完全在什么得到通过如同 key。
怎样我钥匙了它,和为什么它看起来很好本地
天真的版本钥匙纯粹在 r.RemoteAddr,原始插座地址 Go 自己的 HTTP 服务器看到对于连接:
// 什么我首先运送
func limiterKey(r *http.Request) string {
return r.RemoteAddr
}本地,运行一切通过 docker compose up 在我的笔记本电脑,那是确切的浏览器的自己的地址,127.0.0.1 或接近它,一个机器,一个桶,工作恰好如预期在每个手动测试我运行的中。
拓扑后面 Caddy 在一个真实部署中不是那样。每个请求到博客服务通过 frontend 容器到达作为一个内部反向代理,它自己坐后面 Caddy,它自己面向互联网。RemoteAddr 在博客服务是frontend 容器的地址,对每个单一访问者,因为那是真正谁打开 TCP 连接到博客服务从它的观点。一个基于那个钥匙不每个访问者全部,它是每个跳,而且有恰好一个跳在"每个互联网访问者"和"博客服务"之间。一个每个 IP 限制器建造在那个钥匙是一个事实站点全局限制器,它保持在每个本地测试中无形因为本地没有代理跳隐藏里面,bug 只存在在我从不直接测试对的拓扑。
标准的答案,和陷阱内部它
对"我看到的地址不是真实的客户"的标准修复是 X-Forwarded-For,一个头代理追加当他们前进请求。盲目地信任它是它自己的知名陷阱虽然:X-Forwarded-For 是一个请求头,并且任何客户可以设置它到字面上什么在 Caddy 曾经看到请求,包括一个假的值选择特别的为了与某个别人的速率限制桶碰撞,或避开他们自己的。你不可以只读它并信任它,你必须知道哪个部分的它,如果任何,你自己的基础设施真的写了。
规则我降落在:信任恰好一个值,并且只当有恰好一个值信任。
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" && !strings.Contains(xff, ",") {
if ip := strings.TrimSpace(xff); ip != "" {
return ip
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}一个单一值的头意味着博客服务的自己的上游(前端代理,和只有前端代理)写了它新鲜。什么有一个逗号在它中,多个跳链在一起,完全是什么一个客户伪造的头从外面将生产,既然一个真实的客户有没有早期的跳已经附加什么在他们自己的假值之前,所以那个形状掉回到 RemoteAddr 而不是是钥匙完全。
修复的其他的一半住一个跳早前
没有那个工作除非什么上游真的重写头到一个单一可信任的值而不只是中继无论浏览器送了。那是前端代理的工作,并且它做那个故意地,不偶然:
const inboundXff = event.request.headers.get('x-forwarded-for');
let clientAddress = '';
try {
clientAddress = event.getClientAddress();
} catch {
// 没有可信任的客户地址对这个请求(例如一个直接的
// loopback healthcheck 绕过 Caddy)和落回到发送无
}
const forwardedFor = resolveForwardedFor(inboundXff, clientAddress);
if (forwardedFor) headers.set('x-forwarded-for', forwardedFor);resolveForwardedFor 把最后一个项的什么来了(那个 Caddy 自己附加,可信任无论什么一个客户前面链中的早期前面的什么,客户端可以有伪造的)或掉回到前端的自己的解决的客户地址,并且总是发射恰好一个值向前。博客服务的单一值检查只保持因为这个早期的跳保证它,从不转发一个多值链它自己。两个服务,两个小逻辑片,并且整个东西只真正工作因为每一个信任层立即后面它并没什么进一步回比那。
什么实际上浮现了它
不一个报告从一个生气的用户得到速率限制不公平,那是什么我预期会最后标记这个。它在一个审查通过出现当工作通过登入和密码改变端点,追踪通过恰好谁可以看到什么地址在每个跳,并意识到数字"10 请求一分钟"是真的,只有真的对错的人口。一旦你说它高声,"每个访问者到网站分享一个桶",它听起来明显地打破。达到那个需要真的画请求路径跳在跳在一个限制器通过了它的单元测试的拓扑它会真的运行在。
相同的修复,两次
博客服务不是唯一的地方这个恰好的 bug 形状住。auth 服务保持它自己的分开的相同的限制器包的副本比与博客服务分享一个模块(这个整个代码库复制一些小事物像那目的上,JWT 声称结构是另一个,比从一个分享的内部包拉为几行代码),并且它的登入和密码改变端点有了相同的单一跳后面 Caddy 问题,钥匙了相同的错误的方式相同的原因。修复一个而不是另一个会有意味着一个端点强制执行一个真实的每个访问者限制当一个同胞端点两个服务结束保持默默地全局。一旦我理解了 bug 的真实形状,检查是否它存在任何地方其他相同的请求路径触及了几分钟。写这个贴子花了远长比任何修复做。
有趣一下的无聊课
一个限制器有一个正确的令牌桶和错误的钥匙不是半打破,它是完全打破,它只是失败在一个方向那看起来像从外面成功,请求得到允许或拒绝,数字移动,什么都不扔一个错误。那是真正的危险的形状对这个 bug 种取。一个限制器那崩溃彻底会有被抓住第一次有人打 endpoint 本地。那静地保护错误的边界保持工作,一直到边界它是真的应该保护得到打由某个人这不是玩好的。