三处都属于「故障时才发现」的运维缺口,一起改。
1) 访问日志泄露凭证
flamego.Classic() 内部 Use(Logger()),而 Logger 记的是 RequestURI —— 含 query。
实测日志出现 path=/_/tblocal/4?t=SECRETTOKEN123:?t= 是私密提问钥匙,
?code= 是一次性改密凭证,?q= 是搜索词,全都会被明文落盘,凡是能看日志的人
都能直接读私密提问或重置密码。internal/tracing 早就为同一原因只取 URL.Path
并留了注释,漏的是框架自带那层。
→ 改 flamego.New() + accessLogMiddleware,只记 Method/Path/Status/Duration/IP;
静态资源成功请求不记(噪音),4xx/5xx 照记(排查裂图靠它)。
2) 没有优雅关闭
直接 ListenAndServe,Go 对 SIGTERM 是立即退出,每次部署都会把正在写库的 POST
就地处死,留下「提问已 INSERT、图片没传完」这类半成品。
→ signal.NotifyContext(SIGTERM/SIGINT) + srv.Shutdown(15s),超时才 Close;
退出前关连接池。实测 8 个并发分享图请求中发 SIGTERM,8/8 全部成功。
3) Redis 单点
session 建在 Redis 上,load 失败 flamego/session 会 panic → 每请求 500(含匿名访客);
而 cache.Cacher 没判空 Addr,go-redis 会回退 localhost:6379,于是「不配 Redis」
这种被允许的部署里找回密码整体不可用、私信邮件节流失效,且启动零提示。
→ cache 跟随开关(未配则 MemoryIniter);启动 PingRedis fail-fast,显式 2s 超时。
实测停掉 Redis 后启动立即失败并明确指出是 Redis。
验证:单测 2 项(query 永不入日志 / 静态资源过滤口径);重启日志出现
「收到退出信号」→「HTTP 服务已关闭」;启动打「Redis 连接正常」。
75 lines
3.1 KiB
Go
75 lines
3.1 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 (
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/flamego/flamego"
|
||
"github.com/sirupsen/logrus"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/shared"
|
||
)
|
||
|
||
// accessLogMiddleware 是 flamego.Classic() 内置 Logger() 的替代实现。
|
||
//
|
||
// 为什么不能用内置的:它记录的是 ctx.Request().RequestURI —— **含 query string**
|
||
// ([email protected]/logger.go:36,45)。本站有几个参数绝对不能进日志:
|
||
//
|
||
// ?t=<token> 私密提问的访问钥匙(route/question/question.go)
|
||
// ?code=<code> 一次性改密凭证(route/auth/recover.go)
|
||
// ?q=<keyword> 探索页搜索词
|
||
//
|
||
// 拿到 ?code= 可以直接重置该用户密码;拿到 ?t= 可以读别人发给箱主的私密提问。
|
||
// 而日志通常落 journald / out.log / 容器 json-file,凡是能看日志的人
|
||
// (外包运维、日志采集平台、Uptime 面板)都能直接利用。
|
||
//
|
||
// internal/tracing/flamego.go:36-40 早就为同一原因只取 URL.Path 并留了注释,
|
||
// 这里是同一个判断,之前只是漏在了框架自带的那层中间件上。
|
||
//
|
||
// 因此这里**只记 URL.Path**。日后若要按 query 排查,请显式加白名单键
|
||
// (tab/sort/page 这类无隐私含义的),不要把 RawQuery 整体加回来。
|
||
func accessLogMiddleware() flamego.Handler {
|
||
return func(ctx flamego.Context) {
|
||
started := time.Now()
|
||
// 放在 Recovery 外层:panic 被 Recovery 兜成 500 后,这里仍能记录状态码。
|
||
ctx.Next()
|
||
|
||
status := ctx.ResponseWriter().Status()
|
||
if !shouldAccessLog(ctx.Request().URL.Path, status) {
|
||
return
|
||
}
|
||
logrus.WithFields(accessLogFields(ctx.Request().Request, status, time.Since(started))).Info("access")
|
||
}
|
||
}
|
||
|
||
// shouldAccessLog 判断这条请求要不要进访问日志。
|
||
//
|
||
// 静态资源一页几十个请求,成功的不记,否则日志会被淹掉;
|
||
// 4xx/5xx 仍然要记 —— 排查「主题图 / 字体 404」靠的就是这些。
|
||
func shouldAccessLog(path string, status int) bool {
|
||
return !(strings.HasPrefix(path, "/static/") && status < http.StatusBadRequest)
|
||
}
|
||
|
||
// accessLogFields 构造访问日志字段。
|
||
//
|
||
// 抽成独立函数而不是内联,是为了让「无论请求带什么 query,path 字段都只有 path」
|
||
// 这件事能被单测直接断言 —— 这正是之前出问题的地方(框架默认 Logger 记的是 RequestURI)。
|
||
func accessLogFields(req *http.Request, status int, elapsed time.Duration) logrus.Fields {
|
||
return logrus.Fields{
|
||
"method": req.Method,
|
||
// 只取 URL.Path。绝不要换成 RequestURI / RawQuery,见文件头注释。
|
||
"path": req.URL.Path,
|
||
"status": status,
|
||
"duration": elapsed.Round(time.Millisecond).String(),
|
||
// 客户端 IP 走站内唯一口径 shared.ResolveClientIP(反代下取真实来源,
|
||
// 且只在 TCP 对端可信时才采信代理头),与封禁记录用同一套值,
|
||
// 出事时能把「日志里这个人」和「ban_records 里这条」对上。
|
||
"remote_ip": shared.ResolveClientIP(req),
|
||
}
|
||
}
|