miguan v1
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/flamego/flamego"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
)
|
||||
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
// - 真正的 404 探测由 f.NotFound 处理器单独计数(见 route.go),
|
||||
// 不再把业务性的"302 → /"(语言切换 / 登出 / 投票不存在 / 黑名单跳转等)误判为扫描。
|
||||
// - 核心检测逻辑见 internal/security/autoban(可独立测试);告警发送函数由本层注入。
|
||||
func autobanMiddleware(b *autoban.Banner) flamego.Handler {
|
||||
func autobanMiddleware(b *autoban.Banner, hp *honeypot.Honeypot) flamego.Handler {
|
||||
return func(ctx flamego.Context) {
|
||||
// 经 shared.LimitKey 归一化后再计数与判定:IPv4 原样,IPv6 聚合到 /64。
|
||||
// 否则攻击者可在单个 /64 内轮换地址,使每个键只命中一次而永不触发阈值。
|
||||
@@ -32,8 +33,12 @@ func autobanMiddleware(b *autoban.Banner) flamego.Handler {
|
||||
|
||||
path := ctx.Request().URL.Path
|
||||
kind := autoban.KindNone
|
||||
if autoban.IsSensitivePath(path) {
|
||||
kind = autoban.KindSensitive
|
||||
// 蜜罐命中的路径由蜜罐处理器自己计数(它需要同时知道「是否触发了封禁」
|
||||
// 以便落库)。若这里再记一次,同一次请求会被计两遍,阈值实际减半。
|
||||
if hp == nil || !hp.Match(path) {
|
||||
if autoban.IsSensitivePath(path) {
|
||||
kind = autoban.KindSensitive
|
||||
}
|
||||
}
|
||||
if kind != autoban.KindNone {
|
||||
defer b.Record(ip, path, kind)
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/security/ipinfo"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
)
|
||||
|
||||
// honeypotHandler 蜜罐处理器:只在「无任何路由匹配」的路径上生效。
|
||||
//
|
||||
// 与真实业务路由(/login、/register、/_/{domain}、/user/**、/api/v1/**)天然零交集,
|
||||
// 因为它挂在 f.NotFound 上 —— flamego 只有在路由表完全落空时才会走到这里。
|
||||
//
|
||||
// 行为:
|
||||
// - GET → 返回仿 WordPress 的假登录页(200),让扫描器判定「后台存在」;
|
||||
// - POST → 解析并记录提交的用户名/密码,返回「用户名或密码无效」的假响应;
|
||||
// - 无论 GET/POST → 计入 autoban 敏感路径统计,达到阈值即临时封禁 + 邮件告警;
|
||||
// - 绝不调用 ctx.Next(),不查业务表、不渲染业务模板。
|
||||
type honeypotHandler struct {
|
||||
hp *honeypot.Honeypot
|
||||
ab *autoban.Banner
|
||||
// store 允许为 nil(单测 / 未装配 DB 时降级为只记录日志)。
|
||||
store db.HoneypotStore
|
||||
// sendAlert 发送「捕获到破解尝试」的告警邮件。nil 则不发。
|
||||
sendAlert func(mail.HoneypotAlert) error
|
||||
// alerted 保证同一个 IP 只告警一次(与 autoban 的告警去重口径一致),
|
||||
// 避免暴力破解脚本在封禁前反复提交把邮箱打爆。
|
||||
alerted map[string]bool
|
||||
alertedMu sync.Mutex
|
||||
}
|
||||
|
||||
// newHoneypotHandler 构造蜜罐处理器。
|
||||
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore) *honeypotHandler {
|
||||
return &honeypotHandler{
|
||||
hp: hp,
|
||||
ab: ab,
|
||||
store: store,
|
||||
alerted: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// Serve 处理一次蜜罐命中。
|
||||
func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
||||
req := ctx.Request().Request
|
||||
ip := shared.LimitKey(shared.ResolveClientIP(req))
|
||||
path := req.URL.Path
|
||||
|
||||
// 记录与封禁:走既有 autoban 链路,复用阈值封禁与告警。
|
||||
banned := false
|
||||
if h.ab != nil && ip != "" {
|
||||
h.ab.Record(ip, path, autoban.KindSensitive)
|
||||
// Record 内部在触发封禁时把 IP 写入 banned 表;此处再查一次得到确切状态。
|
||||
banned = h.ab.IsBanned(ip)
|
||||
}
|
||||
|
||||
// body 只能读一次:先提取凭据(POST 时读 body),再决定写哪种响应。
|
||||
creds := h.hp.ExtractCredentials(req)
|
||||
|
||||
switch req.Method {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
h.serveLoginPage(ctx, path)
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch:
|
||||
h.serveLoginFailure(ctx, path, honeypot.LoginFailureNotice)
|
||||
default:
|
||||
// 其他方法(PUT/DELETE/OPTIONS 等)返回一个无内容的假 200,模拟后端存在。
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// 落库留证。落库失败只记日志,绝不影响已写出的响应。
|
||||
h.record(ip, req, path, creds, banned)
|
||||
}
|
||||
|
||||
// serveLoginPage 返回假登录页。
|
||||
func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
|
||||
redir := ""
|
||||
if q := ctx.Request().URL.Query().Get("redirect_to"); q != "" {
|
||||
redir = q
|
||||
}
|
||||
honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{
|
||||
Action: path,
|
||||
Title: "Log In ‹ WordPress",
|
||||
Heading: "WordPress",
|
||||
Redir: redir,
|
||||
})
|
||||
}
|
||||
|
||||
// serveLoginFailure 返回「登录失败」的假响应。
|
||||
//
|
||||
// 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误),
|
||||
// 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。
|
||||
func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string, notice string) {
|
||||
body := honeypot.RenderLoginPage(honeypot.LoginPageVars{
|
||||
Action: path,
|
||||
Title: "Log In \u2039 WordPress",
|
||||
Heading: "WordPress",
|
||||
Notice: notice,
|
||||
Redir: ctx.Request().URL.Query().Get("redirect_to"),
|
||||
})
|
||||
w := ctx.ResponseWriter()
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// record 追加一条命中记录。
|
||||
func (h *honeypotHandler) record(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
|
||||
h.recordCredentials(ip, req, path, creds, banned)
|
||||
}
|
||||
|
||||
// recordCredentials 把一次命中写入数据库。
|
||||
func (h *honeypotHandler) recordCredentials(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
|
||||
provider, proxied := ipinfo.Lookup(ip)
|
||||
|
||||
fieldsJSON := ""
|
||||
if len(creds.Fields) > 0 {
|
||||
if b, err := json.Marshal(creds.Fields); err == nil {
|
||||
fieldsJSON = string(b)
|
||||
}
|
||||
}
|
||||
|
||||
query := ""
|
||||
if req.URL != nil {
|
||||
query = req.URL.RawQuery
|
||||
if len(query) > 1000 {
|
||||
query = query[:1000]
|
||||
}
|
||||
}
|
||||
|
||||
hit := &db.HoneypotHit{
|
||||
IP: ip,
|
||||
Method: req.Method,
|
||||
Path: path,
|
||||
Query: query,
|
||||
Username: creds.Username,
|
||||
Password: creds.Password,
|
||||
Fields: fieldsJSON,
|
||||
RawBody: creds.RawBody,
|
||||
HadFields: creds.HadFields,
|
||||
UserAgent: truncate(req.UserAgent(), 500),
|
||||
Referer: truncate(req.Referer(), 500),
|
||||
ContentType: truncate(req.Header.Get("Content-Type"), 250),
|
||||
Provider: provider,
|
||||
Proxied: proxied,
|
||||
Banned: banned,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 只有真的提交了凭据才告警:纯路径探测(GET)没有取证价值,
|
||||
// 而爆破脚本往往在封禁前反复 POST,只告警一次即可。
|
||||
if creds.HadFields {
|
||||
h.alert(ip, req, path, creds, provider, proxied, banned)
|
||||
}
|
||||
|
||||
// 有凭据的提交才是高价值证据,日志里点名;纯探测只记路径。
|
||||
if creds.HadFields {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"ip": ip,
|
||||
"path": path,
|
||||
"username": creds.Username,
|
||||
"banned": banned,
|
||||
}).Warn("honeypot: credential submission captured")
|
||||
}
|
||||
|
||||
if h.store == nil {
|
||||
return
|
||||
}
|
||||
// 用独立超时上下文,避免请求被取消时取证写不进去。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := h.store.Create(ctx, hit); err != nil {
|
||||
logrus.WithError(err).WithField("ip", ip).WithField("path", path).
|
||||
Warn("honeypot: failed to persist hit")
|
||||
}
|
||||
}
|
||||
|
||||
// alert 发送一次告警邮件(每个 IP 只发一次)。
|
||||
func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) {
|
||||
if h.sendAlert == nil || ip == "" {
|
||||
return
|
||||
}
|
||||
h.alertedMu.Lock()
|
||||
if h.alerted[ip] {
|
||||
h.alertedMu.Unlock()
|
||||
return
|
||||
}
|
||||
h.alerted[ip] = true
|
||||
h.alertedMu.Unlock()
|
||||
|
||||
durationMin := conf.Security.BanDurationMinutes
|
||||
if durationMin <= 0 {
|
||||
durationMin = 15
|
||||
}
|
||||
go func() {
|
||||
if err := h.sendAlert(mail.HoneypotAlert{
|
||||
IP: ip,
|
||||
Provider: provider,
|
||||
Proxied: proxied,
|
||||
Path: path,
|
||||
Username: creds.Username,
|
||||
Password: creds.Password,
|
||||
Banned: banned,
|
||||
DurationMin: durationMin,
|
||||
}); err != nil {
|
||||
logrus.WithError(err).WithField("ip", ip).Warn("honeypot: failed to send alert mail")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// truncate 截断字符串到指定长度(按 rune,避免截断多字节字符)。
|
||||
func truncate(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// honeypotExtraPaths 解析配置中的自定义诱饵路径(逗号/分号/换行分隔)。
|
||||
func honeypotExtraPaths(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(f, "/") {
|
||||
f = "/" + f
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// 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"
|
||||
|
||||
"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()
|
||||
f := flamego.New()
|
||||
hp := honeypot.New(honeypot.Options{Enabled: enabled})
|
||||
ab := autoban.New(autoban.Options{
|
||||
Enabled: true,
|
||||
ThresholdSensitive: 1, // 命中一次即封禁,便于断言
|
||||
Window: 60,
|
||||
Duration: 60,
|
||||
})
|
||||
hph := newHoneypotHandler(hp, ab, nil) // store=nil → 只走日志,不落库
|
||||
|
||||
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("提交凭据后应返回「用户名或密码无效」的假提示")
|
||||
}
|
||||
}
|
||||
+38
-3
@@ -26,11 +26,13 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/captcha"
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/route/chat" // 私聊路由
|
||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/security/ipinfo"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||||
@@ -96,6 +98,19 @@ func New() *flamego.Flame {
|
||||
},
|
||||
})
|
||||
|
||||
// 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。
|
||||
// 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。
|
||||
hp := honeypot.New(honeypot.Options{
|
||||
Enabled: conf.Security.EnableHoneypot,
|
||||
ExtraPaths: honeypotExtraPaths(conf.Security.HoneypotPaths),
|
||||
})
|
||||
hph := newHoneypotHandler(hp, ab, db.Honeypots)
|
||||
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
|
||||
// 收件人复用 security_alert_email,不新增配置项。
|
||||
hph.sendAlert = func(a mail.HoneypotAlert) error {
|
||||
return mail.SendHoneypotAlertMail(a)
|
||||
}
|
||||
|
||||
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
|
||||
if err != nil {
|
||||
logrus.WithError(err).Fatal("Failed to embed templates file system")
|
||||
@@ -148,7 +163,7 @@ func New() *flamego.Flame {
|
||||
|
||||
f.Use(securityHeaders())
|
||||
f.Use(ipBlacklistMiddleware())
|
||||
f.Use(autobanMiddleware(ab))
|
||||
f.Use(autobanMiddleware(ab, hp))
|
||||
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
|
||||
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
|
||||
f.Use(requestBodyLimitMiddleware())
|
||||
@@ -430,8 +445,28 @@ func New() *flamego.Flame {
|
||||
context.Contexter(),
|
||||
)
|
||||
f.NotFound(func(ctx flamego.Context) {
|
||||
// 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。flamego 的 NotFound 会先
|
||||
// 写 404 状态再 Redirect,故须在 Redirect 前记录。
|
||||
// ── 蜜罐分流(优先)───────────────────────────────────────────────
|
||||
// 命中诱饵路径(/wp-admin、/.ssh、/.env 等后台探测特征)时,返回一个
|
||||
// 仿 WordPress 的假登录页,并把攻击者提交的凭据记录入库。
|
||||
//
|
||||
// 为什么挂在 NotFound 而不是全局中间件:NotFound 只在「路由表完全落空」
|
||||
// 时触发,因此与 /login、/register、/_/{domain}、/user/**、/api/v1/** 等
|
||||
// 真实业务路由天然零交集,不会劫持正常用户;且它位于全局 f.Use 链之后,
|
||||
// requestBodyLimitMiddleware 已对 body 施加上限,解析 POST body 安全。
|
||||
// 组级中间件(含 CSRF)只挂在具体路由上,不作用于 NotFound,故表单可解析。
|
||||
//
|
||||
// 短路:flamego 的 context.run() 在 ResponseWriter().Written() 为真时即
|
||||
// return,故此处写完响应且不调用 ctx.Next() 即可安全终止整条链路。
|
||||
if hp != nil && hp.Match(ctx.Request().URL.Path) {
|
||||
hph.Serve(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
// 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。
|
||||
// ⚠️ 更正:flamego 的 NotFound 处理器并不会自动写 404 状态码 ——
|
||||
// ResponseWriter.Write() 在未显式 WriteHeader 时写的是 200,
|
||||
// ctx.Redirect("/") 写的是 302 + Location: /。这里保持原有 302→/ 行为
|
||||
// (对扫描器而言,302 到首页同样表示「此路径不存在」)。
|
||||
// 键必须与 autobanMiddleware 的 IsBanned/Record 一致(同样经 LimitKey 聚合),
|
||||
// 否则计数落在另一个键上,封禁永不生效。
|
||||
// ⚠️ 计数受 ban_track_notfound 控制,默认关闭;Record 内部会忽略未开启的
|
||||
|
||||
Reference in New Issue
Block a user