Files
acfunlive-remake/webui_test.go
T
tamakyi c55fe3539d 口令失败记录加上硬上限,并且不再傻等到满 delay
审查发现这张表的容量可以被外部放大:IP 来自 r.RemoteAddr,是请求方能左右的输入。

1. 原来为什么会被写满
   清理逻辑只在 len > 1000 时才触发一次,而且只删「10 分钟没动静」的。
   换着来源持续撞口令时,每条记录的 last 都是刚刚更新的,于是「虽然超了
   1000 条但一条都不符合删除条件」,表现就是表不停往上涨。
   实测:3000 个不同来源各失败一次之后,表里 3000 条,一条没删。

2. 现在怎么收敛
   - 加硬上限 authFailMaxEntries(1 万条)。到上限立刻扫一次,
     只靠「每 256 条扫一次」的话两个周期之间涌进来的会把表顶破。
   - 扫表先删过期项,还多就按「最久没动静」排序淘汰。
   - 淘汰时留一成余量(淘汰到九成),这样满表之后还能再收一千来个
     新来源才需要排下一次,不会变成「每来一个就排一遍全表」。
   - 同一来源的失败次数封顶。延迟早就封顶了(2 秒),数字继续涨没有意义。

3. 延迟等待改成可以被断开
   原来在 handler 里直接 time.Sleep(最多 2 秒)。并发撞口令时每个请求各占一个
   goroutine 干等两秒,很容易把服务拖住。现在用 timer + r.Context():
   客户端一断开立刻返回,不再占着连接。

已验证:
- 新增 5 个用例:
  TestAuthFailTableHasHardCap(2 万个新来源撞完,表必须被压在 1 万以内)、
  TestAuthFailExpiredEntriesGetSwept(过期项扫干净)、
  TestAuthFailSweepsPeriodically(定期扫是自动的,不用调用方记着)、
  TestAuthFailCountCapped(同来源次数封顶、延迟封顶)、
  TestAwaitAuthDelayStopsOnCancel(上游取消后立刻返回,不能傻等满 2 秒)。
- 反向验证:把淘汰目标临时改成 authFailMaxEntries*1000(等于没上限),
  TestAuthFailTableHasHardCap 立刻报「应压在 10000 以内,实际 20000」。
- gofmt / go vet / go build / GOOS=linux go build / go test / go test -race 全过。
2026-09-26 03:00:37 +08:00

