mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 21:33:05 +00:00
门户可经 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 都会限流,是条**假判据**。已改为**测出实际阈值** (清零后应重新拿到完整额度),再去变异即打红。 诚实说明:常量时间比对那条**无法用单测可靠断言**(时序属性,噪声远大于 信号)。它由代码评审保证,不由测试保证 —— 写明以免后人以为有测试兜着。
202 lines
6.9 KiB
Go
202 lines
6.9 KiB
Go
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
|
||
}
|