- 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 侧禁用自动封禁仍封禁
264 lines
8.3 KiB
Go
264 lines
8.3 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 (
|
||
"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 的蜜罐独立通道(RecordHoneypot)。
|
||
//
|
||
// 刻意不用常规的 Record:它受 enable_auto_ban 开关约束,
|
||
// 而蜜罐是显式开启的功能 —— 开了就该封禁,不该被另一个开关连坐。
|
||
// 蜜罐路径与业务路由零交集,正常访客不可能命中,阈值取 1(一次即封)。
|
||
banned := false
|
||
if h.ab != nil && ip != "" {
|
||
banned = h.ab.RecordHoneypot(ip, path)
|
||
}
|
||
|
||
// 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
|
||
}
|