mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-01 15:02:53 +00:00
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 都会限流,是条**假判据**。已改为**测出实际阈值** (清零后应重新拿到完整额度),再去变异即打红。 诚实说明:常量时间比对那条**无法用单测可靠断言**(时序属性,噪声远大于 信号)。它由代码评审保证,不由测试保证 —— 写明以免后人以为有测试兜着。
This commit is contained in:
@ -12,8 +12,10 @@ import (
|
||||
"time"
|
||||
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"net/http"
|
||||
)
|
||||
@ -108,6 +110,10 @@ type Handler struct {
|
||||
sessionMu sync.Mutex
|
||||
sessions map[string]time.Time
|
||||
|
||||
// loginLimiter 是登录入口的按来源失败计数(见 login_limiter.go)。
|
||||
// 门户可被穿透到公网,登录是唯一的口令入口,必须有滥用防护。
|
||||
loginLimiter *loginLimiter
|
||||
|
||||
sseEvents *sseEventRing // SSE 事件环状缓冲区,Last-Event-ID 重放用
|
||||
|
||||
chatMu sync.Mutex
|
||||
@ -175,6 +181,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
term: term,
|
||||
llm: llm,
|
||||
sessions: make(map[string]time.Time),
|
||||
loginLimiter: newLoginLimiter(loginMaxFails, loginWindow),
|
||||
pendingIdx: -1,
|
||||
chatMsgCache: make(map[string]*chatMsgEntry),
|
||||
sseEvents: newSSEEventRing(200),
|
||||
@ -511,6 +518,11 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// 限流必须在**校验之前**:口令错误也要计数。否则爆破请求每次都走
|
||||
// 完整套校验(含配置读取),限流也就失去了保护意义。
|
||||
if !h.enforceLoginRateLimit(w, r) {
|
||||
return
|
||||
}
|
||||
_, username, password, _ := h.getWebUIConfig()
|
||||
if username == "" || password == "" {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "webui username/password not configured"})
|
||||
@ -520,14 +532,47 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
// 体积限制必须在解码**之前**生效,且不能只依赖解码器报错:
|
||||
//
|
||||
// json.Decoder 是**按需读流**的。若请求体是「超大且非法 JSON」,
|
||||
// 解码器会在第 0 个字节就报语法错误,**永远不会读到上限**,
|
||||
// 于是 MaxBytesError 根本不会出现 —— 而 8MB 数据仍已被读入缓冲。
|
||||
// 那样「限体积」只对「合法到能继续解析的大 JSON」生效。
|
||||
//
|
||||
// 所以先用 ContentLength 快速拒绝(覆盖绝大多数真实攻击:直接发
|
||||
// 声明很大的 Content-Length),再用 MaxBytesReader 兜住分块传输
|
||||
// 与谎报 Content-Length 的情况。
|
||||
if r.ContentLength > loginBodyLimit {
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "请求体过大"})
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, loginBodyLimit)
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "请求体过大"})
|
||||
return
|
||||
}
|
||||
// 解析到一半也可能撞上上限(合法 JSON 但超长),再兜一次。
|
||||
if r.ContentLength < 0 && bodyOverLimit(r) {
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "请求体过大"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if body.Username != username || body.Password != password {
|
||||
// 两条失败路径**必须**给完全相同的状态码与报文,否则可用于枚举用户名。
|
||||
// 用 constant-time 比较:== 会在第一个不同字节处短路,泄漏
|
||||
// 「猜对了几位」的时序信息(远程噪声大,但攻击者可多次采样取均值)。
|
||||
userOK := subtle.ConstantTimeCompare([]byte(body.Username), []byte(username)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(body.Password), []byte(password)) == 1
|
||||
if !userOK || !passOK {
|
||||
h.loginLimiter.Fail(sourceKey(r))
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
// 成功即清零:惩罚只针对持续失败,手滑输错几次不该被记账。
|
||||
h.loginLimiter.Reset(sourceKey(r))
|
||||
token, expires, err := h.createSession()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
|
||||
Reference in New Issue
Block a user