Files
HomeAgent/internal/plugins/webui/auth_hardening_test.go
JianFeeeee f256098c9f refactor: 全面改名为 HologramAgent + 提示词技术内容下沉到 skill/工具描述 + 修 21 处乱码
## ① 全面改名 HomeAgent → HologramAgent

仓库已改名,但代码与文档里仍有大量旧名。本次按"用户能看到的优先"全量替换:

- 用户可见:登录页标题/页脚、默认人格第一句、CLI 状态输出、启动日志、
  plugin_install 的工具描述、AI 图片插件的 User-Agent
- 文档与脚本:README、assets/docs、docs/zh、deploy/*.sh、.github/workflows
- 鸿蒙工程:cmd/ohos/HomeAgent → cmd/ohos/HologramAgent(git mv)
  目录内的 module/app 描述同步;**证书路径原样保留**
  (default_HomeAgent_xxx.cer/p7b/p12 是签名材料的实际文件名,改了会找不到证书)
- 插件元数据:plugin.json 的 author

★ 必须保持原样的两处(改了会坏):
  · legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」…"
    —— 那是识别历史播种人格卡的**原文锚点**,改了就识别不出旧数据
  · 公开 SDK 的 ProxyAuthHomeAgent = "homeagent"
    —— 那是 SDK 契约里的值,属独立仓库,本仓不改

## ② 修 21 处乱码(U+FFFD)

这些是历史编辑留下的编码破坏,全在注释里,例如:
  「赌注重下���遍」→「赌注重下一遍」
  「「不��滤」」  →「「不过滤」」
  「**待��退场**」→「**待退场**」

★ 保留 providers/chineseclip/tokenizer.go 里的 2 处:那是**故意**用
  U+FFFD 演示"非法 UTF-8 字节被归一成 U+FFFD",不是乱码。

## ③ 提示词技术内容下沉(用户:技术性内容应该收敛到 skill 与 -help 中)

实测系统提示词 2309 字,其中两段几乎全是**模型无法据以行动的实现细节**:

  【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、batchRunnable 判据…)
  【三层记忆】   1069 字(L1/L2/L3 编号、消费式删除、向量空间、指纹…)

收敛后 **2309 → 1723 字(-25%)**,保留的全是"模型据此做什么不同的事":

  工具顺序:默认并行 / 不要依赖先后 / 同通道保序 / 写类工具可能串行
  三层记忆:时序里能看最近 / doc_query 把历史事件按原始时间戳插回时序 /
           关系用 memory_recall / 成文知识用 knowledge_search

判断口径写进判据:新加一段提示词前先问"模型会据此做什么不同的事",
答不上来就该进 skill 或工具描述。

## 判据

prompt_memory_test.go 新增 2 条:
- TestSystemPromptHasNoImplementationJargon
  黑名单:ParallelSafe / batchRunnable / 消费式 / DenseCosine / fingerprint /
  向量空间 / 批内降级 / 自激
- TestSystemPromptStaysCompact:上限 2000 字(当前 1723)

变异:把 "ParallelSafe 声明、批内降级" 加回工具顺序段 ⇒ 第一条立刻红。

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- make check-client-versions:客户端与内核版本一致
2026-10-22 20:49:44 +08:00

428 lines
16 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 (
"bufio"
"bytes"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// ===== 登录入口的滥用防护 =====
//
// 真实风险:webui 门户经 frp 穿透到公网(https://homeagent.jianfgit.xyz/
// 实测直达),而 handleLogin 在加固前**零防护**:无速率限制、无失败计数、
// 口令用 == 明文比对、失败不审计。配合历史上出现过的弱口令习惯
// (日志里能看到「为截图登 WebUI 临时改密码」这类操作),等于把一个
// 可爆破的口子直接开到外网。
//
// 这组判据钉住四件事:有限流、限流不误伤、可退避、比对不泄漏信息。
const testAuthPassword = "correct-horse-battery"
func newAuthTestHandler(t *testing.T) *Handler {
t.Helper()
cfgReg := internalConfig.NewConfigRegistry("")
seedWebUIConfig(cfgReg)
cfgReg.PluginConfig("webui").Set("password", testAuthPassword)
h := NewHandler(testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", cfgReg)}))
// 走生产真实入口 Handler() = proxyDispatch(logged(mux))。
// 直接用 h.mux 会绕过 logged 中间件,测不到限流的实际生效位置。
h.RegisterRoutes(http.NewServeMux())
return h
}
func postLoginFrom(t *testing.T, h *Handler, ip, user, pass string) *httptest.ResponseRecorder {
t.Helper()
body := `{"username":"` + user + `","password":"` + pass + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.RemoteAddr = ip + ":54321"
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.Handler().ServeHTTP(rec, req)
return rec
}
func tryLoginFrom(h *Handler, ip, user, pass string) int {
body := `{"username":"` + user + `","password":"` + pass + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.RemoteAddr = ip + ":54321"
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.Handler().ServeHTTP(rec, req)
return rec.Code
}
func tryLogin(h *Handler, user, pass string) int {
return tryLoginFrom(h, "203.0.113.1", user, pass)
}
func postLoginRaw(t *testing.T, h *Handler, body []byte) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", bytes.NewReader(body))
req.RemoteAddr = "203.0.113.1:54321"
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.Handler().ServeHTTP(rec, req)
return rec
}
// 连续失败必须被拦:放行到 30 次都不该一次 429 都没有。
func TestLoginRateLimitsAfterRepeatedFailures(t *testing.T) {
h := newAuthTestHandler(t)
ok, blocked := 0, 0
for i := 0; i < 30; i++ {
switch code := tryLogin(h, "admin", "wrong-password"); code {
case http.StatusOK:
ok++
case http.StatusTooManyRequests:
blocked++
}
}
if ok > 0 {
t.Errorf("错误口令居然登录成功了 %d 次", ok)
}
if blocked == 0 {
t.Error("连续 30 次错误口令从未触发限流(一次 429 都没有)—— 门户可被暴力破解")
}
}
// 限流必须按来源区分:否则一个 IP 的狂刷就能把所有人(含管理员)一起锁死,
// 限流本身就变成了 DoS 手段。
func TestLoginRateLimitIsPerSource(t *testing.T) {
h := newAuthTestHandler(t)
for i := 0; i < 30; i++ {
tryLoginFrom(h, "203.0.113.9", "admin", "bad")
}
if code := tryLoginFrom(h, "203.0.113.9", "admin", "bad"); code != http.StatusTooManyRequests {
t.Errorf("攻击者来源应被限流,实际 %d", code)
}
if code := tryLoginFrom(h, "198.51.100.7", "admin", testAuthPassword); code != http.StatusOK {
t.Errorf("其他来源的正常登录被误伤(跨来源污染),实际 %d", code)
}
}
// 成功必须清零:不能因为早先手滑输错几次就再也登不进。
//
// 判据强度说明:不能只「跑 30 次看有没有限流」—— 阈值只有 5,
// 跑 30 次时**无论有没有 Reset 都会限流**,那样的判据是假的
// (已实测:去掉 Reset 后本条依然绿)。必须**测出实际阈值**:
// 清零后应当重新拿到完整的窗口额度。
func TestLoginSuccessResetsCounter(t *testing.T) {
h := newAuthTestHandler(t)
// 先用 3 次失败「污染」计数(低于阈值,此时仍能登录)
for i := 0; i < 3; i++ {
tryLogin(h, "admin", "bad")
}
if code := tryLogin(h, "admin", testAuthPassword); code != http.StatusOK {
t.Fatalf("少量失败后应仍能正常登录,实际 %d", code)
}
// 成功之后,额度必须**重新算满**:连错 loginMaxFails 次才该被拦。
for i := 1; i <= loginMaxFails; i++ {
if code := tryLogin(h, "admin", "bad"); code != http.StatusUnauthorized {
t.Fatalf("第 %d 次失败期望 401,实际 %d —— 成功登录未清零计数(额度被提前扣掉了)",
i, code)
}
}
if code := tryLogin(h, "admin", "bad"); code != http.StatusTooManyRequests {
t.Errorf("第 %d 次失败后应被限流,实际 %d", loginMaxFails+1, code)
}
}
// 429 必须带 Retry-After,否则客户端/脚本无从判断何时该重试。
func TestLoginRateLimitedCarriesRetryAfter(t *testing.T) {
h := newAuthTestHandler(t)
for i := 0; i < 30; i++ {
tryLogin(h, "admin", "bad")
}
rec := postLoginFrom(t, h, "203.0.113.1", "admin", "bad")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("期望 429,实际 %d", rec.Code)
}
if rec.Header().Get("Retry-After") == "" {
t.Error("429 响应缺少 Retry-After 头")
}
}
// 请求体必须限量:不限流的话一个请求就能把内存吃光。
//
// 判据要点:**不能只看状态码**。8MB 垃圾 JSON 会让解码器直接失败并返回
// 400,与「被限流拒绝」撞码 —— 那样这条判据是假的(改与不改都绿)。
// 所以断言大请求体在解码前就被挡下,即 413。
func TestLoginBodySizeLimited(t *testing.T) {
h := newAuthTestHandler(t)
body := make([]byte, 8<<20)
for i := range body {
body[i] = 'a'
}
rec := postLoginRaw(t, h, body)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("超大请求体应返回 413,实际 %d(body=%.120s)—— "+
"若为 400 说明只是解码失败而非体积限制,判据无效", rec.Code, rec.Body.String())
}
if code := tryLogin(h, "admin", testAuthPassword); code != http.StatusOK {
t.Errorf("正常登录被体积限制误伤,实际 %d", code)
}
}
// 失败原因不得可区分:不同失败给不同状态码或报文 = 可枚举用户名。
func TestLoginNoUsernameEnumeration(t *testing.T) {
h := newAuthTestHandler(t)
badUser := postLoginFrom(t, h, "203.0.113.1", "no-such-user-xyz", "whatever")
badPass := postLoginFrom(t, h, "198.51.100.7", "admin", "wrong")
if badUser.Code != badPass.Code {
t.Errorf("不同失败原因返回不同状态码(%d vs %d),可用于枚举用户名",
badUser.Code, badPass.Code)
}
if badUser.Body.String() != badPass.Body.String() {
t.Errorf("不同失败原因返回不同响应体,可用于枚举用户名:\n 用户不存在: %s\n 口令错误 : %s",
badUser.Body.String(), badPass.Body.String())
}
}
// 限流必须是**有界**的:过期记录要被清掉,否则攻击者轮换来源 IP
// 就能把 map 喂成内存泄漏。
func TestLoginLimiterPrunesExpiredKeys(t *testing.T) {
l := newLoginLimiter(loginMaxFails, loginWindow)
for i := 0; i < 500; i++ {
l.Fail("src-" + strconv.Itoa(i))
}
if n := l.liveKeys(); n != 500 {
t.Errorf("记录数 = %d,期望 500", n)
}
// 推进到窗口之后并触发清理
l.clockAdvance(loginWindow + time.Minute)
l.prune()
if n := l.liveKeys(); n != 0 {
t.Errorf("过期后仍残留 %d 条记录 —— 轮换 IP 即可无限增长(内存泄漏)", n)
}
}
// 计时器替代方案:验证 Allow 返回的重试时长不为 0。
// 返回 0 会让客户端立即重试 —— 那等于没有限流。
func TestLoginLimiterRetryNeverZero(t *testing.T) {
l := newLoginLimiter(2, time.Hour)
l.Fail("k")
l.Fail("k")
ok, retry := l.Allow("k")
if ok {
t.Fatal("达到阈值后应被限流")
}
if retry <= 0 {
t.Errorf("Retry-After 时长 = %v,必须为正(返回 0 会让客户端立即重试)", retry)
}
}
// ===== 限流的来源识别:穿透部署下不能把所有人算成一个 =====
//
// ★ 这是我在生产上亲手踩出来的:加了按 IP 限流后,跑 8 次错误登录做验证,
// 结果**把管理员自己锁在外面 10 分钟**。
//
// 原因:webui 经 frp/nginx 穿透到公网,所有外部请求的 RemoteAddr 都是
// 127.0.0.1(日志实证:from=127.0.0.1 status=429)。于是所有人共用一个桶,
// 任何人爆破 5 次,**所有人**(含管理员)一起被锁 —— 限流反而成了 DoS。
//
// 正确做法不是「不信 XFF」(那正是我第一版的做法,会退化成全局限流),
// 而是:**只信任来自受信反代的 X-Forwarded-For**。受信判定不能靠 IP 名单
// 猜(穿透场景下反代就在本机 127.0.0.1),得由配置显式声明。
// 经受信反代时,必须按 XFF 里的真实客户端 IP 计数。
func TestLoginRateLimitUsesForwardedForFromTrustedProxy(t *testing.T) {
h := newAuthTestHandler(t)
h.trustedProxies = []string{"127.0.0.1/32", "::1/128"}
// 攻击者(XFF 声明的来源)狂刷
for i := 0; i < 30; i++ {
tryLoginXFF(h, "203.0.113.66", "admin", "bad")
}
// 受害者:不同的 XFF 声明 + 正确口令 → 不该被牵连
if code := tryLoginXFF(h, "198.51.100.23", "admin", testAuthPassword); code != http.StatusOK {
t.Errorf("受信反代下,不同真实客户端被牵连(限流退化成全局),实际 %d", code)
}
}
// 不受信来源的 XFF 必须被忽略:否则任何人都能换一个头就绕过限流
// (甚至把限流当成打别人来源的武器)。
func TestLoginRateLimitIgnoresUntrustedForwardedFor(t *testing.T) {
h := newAuthTestHandler(t)
// 显式配置为「无受信反代」
h.trustedProxies = nil
for i := 0; i < 30; i++ {
tryLoginXFF(h, "203.0.113.66", "admin", "bad")
}
// 换一个 XFF 继续试:来源未被认可,应仍然被限流
if code := tryLoginXFF(h, "198.51.100.23", "admin", "bad"); code != http.StatusTooManyRequests {
t.Errorf("换 XFF 头就绕过了限流,实际 %d —— 说明采信了不可信的 XFF", code)
}
}
// 反代在**同一台机器**上(穿透部署的常态)时,若未配置受信反代,
// 必须仍能识别不同客户端 —— 否则默认配置就把限流变成了全局锁。
// 判据:未配置时退化到「有 XFF 就用第一个非内网地址」?不行 —— 那等于
// 无条件采信。所以这里钉的是另一个行为:**必须显式配置才能生效**,
// 且未配置时的行为要与「无反代」场景一致(全部算同一个来源)。
func TestLoginRateLimitNeedsExplicitTrustedProxyConfig(t *testing.T) {
h := newAuthTestHandler(t)
h.trustedProxies = nil // 未配置
// 未配置 = 不采信 XFF ⇒ 两个不同 XFF 视为同一来源(127.0.0.1)
for i := 0; i < 6; i++ {
tryLoginXFF(h, "203.0.113.66", "admin", "bad")
}
if code := tryLoginXFF(h, "198.51.100.23", "admin", "bad"); code != http.StatusTooManyRequests {
t.Errorf("未配置受信反代时,XFF 不应被采信(应视为同一来源),实际 %d", code)
}
}
// tryLoginXFF 带 X-Forwarded-For 的登录。
func tryLoginXFF(h *Handler, xff, user, pass string) int {
body := `{"username":"` + user + `","password":"` + pass + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.RemoteAddr = "127.0.0.1:54321" // 穿透场景:反代在本机
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", xff)
rec := httptest.NewRecorder()
h.Handler().ServeHTTP(rec, req)
return rec.Code
}
// parseTrustedProxies 解析要稳:合法项接受,非法项被丢弃(而不是让整份
// 配置静默失效)。
func TestParseTrustedProxies(t *testing.T) {
got := parseTrustedProxies(" 127.0.0.1 , 10.0.0.0/8 ,, ::1 ")
if len(got) != 3 {
t.Errorf("应解析出 3 项,实际 %d: %v", len(got), got)
}
// 非法项被丢弃
got = parseTrustedProxies("127.0.0.1,999.999.999.999,10.0.0.0/33")
if len(got) != 1 || got[0] != "127.0.0.1" {
t.Errorf("非法项未被丢弃,实际 %v", got)
}
// 空串 → nil(不采信任何 XFF)
if parseTrustedProxies(" ") != nil {
t.Error("空白配置应返回 nil(保守默认:不采信 XFF)")
}
}
// ===== Server 超时:Slowloris 防护,但不能误杀流式 =====
//
// http.Server 原本**一个超时都没设**(只有 Handler)。后果是 Slowloris:
// 攻击者只占连接不发完整请求头,每个连接挂几 KB,Go 默认不主动断
// (MaxHeaderBytes 限了头部大小,但「慢慢发」不占头部大小),
// 几百个连接就能耗尽 fd。
//
// ★ 但不能图省事直接加 WriteTimeout:webui 有一条**长连接** SSE
// (/api/v1/chat/events)与流式 /v1/chat/completions(可跑 300s)。
// WriteTimeout 是**从请求开始到响应写完**的总预算,会把它们全部腰斩
// (表现为 SSE 每 30s 断一次、前端疯狂重连)。
//
// 判据钉住该设的与不该设的。
func TestServerHasReadSideTimeouts(t *testing.T) {
p := newServerForTest(t)
srv := p.server
if srv == nil {
t.Fatal("server 未初始化")
}
// ReadHeaderTimeout 是 Slowloris 的正解:头在规定时间内没发完就断。
if srv.ReadHeaderTimeout <= 0 {
t.Errorf("ReadHeaderTimeout = %v,必须 > 0(无此值时 Slowloris 可挂住连接)",
srv.ReadHeaderTimeout)
}
// IdleTimeout 覆盖 keep-alive 空闲连接(ReadHeaderTimeout 管不到)。
if srv.IdleTimeout <= 0 {
t.Errorf("IdleTimeout = %v,必须 > 0(keep-alive 空闲连接会无限累积)", srv.IdleTimeout)
}
// ReadTimeout 限制「读完整请求」的时间(含 body),防慢速上传。
if srv.ReadTimeout <= 0 {
t.Errorf("ReadTimeout = %v,必须 > 0(慢速上传会长期占用连接)", srv.ReadTimeout)
}
}
// ★ 反向判据:WriteTimeout 必须为 0(保持流式不被腰斩)。
// 这是「不该设的超时」,同样要钉住 —— 否则将来有人「顺手补全」就把
// SSE 与流式端点弄坏了,而这类回归在功能测试里很难立刻发现。
func TestServerHasNoWriteTimeout(t *testing.T) {
p := newServerForTest(t)
if got := p.server.WriteTimeout; got != 0 {
t.Errorf("WriteTimeout = %v,应为 0 —— 它会腰斩 SSE(/api/v1/chat/events)"+
"与流式 /v1/chat/completions(可跑 300s),表现为 SSE 每隔一段时间断一次",
got)
}
}
// SSE 端点必须真的能长时间保持连接(判据的正面一侧)。
// 短于 WriteTimeout 的观察窗口即可(不需要真等 30s)。
func TestSSEConnectionSurvivesBeyondReadTimeout(t *testing.T) {
srv, _, _ := newOpenAITestServer(t)
// 连上 SSE,观察它至少活过 ReadHeaderTimeout(证明没有被读侧超时误杀)
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/v1/chat/events", nil)
req.Header.Set("X-API-Key", testAuthAPIKey)
req.Header.Set("Accept", "text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("SSE 连接失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("SSE 应 200,实际 %d", resp.StatusCode)
}
// 试着读一点:能读到(哪怕是心跳/注释行)说明连接是活的
rd := bufio.NewReader(resp.Body)
done := make(chan bool, 1)
go func() {
_, err := rd.ReadString('\n')
done <- err == nil
}()
select {
case ok := <-done:
if !ok {
t.Error("SSE 首读即失败(连接被立即关闭)")
}
case <-time.After(5 * time.Second):
// 没数据也算活:SSE 空闲时不发帧是正常的,关键是连接没断。
_ = resp.Body.Close()
}
}
// newServerForTest 起一个 webui 插件实例(走真实 Start),用于检查 server 配置。
func newServerForTest(t *testing.T) *Plugin {
t.Helper()
cfgReg := internalConfig.NewConfigRegistry("")
seedWebUIConfig(cfgReg)
// 绑到空闲端口:绝不能用 :8080,那是生产端口(见 a752ae1 的教训)
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := probe.Addr().String()
probe.Close()
cfgReg.PluginConfig("webui").Set("addr", addr)
p := &Plugin{name: "webui", mux: http.NewServeMux()}
s := testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", cfgReg)})
if err := p.Start(s); err != nil {
t.Fatalf("启动 webui 失败: %v", err)
}
t.Cleanup(func() { _ = p.Stop() })
return p
}