- autoban: 新增 RecordHoneypot 独立通道(不受 enabled 约束)+ 独立阈值/时长; IsBanned 不再因 enable_auto_ban=false 而忽略已有封禁 - honeypot: 新增 honeypotSuffixes 后缀匹配,覆盖 /backup.zip、/db.sql、/id_rsa.pem - 新增配置 honeypot_threshold / honeypot_ban_minutes - 补测试:autoban/honeypot_test.go、route 侧禁用自动封禁仍封禁
237 lines
7.8 KiB
Go
237 lines
7.8 KiB
Go
// Copyright 2026 tamakyi. All rights reserved.
|
|
// Use of this source code is governed by a MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package route
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/flamego/flamego"
|
|
|
|
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
|
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
|
)
|
|
|
|
// newHoneypotTestServer 造一个只含蜜罐 NotFound 分流的最小 flamego 实例。
|
|
//
|
|
// 刻意不调用 route.New()(那会拉起 DB/Redis/session 依赖),而是复刻它的
|
|
// 关键结构:全局 Use 链 → 具体路由 → NotFound 蜜罐分流。
|
|
func newHoneypotTestServer(t *testing.T, enabled bool) *httptest.Server {
|
|
t.Helper()
|
|
return newHoneypotTestServerWith(t, enabled, true)
|
|
}
|
|
|
|
// newHoneypotTestServerWith 可分别控制「蜜罐开关」与「常规自动封禁开关」,
|
|
// 用于验证两者相互独立(见 autoban.Options.HoneypotEnabled)。
|
|
func newHoneypotTestServerWith(t *testing.T, enabled, autoBan bool) *httptest.Server {
|
|
t.Helper()
|
|
f := flamego.New()
|
|
hp := honeypot.New(honeypot.Options{Enabled: enabled})
|
|
ab := autoban.New(autoban.Options{
|
|
Enabled: autoBan,
|
|
HoneypotEnabled: enabled,
|
|
ThresholdSensitive: 1, // 命中一次即封禁,便于断言
|
|
Window: 60 * time.Second,
|
|
Duration: 60 * time.Minute, // ⚠️ 必须是 time.Duration:写 60 是 60 纳秒,封禁瞬间即失效
|
|
})
|
|
hph := newHoneypotHandler(hp, ab, nil) // store=nil → 只走日志,不落库
|
|
|
|
// 与 internal/route/route.go 一致:自动封禁中间件挂在全局 Use 链,
|
|
// 在业务路由与 NotFound 之前 —— 它负责检查「是否已封禁」并返回 403。
|
|
f.Use(autobanMiddleware(ab, hp))
|
|
|
|
f.Get("/login", func(ctx flamego.Context) {
|
|
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
|
_, _ = ctx.ResponseWriter().Write([]byte("REAL_LOGIN"))
|
|
})
|
|
f.Get("/api/v1/questions", func(ctx flamego.Context) {
|
|
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
|
_, _ = ctx.ResponseWriter().Write([]byte("REAL_API"))
|
|
})
|
|
|
|
f.NotFound(func(ctx flamego.Context) {
|
|
if hp.Match(ctx.Request().URL.Path) {
|
|
hph.Serve(ctx)
|
|
return
|
|
}
|
|
ctx.Redirect("/")
|
|
})
|
|
|
|
ts := httptest.NewServer(f)
|
|
t.Cleanup(ts.Close)
|
|
return ts
|
|
}
|
|
|
|
// TestHoneypotServesFakeLoginPage 验证:命中诱饵路径时返回假登录页(含 canary),
|
|
// 而不是 302 跳首页。
|
|
func TestHoneypotServesFakeLoginPage(t *testing.T) {
|
|
ts := newHoneypotTestServer(t, true)
|
|
|
|
resp, err := http.Get(ts.URL + "/wp-login.php")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
|
t.Errorf("Content-Type = %q, want text/html", ct)
|
|
}
|
|
if !strings.Contains(string(body), honeypot.CanaryToken) {
|
|
t.Error("假登录页缺少 canary 标记,无法溯源")
|
|
}
|
|
if !strings.Contains(string(body), `name="log"`) || !strings.Contains(string(body), `name="pwd"`) {
|
|
t.Error("假登录页应使用 WordPress 经典字段名 log / pwd")
|
|
}
|
|
if strings.Contains(string(body), "REAL_LOGIN") {
|
|
t.Error("假登录页不应包含真实业务内容")
|
|
}
|
|
}
|
|
|
|
// TestHoneypotDoesNotHijackRealRoutes 是红线测试:真实业务路由绝不能被蜜罐接管。
|
|
func TestHoneypotDoesNotHijackRealRoutes(t *testing.T) {
|
|
ts := newHoneypotTestServer(t, true)
|
|
|
|
for path, want := range map[string]string{
|
|
"/login": "REAL_LOGIN",
|
|
"/api/v1/questions": "REAL_API",
|
|
} {
|
|
resp, err := http.Get(ts.URL + path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if !strings.Contains(string(body), want) {
|
|
t.Errorf("%s 返回了 %q,期望包含 %q(业务路由被劫持)", path, truncate(string(body), 80), want)
|
|
}
|
|
if strings.Contains(string(body), honeypot.CanaryToken) {
|
|
t.Errorf("%s 命中了蜜罐,业务路由被劫持", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHoneypotDisabledFallsBackToRedirect 验证:蜜罐关闭时行为与原先一致(302 → /)。
|
|
func TestHoneypotDisabledFallsBackToRedirect(t *testing.T) {
|
|
ts := newHoneypotTestServer(t, false)
|
|
|
|
client := &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
resp, err := client.Get(ts.URL + "/wp-login.php")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusFound {
|
|
t.Errorf("status = %d, want 302", resp.StatusCode)
|
|
}
|
|
if loc := resp.Header.Get("Location"); loc != "/" {
|
|
t.Errorf("Location = %q, want /", loc)
|
|
}
|
|
}
|
|
|
|
// TestHoneypotCapturesCredentials 验证:POST 提交的用户名/密码能被提取出来。
|
|
func TestHoneypotCapturesCredentials(t *testing.T) {
|
|
hp := honeypot.New(honeypot.Options{Enabled: true})
|
|
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
|
|
strings.NewReader("log=admin&pwd=hunter2&redirect_to=%2Fwp-admin%2F"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
creds := hp.ExtractCredentials(req)
|
|
if creds.Username != "admin" {
|
|
t.Errorf("Username = %q, want admin", creds.Username)
|
|
}
|
|
if creds.Password != "hunter2" {
|
|
t.Errorf("Password = %q, want hunter2", creds.Password)
|
|
}
|
|
}
|
|
|
|
// TestHoneypotPostReturnsFailurePage 验证:POST 后返回 200 的假登录页并带错误提示。
|
|
func TestHoneypotPostReturnsFailurePage(t *testing.T) {
|
|
ts := newHoneypotTestServer(t, true)
|
|
|
|
resp, err := http.Post(ts.URL+"/wp-login.php",
|
|
"application/x-www-form-urlencoded",
|
|
strings.NewReader("log=admin&pwd=admin"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if !strings.Contains(string(body), honeypot.LoginFailureNotice) {
|
|
t.Error("提交凭据后应返回「用户名或密码无效」的假提示")
|
|
}
|
|
}
|
|
|
|
// TestHoneypotBansEvenWhenAutoBanDisabled 是本次线上实测发现缺陷的回归测试。
|
|
//
|
|
// 现象:蜜罐命中 70+ 次,站点一次都没封禁。
|
|
// 根因:蜜罐复用了 autoban.Record,而 Record 受 enable_auto_ban 开关约束 ——
|
|
// 运维关掉自动封禁(它对共享出口误伤多)就连带让蜜罐只记录不封禁。
|
|
// 修复:蜜罐走独立的 RecordHoneypot 通道,只看 enable_honeypot。
|
|
func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
|
|
// 蜜罐开、常规自动封禁关 —— 这正是线上触发问题的组合
|
|
ts := newHoneypotTestServerWith(t, true, false)
|
|
|
|
// 第一次命中蜜罐
|
|
resp, err := http.Get(ts.URL + "/wp-login.php")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("首次命中 status = %d, want 200(应先返回假登录页)", resp.StatusCode)
|
|
}
|
|
|
|
// 第二次:应已被蜜罐封禁 → 403
|
|
resp2, err := http.Get(ts.URL + "/wp-login.php")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp2.Body.Close()
|
|
body, _ := io.ReadAll(resp2.Body)
|
|
if resp2.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403(蜜罐封禁不得被 enable_auto_ban 连坐)", resp2.StatusCode)
|
|
}
|
|
if !strings.Contains(string(body), "403 Forbidden") {
|
|
t.Errorf("body = %q, want 含 \"403 Forbidden\"", truncate(string(body), 80))
|
|
}
|
|
}
|
|
|
|
// TestHoneypotBanBlocksBusinessRoutes 验证:被蜜罐封禁的 IP 连正常业务路由也进不去。
|
|
func TestHoneypotBanBlocksBusinessRoutes(t *testing.T) {
|
|
ts := newHoneypotTestServerWith(t, true, false)
|
|
|
|
resp, err := http.Get(ts.URL + "/wp-login.php")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
resp2, err := http.Get(ts.URL + "/login")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp2.Body.Close()
|
|
if resp2.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403(被封禁的 IP 不应还能访问业务路由)", resp2.StatusCode)
|
|
}
|
|
}
|