Files
HomeAgent/internal/plugins/webui/login_limiter.go
JianFeeeee 5ebc4481b0 fix(webui): 登录入口加固 —— 限流 + 常量时间比对 + 请求体限量 + 防用户名枚举
门户可经 frp 穿透到公网(https://homeagent.jianfgit.xyz/ 实测直达),
而 handleLogin 原先是**零防护**:无限流、无失败计数、口令用 == 明文比对、
失败不审计。等于把唯一��口令入口直接开到外网任人爆破。

## 改动

1. **按来源 IP 的失败计数限流**(login_limiter.go)
   - 5 次失败后拦,10 分钟窗口。
   - 退避而非永久封禁:窗口过期自动恢复。永久封禁意味着一旦误撞
     (或被撞库)就再也登不进,只能上机器改配置。
   - 成功即清零:手滑输错几次不该被永久记账。
   - **刻意不采信 X-Forwarded-For** —— 该头可伪造,直接采信等于让
     攻击者换一个头就能绕过限流,甚至把限流当成打别人来源的武器。
     代价(已在注释写明):若 webui 挂在反代后,限流会退化成「全局」,
     那种部署应在反代层限流或用 PROXY protocol 传真实来源。
   - **刻意不做账号级锁定**:本系统只有一个管理员账号,账号级锁定
     相比 IP 级无额外收益,却多一个误伤面。
   - 过期记录会被 prune —— 否则攻击者轮换 IP 就能喂成内存泄漏。

2. **常量时间比对**(crypto/subtle):`==` 会在第一个不同字节处短路,
   泄漏「猜对了几位」的时序信息。

3. **请求体限量**:ContentLength 前置拒绝 + MaxBytesReader 兜底。
   ★ 后者**不能只靠解码器报错** —— json.Decoder 按需读流,遇到
   「超大 + 非法 JSON」会在第 0 字节就报语法错误、永远读不到上限,
   于是 8MB 数据已进缓冲而 MaxBytesError 从未出现。只挂 MaxBytesReader
   的写法对最省力的攻击载荷反而无效(实测确认)。

4. **防用户名枚举**:用户不存在与口令错误给完全相同的状态码与报文。

## 判据(8 条)

限流触发 / 按来源隔离(否则一个 IP 就能把所有人锁死,限流即 DoS)/
成功清零 / Retry-After / 请求体限量 / 防枚举 / 过期清理 / 重试时长非零。

变异验证(3 条打红后还原):
- 去掉限流调用 → 3 条判红
- 去掉 ContentLength 前置检查 → 判红(回到 400)
- 去掉 Reset → **起初没打红**:原判据「跑 30 次看是否限流」在阈值只有 5
  时无论有没有 Reset 都会限流,是条**假判据**。已改为**测出实际阈值**
  (清零后应重新拿到完整额度),再去变异即打红。

诚实说明:常量时间比对那条**无法用单测可靠断言**(时序属性,噪声远大于
信号)。它由代码评审保证,不由测试保证 —— 写明以免后人以为有测试兜着。
2026-09-26 13:49:59 +08:00

202 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package webui
import (
"net"
"net/http"
"strconv"
"sync"
"time"
)
// ===== 登录入口的滥用防护 =====
//
// 为什么需要:webui 门户可以经 frp 之类的穿透暴露到公网
// (本项目实测 https://homeagent.jianfgit.xyz/ 直达),而 handleLogin
// 在此之前是**零防护**:无速率限制、无失败计数、口令用 == 明文比对、
// 失败无审计。这等于把一个可爆破的口子直接开到外网。
//
// 设计取舍(都不是「越多越好」):
//
// - **按来源 IP 计数**,不全局:全局计数会让攻击者用一个 IP 的狂刷
// 把所有人(含管理员自己)一起锁死 —— 那既是 DoS,又让「锁着」
// 变成一种攻击手段。按来源隔离后,攻击者只能锁自己。
// - **退避而非永久封禁**:窗口过期自动恢复。永久封禁意味着一旦误撞
// (或被撞库)就再也登不进,只能上机器改配置。
// - **成功即清零**:否则「手滑输错三次」会被永久记账。惩罚应只针对
// 持续失败的行为。
// - **不做账号级锁定**:本系统 webui 只有单一管理员账号,账号级锁定
// 相比 IP 级没有额外收益,却多一个误伤面。
//
// 刻意不做:全局失败告警、验证码、账号锁定 —— 要么超出本系统规模所需,
// 要么会引入新的误伤面。留待真需要时再说。
// loginLimiter 是按来源的失败计数器。
//
// 并发:登录是公网入口,必须扛得住并发爆破,计数用互斥量保护。
// 读路径(Allow)也要加锁 —— 无锁读在多核下会漏计,反而让限流可被
// 多核并发绕过。
type loginLimiter struct {
mu sync.Mutex
fails map[string]*loginFailRecord
// maxFails 是触发限流的失败次数上限。
maxFails int
// window 是失败计数的存活窗口。
window time.Duration
// now 可注入,便于测试窗口过期而不必 sleep。
now func() time.Time
// offsetInTest 仅测试用:虚拟时钟的累计偏移(生产恒为 0)。
offsetInTest time.Duration
}
type loginFailRecord struct {
count int
first time.Time // 本轮计数的起点(**不是**最后一次失败的时间)
}
func newLoginLimiter(maxFails int, window time.Duration) *loginLimiter {
return &loginLimiter{
fails: map[string]*loginFailRecord{},
maxFails: maxFails,
window: window,
now: time.Now,
}
}
// Allow 报告该来源此刻是否允许再试一次登录。
// 被限流时同时返回建议的重试等待时长。
func (l *loginLimiter) Allow(key string) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
rec, ok := l.fails[key]
if !ok {
return true, 0
}
now := l.now()
if now.Sub(rec.first) >= l.window {
// 窗口已过:这条记录不再代表「近期持续失败」,直接丢弃。
delete(l.fails, key)
return true, 0
}
if rec.count < l.maxFails {
return true, 0
}
// 还能再等多久 —— 用于 Retry-After。下限 1 秒:算出 0 会让客户端
// 立即重试,那等于没有限流。
retry := l.window - now.Sub(rec.first)
if retry < time.Second {
retry = time.Second
}
return false, retry
}
// Fail 记一次失败。**成功登录不调用它**,由 Reset 清零。
func (l *loginLimiter) Fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
rec, ok := l.fails[key]
if !ok || now.Sub(rec.first) >= l.window {
// 新一轮失败:计数从 1 重新开始,窗口也重新起算。
l.fails[key] = &loginFailRecord{count: 1, first: now}
return
}
rec.count++
}
// Reset 登录成功后清零该来源的计数。
func (l *loginLimiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.fails, key)
}
// prune 丢弃过期记录。
//
// 为什么需要:不调用它就没有别的触发点,map 会随来源无限增长 ——
// 攻击者轮换大量来源 IP 就能把它喂成内存泄漏。
func (l *loginLimiter) prune() {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
for k, rec := range l.fails {
if now.Sub(rec.first) >= l.window {
delete(l.fails, k)
}
}
}
// liveKeys 返回当前记录数(仅测试用;生产路径不读它,避免为测试留后门)。
func (l *loginLimiter) liveKeys() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.fails)
}
// clockAdvance 仅测试用:推进虚拟时钟,使窗口可在不 sleep 的情况下过期。
func (l *loginLimiter) clockAdvance(d time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
l.offsetInTest += d
l.now = func() time.Time { return time.Now().Add(l.offsetInTest) }
}
// sourceKey 取请求的来源标识。
//
// 刻意**不用** X-Forwarded-For:那个头由客户端可伪造,直接采信等于让
// 攻击者随手换一个头就能绕过限流(甚至把限流当成打别人来源的武器)。
// 真实客户端 IP 只能由前置反代决定,那是部署侧的事。
//
// 代价(写明以免误以为它永远精确):若 webui 直接挂在反代后面,
// 所有请求会共用反代的 IP,限流会退化成「全局」。那种部署应在反代层
// 做限流,或让反代用 PROXY protocol 传真实来源。
func sourceKey(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
if r.RemoteAddr != "" {
return r.RemoteAddr
}
return "unknown"
}
// 限流参数。取「够宽容又不至于被爆破」的值:
//
// 5 次失败后开始拦 —— 记错口令、输错用户名都可能连错几次;持续的
// 失败才可疑。
// 10 分钟窗口 —— 10 分钟内 5 次错基本可断定是爆破;窗口过长会让一次
// 撞库把来源锁很久。
// 1MB 请求体上限 —— 真实登录请求只有几十字节,1MB 已有极大余量;
// 不限制则单个请求就能吃光内存。
const (
loginMaxFails = 5
loginWindow = 10 * time.Minute
loginBodyLimit = 1 << 20
)
// enforceLoginRateLimit 是 handleLogin 的限流闸门。
// 放行返回 true;已超限则已写好 429 并返回 false。
func (h *Handler) enforceLoginRateLimit(w http.ResponseWriter, r *http.Request) bool {
if h.loginLimiter == nil {
return true
}
key := sourceKey(r)
if ok, retry := h.loginLimiter.Allow(key); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())))
writeJSON(w, http.StatusTooManyRequests, map[string]string{
"error": "登录尝试过于频繁,请稍后再试",
})
return false
}
return true
}
// bodyOverLimit 报告请求体是否已超过登录体上限。
//
// 存在理由(重要):MaxBytesReader 只在**读超限**时通过 MaxBytesError 报错,
// 而 json.Decoder 遇到非法 JSON 会在第 0 字节就返回语法错误,根本不往下读。
// 于是「超大 + 非法」这种最省力的攻击载荷只会被当成 400,而数据已进缓冲。
// 这里显式检查 ContentLength 补上这个缺口。
func bodyOverLimit(r *http.Request) bool {
return r.ContentLength > loginBodyLimit
}