306 lines
9.6 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.
// web UI 相关
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
)
// 测试期间换一个管理口令
func withAuthToken(t *testing.T, token string) {
t.Helper()
streamers.Lock()
old := config.AuthToken
config.AuthToken = token
streamers.Unlock()
t.Cleanup(func() {
streamers.Lock()
config.AuthToken = old
streamers.Unlock()
})
}
// 页面上发给服务端的 X-Auth-Token 是 encodeURIComponent 过的口令,
// 这里用 url.QueryEscape 造出同样的形状(对中文而言两者产出一致)
func pageHeader(r *http.Request, token string) *http.Request {
r.Header.Set("X-Auth-Token", url.QueryEscape(token))
return r
}
func TestCheckAuthTokenPlainASCII(t *testing.T) {
withAuthToken(t, "a1b2c3d4e5f6")
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "a1b2c3d4e5f6")
if !checkAuthToken(r) {
t.Fatal("ASCII 口令应该通过")
}
// 带 % 的口令:页面会编码成 %25,服务端解码后要能对上
withAuthToken(t, "ab%20cd")
r = pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "ab%20cd")
if r.Header.Get("X-Auth-Token") != "ab%2520cd" {
t.Fatalf("编码结果不对:%q", r.Header.Get("X-Auth-Token"))
}
if !checkAuthToken(r) {
t.Fatal("含 % 的口令应该通过")
}
}
func TestCheckAuthTokenNonASCII(t *testing.T) {
// 中文口令是最容易踩的:原先直接把中文塞进请求头,浏览器会抛
// TypeError(Failed to read the 'headers' property…),请求根本发不出去,
// 页面上只显示「无法连接到服务器」,用户被彻底锁在门外
withAuthToken(t, "我的口令abc123")
r := pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "我的口令abc123")
if r.Header.Get("X-Auth-Token") == "我的口令abc123" {
t.Fatal("测试前提不成立:头里不该是原文")
}
if !checkAuthToken(r) {
t.Fatal("编码后的中文口令应该通过")
}
// 手写 curl 按文档原样发中文口令,也要能通过
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "我的口令abc123")
if !checkAuthToken(r) {
t.Fatal("原样的中文口令也应该通过")
}
}
func TestCheckAuthTokenAllSources(t *testing.T) {
withAuthToken(t, "口令abc123")
// Authorization: Bearer
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("Authorization", "Bearer "+url.QueryEscape("口令abc123"))
if !checkAuthToken(r) {
t.Fatal("Bearer 写法应该通过")
}
// ?token=(net/http 已经把查询串解码好了)
r = httptest.NewRequest(http.MethodGet, "/api/status?token="+url.QueryEscape("口令abc123"), nil)
if !checkAuthToken(r) {
t.Fatal("查询参数写法应该通过")
}
// 下载链接里的 ?token= 也是这个路径,混着 % 的口令同样要对上
withAuthToken(t, "口令%20abc")
r = httptest.NewRequest(http.MethodGet, "/api/file?token="+url.QueryEscape("口令%20abc"), nil)
if !checkAuthToken(r) {
t.Fatal("查询参数里的混合口令应该通过")
}
}
func TestCheckAuthTokenRejects(t *testing.T) {
withAuthToken(t, "正确的口令")
cases := map[string]*http.Request{
"口令错了": func() *http.Request {
return pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "错误的口令")
}(),
"没带口令": httptest.NewRequest(http.MethodGet, "/api/status", nil),
"空口令头": func() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "")
return r
}(),
"编码了但不是口令": func() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "%E5%8F%A3%E4%BB%A4")
return r
}(),
}
for name, r := range cases {
if checkAuthToken(r) {
t.Fatalf("%s:不该通过", name)
}
}
// 口令没配时一律拒绝,不能因为「都是空」就放行
withAuthToken(t, "")
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "")
if checkAuthToken(r) {
t.Fatal("口令为空时不该通过")
}
}
func TestAuthTokenCandidatesOrder(t *testing.T) {
// 三种写法同时出现时按 Bearer → X-Auth-Token → ?token= 的顺序给候选,
// 顺序本身不影响结果,但别写出重复项(重复比较纯属浪费)
r := httptest.NewRequest(http.MethodGet, "/api/status?token=qqq", nil)
r.Header.Set("Authorization", "Bearer aaa")
r.Header.Set("X-Auth-Token", "bbb")
got := authTokenCandidates(r)
want := []string{"aaa", "bbb", "qqq"}
if len(got) != len(want) {
t.Fatalf("候选数量不对:%q", got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("候选顺序不对:%q,想要 %q", got, want)
}
}
// Authorization 不是 Bearer 前缀时不算数
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("Authorization", "Basic zzz")
r.Header.Set("X-Auth-Token", "bbb")
got = authTokenCandidates(r)
if len(got) != 1 || got[0] != "bbb" {
t.Fatalf("非 Bearer 的 Authorization 不该当候选:%q", got)
}
}
// ==================== 口令错误记录的容量 ====================
// IP 是外部给的:只靠「过期清理」挡不住换着地址撞口令的情况,
// 必须要有硬上限。这些用例锁住这一点。
// 换着来源持续撞口令,表必须被压在上限以内,不能一直涨
func TestAuthFailTableHasHardCap(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
// 每个都是新来源、且都是「刚刚」失败过 —— 过期清理一条都删不掉的情况
for i := 0; i < authFailMaxEntries*2; i++ {
authFailDelay(fmt.Sprintf("198.51.100.%d", i))
}
got := 0
authFail.Lock()
got = len(authFail.m)
authFail.Unlock()
if got > authFailMaxEntries {
t.Errorf("记录数应被压在上限 %d 以内,实际 %d(换 IP 撞口令会把内存写满)",
authFailMaxEntries, got)
}
t.Logf(" %d 个新来源撞完,表内留下 %d 条(上限 %d)", authFailMaxEntries*2, got, authFailMaxEntries)
}
// 超过过期时间的记录要被扫掉,不是在表里一直躺着
func TestAuthFailExpiredEntriesGetSwept(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
for i := 0; i < authFailSweepEvery+5; i++ {
authFailDelay(fmt.Sprintf("203.0.113.%d", i))
}
authFail.Lock()
before := len(authFail.m)
// 把记录的最后时间推到过期之前
for k, v := range authFail.m {
authFail.m[k] = authFailEntry{count: v.count, last: time.Now().Add(-2 * authFailExpire)}
}
authFail.Unlock()
sweepAuthFail(time.Now())
authFail.Lock()
after := len(authFail.m)
authFail.Unlock()
if after != 0 {
t.Errorf("已经全部过期的记录应被扫干净,扫之前 %d 条,扫之后 %d 条", before, after)
}
}
// 扫表必须是自动发生的:不要求调用方记着手动清理,
// 混在里面的过期项应该在若干次记录之后自己消失
func TestAuthFailSweepsPeriodically(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
// 直接往里塞一条早就过期了的记录(绕过 authFailDelay,模拟「以前留下的」)
authFail.Lock()
if authFail.m == nil {
authFail.m = make(map[string]authFailEntry)
}
authFail.m["203.0.113.99"] = authFailEntry{count: 1, last: time.Now().Add(-2 * authFailExpire)}
authFail.Unlock()
// 之后只要还在有人失败,过一阵子它就应该被清掉
for i := 0; i < authFailSweepEvery+1; i++ {
authFailDelay(fmt.Sprintf("198.51.100.%d", i))
}
authFail.Lock()
_, still := authFail.m["203.0.113.99"]
authFail.Unlock()
if still {
t.Errorf("记了 %d 次之后,过期记录应当已经被自动扫掉", authFailSweepEvery+1)
}
}
// 同一个来源的计数不能无限涨(临界点是延迟早已封顶,再涨只是让数字溢出)
func TestAuthFailCountCapped(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
for i := 0; i < authFailMaxCount+50; i++ {
authFailDelay("192.0.2.9")
}
authFail.Lock()
c := authFail.m["192.0.2.9"].count
authFail.Unlock()
if c > authFailMaxCount {
t.Errorf("同一来源的次数应封顶在 %d,实际 %d", authFailMaxCount, c)
}
if d := authFailDelay("192.0.2.9"); d != authMaxDelay {
t.Errorf("打满之后延迟应封顶在 %v,实际 %v", authMaxDelay, d)
}
}
// 客户端断开时延迟等待必须立刻返回,不能傻等满 2 秒
func TestAwaitAuthDelayStopsOnCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(80 * time.Millisecond)
cancel()
}()
start := time.Now()
waited := awaitAuthDelay(ctx, 2*time.Second)
used := time.Since(start)
if waited {
t.Error("上游取消时 awaitAuthDelay 应返回 false")
}
if used > time.Second {
t.Errorf("上游取消后应立刻返回,实际等了 %v", used)
}
// 正常等待不受影响
if !awaitAuthDelay(context.Background(), 10*time.Millisecond) {
t.Error("没被取消时应当正常等完再返回 true")
}
}