fix(security): 修复安全审计的 P1/P2/P3 项,含全局复查新发现的两处越权与外跳

P1:
- 外链图片:markdown 渲染层只放行站内与自有 CDN 图片。配置里的
  image_bucket_cdn_host 常写成 host/path,统一归一化后再比对,避免把自家图一起剥掉
- 密码:页面/提问密码最小 6 位;密码比对挪到验证码之后;页面密码失败锁定 5 次/10 分钟
- 客户端 IP:CF-Connecting-IP / EO-Client-IP 只在 TCP 对端可信(回环或 trusted_proxies)
  时采信,公网对端带来的权威头一律忽略

P2/P3:
- 跳转参数收敛到 shared.SafeRedirectTarget:拒反斜杠与控制字符(url.Parse 会把 %0d%0a
  解成 CRLF,伪造 Location),最后再用 path.Clean 验一次前缀
- 注册不再暴露「邮箱已注册 / 已停用」:与全新注册共用同一个成功出口,只在日志留痕
- CSP:默认 enforce,白名单由配置派生,覆盖 umami(运行时注入)、Turnstile 与
  按 sentry_dsn 条件放行的 Sentry CDN
- /user 与 /_ 下发 no-store;补 X-XSS-Protection、Permissions-Policy,并把安全头前移到
  静态资源中间件之前(原先静态文件拿不到 nosniff/HSTS)
- 封禁事实落库 ban_records;429 统一带 Retry-After 与本地化文案
- token 比较改常量时间;探索页页码上限;JSON 响应显式 Content-Type

全局复查补的两处:
- 私聊 SendMessage 的 POST 缺 conv.OwnerID 校验,被拉黑者可换 domain 继续发消息
- 页面密码 next 与登录 to 同源的反斜杠外跳

验收:verify_p1_fixes 4/4、verify_round2 19/19、verify_page_next 6/6、
verify_chat_owner 3/3(含变异对照);单测与 go build ./... 全通过。
This commit is contained in:
tamakyi
2026-10-27 12:37:40 +08:00
parent a3cf99a8da
commit 7802bb2078
32 changed files with 1579 additions and 145 deletions
+20 -3
View File
@@ -57,11 +57,15 @@ var (
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr,
// 防止伪造 header 绕过速率限制与 IP 黑名单。
// ⚠️ 权威头只在 TCP 对端可信(回环,或命中 TrustedProxies)时才会被采信 ——
// 见 shared.ResolveClientIP:公网对端带来的权威头一律忽略(P1-3 修复)。
TrustProxy bool `ini:"trust_proxy"`
// TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任
// (CDN 权威头仍会采用,前提是源站已由 Cloudflare Tunnel / 回源网段锁定,
// 从而免于维护上游 CDN 的庞大 CIDR 清单)。
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任。
// 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
// 回源的连接必然由本机进程发起,外部无法伪造。
// CDN 直连回源(非 Tunnel)的部署形态下,必须把该 CDN 的回源网段登记到这里,
// 否则它的权威头会被忽略、客户端 IP 退化为 CDN 边缘 IP。
TrustedProxies string `ini:"trusted_proxies"`
EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP
// BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台
@@ -135,6 +139,19 @@ var (
// 否则等于把配置文件当响应体吐给每一个被封的攻击者。
BanImagePath string `ini:"ban_image_path"`
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
// CSP 内容安全策略的生效模式:enforce / report / off,缺省 enforce。
//
// enforce 下发 Content-Security-Policy(真正拦截);report 只下发 Report-Only
// (照常加载、只记违规),用于临时观察。默认拦截而不是观察,原因是:
// 没人会盯着 console,且不给 csp_report_uri 时 Report-Only 等于什么都没做,
// 留个不生效的开关反而制造「已防护」的错觉。默认值的安全前提是白名单已经
// 用真浏览器逐页巡检过(csp_sweep.py:20 个页面零违规)。
// 上线后发现误伤,把 csp 改成 report 或 off 再重启即可恢复,不用改代码。
CSP string `ini:"csp"`
// CSPReportURI 违规上报地址(可选)。留空时浏览器只把违规打到 console 不上报;
// 用 report 模式观察时应当配上,否则「观察一周」就只是空谈。
CSPReportURI string `ini:"csp_report_uri"`
}
Server struct {
+8
View File
@@ -135,6 +135,11 @@ func (c *Context) Refresh() {
}
func (c *Context) JSON(data interface{}) error {
// 显式声明内容类型:之前靠 Go 的 sniff 兜底,实测返回的是 text/plain。
// 有 nosniff 所以不会被当 HTML 渲染,但按 Content-Type 分派的客户端
// (fetch 的 .json()、axios、部分 SDK)会因此走错分支。
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
resp := map[string]interface{}{
"code": 0,
"data": data,
@@ -149,6 +154,9 @@ func (c *Context) ServerError() error {
}
func (c *Context) JSONError(errorCode int, message string) error {
// 必须在 WriteHeader 之前设置:一旦写出状态码,头就锁定了。
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
span := trace.SpanFromContext(c.Request().Context())
resp := map[string]interface{}{
+74
View File
@@ -0,0 +1,74 @@
// 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 db
import (
"context"
"time"
"gorm.io/gorm"
)
// BanRecord 是一次自动封禁的落库记录。
//
// 为什么需要它:自动封禁的生效状态是**进程内存**里的(见 internal/security/autoban),
// 重启即清零,外部完全看不见。线上出现过「邮件说封了、实际还能访问」的排查困境 ——
// 当时没有任何东西能说明封的是哪个键、因为什么、到什么时候解除,只能靠翻日志猜。
//
// 这张表是「封禁事实」的账本,不参与判定:IsBanned 仍然只读内存,
// 表只用于事后解释与取证。写入失败绝不能影响封禁动作本身。
//
// 增长:每个键在封禁期内不会重复建封(autoban 命中已封禁直接拒绝),
// 所以行数量级 = 封禁次数,不需要自动清理。要清可手动 DELETE。
type BanRecord struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
// BanKey 与 autoban.IsBanned 的判断口径完全一致(shared.LimitKey,
// IPv6 折成 /64),否则账本与判定会各说一套。
BanKey string `gorm:"size:64;index" json:"ban_key"`
// Reason 是给人看的原因文案(敏感路径探测 / 蜜罐提交凭据)。
Reason string `gorm:"size:64" json:"reason"`
// DurationMinutes 与 ExpiresAt 一起回答「封多久、什么时候自动解除」。
DurationMinutes int `gorm:"not null;default:0" json:"duration_minutes"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
}
// TableName 固定表名,避免 GORM 复数化差异。
func (BanRecord) TableName() string { return "ban_records" }
// BanRecords 自动封禁记录的全局 store,由 db.Init 装配。
var BanRecords BanRecordStore
// BanRecordStore 封禁记录的读写。
type BanRecordStore interface {
// Create 追加一条封禁记录。
Create(ctx context.Context, record *BanRecord) error
// ListSince 返回 since 之后的记录,按时间倒序,最多 limit 条。
ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error)
}
type banRecords struct {
db *gorm.DB
}
// NewBanRecordsStore 创建封禁记录 store。
func NewBanRecordsStore(db *gorm.DB) BanRecordStore {
return &banRecords{db: db}
}
func (s *banRecords) Create(ctx context.Context, record *BanRecord) error {
return s.db.WithContext(ctx).Create(record).Error
}
func (s *banRecords) ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error) {
var records []*BanRecord
err := s.db.WithContext(ctx).
Where("created_at >= ?", since).
Order("created_at DESC").
Limit(limit).
Find(&records).Error
return records, err
}
+56
View File
@@ -0,0 +1,56 @@
// 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 db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestBanRecordStore 覆盖自动封禁账本的写入与检索。
//
// 这张表不参与判定(IsBanned 只读内存),所以测试只锁两件事:
// ① 记录能落库并按时间倒序取回;② 到期时间与时长原样保留 ——
// 事后排查全依赖这两个字段回答「封了多久、什么时候解」。
func TestBanRecordStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("ban_records"); err != nil {
t.Fatal(err)
}
})
store := NewBanRecordsStore(database)
now := time.Now()
rows := []*BanRecord{
{BanKey: "1.2.3.4", Reason: "敏感路径探测", DurationMinutes: 60, ExpiresAt: now.Add(time.Hour), CreatedAt: now.Add(-2 * time.Hour)},
{BanKey: "2001:db8::/64", Reason: "蜜罐提交凭据", DurationMinutes: 1440, ExpiresAt: now.Add(24 * time.Hour), CreatedAt: now.Add(-10 * time.Minute)},
}
for _, r := range rows {
require.NoError(t, store.Create(ctx, r))
}
got, err := store.ListSince(ctx, now.Add(-3*time.Hour), 10)
require.NoError(t, err)
require.Len(t, got, 2)
// 时间倒序:最新的一条在最前
require.Equal(t, "2001:db8::/64", got[0].BanKey)
require.Equal(t, "蜜罐提交凭据", got[0].Reason)
require.Equal(t, 1440, got[0].DurationMinutes)
require.False(t, got[0].ExpiresAt.IsZero(), "到期时间必须保留,否则无法回答「什么时候解」")
// 时间窗过滤生效
recent, err := store.ListSince(ctx, now.Add(-30*time.Minute), 10)
require.NoError(t, err)
require.Len(t, recent, 1)
require.Equal(t, "2001:db8::/64", recent[0].BanKey)
}
+3
View File
@@ -26,6 +26,8 @@ var AllTables = []interface{}{
&UserRestriction{}, &ContentReport{},
// 蜜罐命中记录(审计证据,只追加不软删)
&HoneypotHit{},
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
&BanRecord{},
}
func Init(typ, dsn string) (*gorm.DB, error) {
@@ -142,6 +144,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
Restrictions = NewRestrictionsStore(db)
Reports = NewReportsStore(db)
Honeypots = NewHoneypotStore(db)
BanRecords = NewBanRecordsStore(db)
Analytics = NewSiteAnalyticsStore(db)
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
+10 -2
View File
@@ -56,12 +56,20 @@ type UpdateTags struct {
ShowInHome string `form:"show_in_home"`
}
// 页面访问密码 / 提问密码的最小长度。
//
// P1-2:这两个字段此前只有 maxlen,箱主可以设成 1 位或 1234 —— 而页面密码正是产品
// 对外宣传的访问控制,提问密码是箱主过滤骚扰的唯一门槛。6 位是最低可接受值
// (纯数字 10^6,配合失败锁定后穷举不可行);留空仍然合法,表示关闭该密码
// (govalid 对空值跳过 minlen / maxlen)。
const PasswordMinLen = 6
// 新增:页面访问密码表单
type UpdatePagePassword struct {
Password string `valid:"maxlen:30" label:"页面访问密码" msg:"form.profile.passwordMax"` // 留空表示关闭
Password string `valid:"minlen:6;maxlen:30" label:"页面访问密码" msg:"form.profile.passwordLen"` // 留空表示关闭
}
// 新增:提问密码表单
type UpdateAskPassword struct {
Password string `valid:"maxlen:30" label:"提问密码" msg:"form.profile.passwordMax"` // 留空表示关闭
Password string `valid:"minlen:6;maxlen:30" label:"提问密码" msg:"form.profile.passwordLen"` // 留空表示关闭
}
+21 -17
View File
@@ -16,21 +16,22 @@ var enDict = map[string]string{
"nav.lang": "Language",
// Common
"common.login": "Log in",
"common.forgot": "Forgot password",
"common.submit": "Submit",
"common.cancel": "Cancel",
"common.save": "Save",
"common.close": "Close",
"common.delete": "Delete",
"common.moveUp": "Move up",
"common.moveDown": "Move down",
"common.share": "Share",
"common.verifying": "Verifying...",
"common.submitting": "Submitting...",
"common.uploading": "Uploading...",
"common.lang": "Language",
"common.menu": "Menu",
"common.login": "Log in",
"common.forgot": "Forgot password",
"common.submit": "Submit",
"common.cancel": "Cancel",
"common.save": "Save",
"common.tooManyRequests": "Too many requests. Please try again later.",
"common.close": "Close",
"common.delete": "Delete",
"common.moveUp": "Move up",
"common.moveDown": "Move down",
"common.share": "Share",
"common.verifying": "Verifying...",
"common.submitting": "Submitting...",
"common.uploading": "Uploading...",
"common.lang": "Language",
"common.menu": "Menu",
"common.pleaseWait": "Please wait...",
"common.backHome": "Back to Home",
@@ -182,9 +183,9 @@ var enDict = map[string]string{
"admin.tagInputHint": "Type and press Enter or comma to add",
"admin.tagInputDuplicate": "Already exists, ignored",
"admin.pagePassword": "Page access password",
"admin.pagePasswordHint": "Set a page password (leave empty to disable)",
"admin.pagePasswordHint": "Set a page password (6–30 characters, leave empty to disable)",
"admin.askPassword": "Ask password",
"admin.askPasswordHint": "Set a password for asking (leave empty to disable)",
"admin.askPasswordHint": "Set a password for asking (6–30 characters, leave empty to disable)",
"admin.saveProfile": "Save changes",
"admin.updated": "Profile updated successfully",
"admin.logout": "Log out",
@@ -619,6 +620,7 @@ var enDict = map[string]string{
"vote.page.submit": "Submit vote",
"vote.page.alreadyVoted": "You have already participated in this poll. Thanks!",
"vote.page.resultsTitle": "Poll Results",
"vote.page.anonymousNotice": "Anonymous poll — one vote per person, results are indicative only.",
"vote.page.totalVotes": "Total",
"vote.page.avgRating": "Average",
"vote.page.avgRank": "Avg rank",
@@ -757,6 +759,7 @@ var enDict = map[string]string{
// Question box page: visitor-visible (P0)
"question.errWrongPassword": "Wrong password",
"question.errPasswordLocked": "Too many wrong attempts. Please try again in %d minutes.",
"question.errAskPasswordWrong": "Wrong ask password",
"question.errLoginRequired": "The owner only allows registered users to ask questions. Please log in first.",
"question.errSelfChat": "You cannot start a chat with yourself",
@@ -926,6 +929,7 @@ var enDict = map[string]string{
"form.profile.name": "Nickname is required and must be at most 20 characters",
"form.profile.newPassword": "New password must be at most 30 characters",
"form.profile.passwordMax": "Password must be at most 30 characters",
"form.profile.passwordLen": "Please set a password of 6–30 characters",
"form.question.content": "Question content is required",
"form.question.contentTooLong": "Question content must be within %d characters",
"form.question.receiveReplyEmail": "Please enter a valid email address to receive replies",
+21 -17
View File
@@ -16,21 +16,22 @@ var jaDict = map[string]string{
"nav.lang": "言語",
// 共通
"common.login": "ログイン",
"common.forgot": "パスワードを忘れた",
"common.submit": "送信",
"common.cancel": "キャンセル",
"common.save": "保存",
"common.close": "閉じる",
"common.delete": "削除",
"common.moveUp": "上へ移動",
"common.moveDown": "下へ移動",
"common.share": "共有",
"common.verifying": "認証中...",
"common.submitting": "送信中...",
"common.uploading": "アップロード中...",
"common.lang": "言語",
"common.menu": "メニュー",
"common.login": "ログイン",
"common.forgot": "パスワードを忘れた",
"common.submit": "送信",
"common.cancel": "キャンセル",
"common.save": "保存",
"common.tooManyRequests": "リクエストが多すぎます。しばらくしてから再度お試しください。",
"common.close": "閉じる",
"common.delete": "削除",
"common.moveUp": "上へ移動",
"common.moveDown": "下へ移動",
"common.share": "共有",
"common.verifying": "認証中...",
"common.submitting": "送信中...",
"common.uploading": "アップロード中...",
"common.lang": "言語",
"common.menu": "メニュー",
"common.pleaseWait": "お待ちください...",
"common.backHome": "ホームに戻る",
@@ -182,9 +183,9 @@ var jaDict = map[string]string{
"admin.tagInputHint": "入力して Enter またはカンマで追加",
"admin.tagInputDuplicate": "既に存在するため無視しました",
"admin.pagePassword": "ページアクセスパスワード",
"admin.pagePasswordHint": "アクセスパスワードを設定(空欄で無効)",
"admin.pagePasswordHint": "アクセスパスワードを設定(6〜30文字、空欄で無効)",
"admin.askPassword": "質問パスワード",
"admin.askPasswordHint": "質問用パスワードを設定(空欄で無効)",
"admin.askPasswordHint": "質問用パスワードを設定(6〜30文字、空欄で無効)",
"admin.saveProfile": "変更を保存",
"admin.updated": "プロフィールを更新しました",
"admin.logout": "ログアウト",
@@ -618,6 +619,7 @@ var jaDict = map[string]string{
"vote.page.submit": "投票する",
"vote.page.alreadyVoted": "この投票にはすでに参加済みです。ありがとうございます!",
"vote.page.resultsTitle": "投票結果",
"vote.page.anonymousNotice": "匿名投票(お一人一票)のため、結果は参考値です。",
"vote.page.totalVotes": "総参加",
"vote.page.avgRating": "平均点",
"vote.page.avgRank": "平均順位",
@@ -756,6 +758,7 @@ var jaDict = map[string]string{
// 質問箱ページ:訪問者に表示(P0)
"question.errWrongPassword": "パスワードが正しくありません",
"question.errPasswordLocked": "パスワードの誤入力が多すぎます。%d 分後にもう一度お試しください。",
"question.errAskPasswordWrong": "質問用パスワードが正しくありません",
"question.errLoginRequired": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
"question.errSelfChat": "自分自身とのチャットは開始できません",
@@ -925,6 +928,7 @@ var jaDict = map[string]string{
"form.profile.name": "ニックネームは必須で、20文字以内である必要があります",
"form.profile.newPassword": "新しいパスワードは30文字以内である必要があります",
"form.profile.passwordMax": "パスワードは30文字以内である必要があります",
"form.profile.passwordLen": "パスワードは6〜30文字で設定してください",
"form.question.content": "質問内容を入力してください",
"form.question.contentTooLong": "質問内容は%d文字以内にしてください",
"form.question.receiveReplyEmail": "返信を受け取るための有効なメールアドレスを入力してください",
+21 -17
View File
@@ -16,21 +16,22 @@ var zhCNDict = map[string]string{
"nav.lang": "语言",
// 通用
"common.login": "登录",
"common.forgot": "忘记密码",
"common.submit": "提交",
"common.cancel": "取消",
"common.save": "保存",
"common.close": "关闭",
"common.delete": "删除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "验证中...",
"common.submitting": "提交中...",
"common.uploading": "上传中...",
"common.lang": "语言",
"common.menu": "菜单",
"common.login": "登录",
"common.forgot": "忘记密码",
"common.submit": "提交",
"common.cancel": "取消",
"common.save": "保存",
"common.tooManyRequests": "请求过于频繁,请稍后再试。",
"common.close": "关闭",
"common.delete": "删除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "验证中...",
"common.submitting": "提交中...",
"common.uploading": "上传中...",
"common.lang": "语言",
"common.menu": "菜单",
"common.pleaseWait": "请稍候...",
"common.backHome": "返回首页",
@@ -182,9 +183,9 @@ var zhCNDict = map[string]string{
"admin.tagInputHint": "输入后按回车或逗号添加",
"admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "页面访问密码",
"admin.pagePasswordHint": "设置访问密码(留空则关闭)",
"admin.pagePasswordHint": "设置访问密码(6–30 位,留空则关闭)",
"admin.askPassword": "提问密码",
"admin.askPasswordHint": "设置提问密码(留空则关闭)",
"admin.askPasswordHint": "设置提问密码(6–30 位,留空则关闭)",
"admin.saveProfile": "保存修改",
"admin.updated": "更新个人信息成功",
"admin.logout": "退出登录",
@@ -619,6 +620,7 @@ var zhCNDict = map[string]string{
"vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已参与过该投票,感谢参与!",
"vote.page.resultsTitle": "投票结果",
"vote.page.anonymousNotice": "匿名投票,每人一票,结果仅供参考。",
"vote.page.totalVotes": "总参与",
"vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名",
@@ -757,6 +759,7 @@ var zhCNDict = map[string]string{
// 提问箱页:访客可见(P0)
"question.errWrongPassword": "密码错误",
"question.errPasswordLocked": "密码错误次数过多,请 %d 分钟后再试",
"question.errAskPasswordWrong": "提问密码错误",
"question.errLoginRequired": "提问箱的主人设置了仅注册用户才能提问,请先登录。",
"question.errSelfChat": "不能给自己发起私聊",
@@ -938,6 +941,7 @@ var zhCNDict = map[string]string{
"form.profile.name": "昵称不能为空,且不超过 20 个字符",
"form.profile.newPassword": "新密码长度不能超过 30 个字符",
"form.profile.passwordMax": "密码长度不能超过 30 个字符",
"form.profile.passwordLen": "请设置 6 至 30 位的密码",
"form.question.content": "问题内容不能为空",
"form.question.contentTooLong": "问题内容不能超过 %d 个字符",
"form.question.receiveReplyEmail": "请填写有效的电子邮箱地址以接收回复",
+21 -17
View File
@@ -16,21 +16,22 @@ var zhTWDict = map[string]string{
"nav.lang": "語言",
// 通用
"common.login": "登入",
"common.forgot": "忘記密碼",
"common.submit": "送出",
"common.cancel": "取消",
"common.save": "儲存",
"common.close": "關閉",
"common.delete": "刪除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "驗證中...",
"common.submitting": "提交中...",
"common.uploading": "上傳中...",
"common.lang": "語言",
"common.menu": "選單",
"common.login": "登入",
"common.forgot": "忘記密碼",
"common.submit": "送出",
"common.cancel": "取消",
"common.save": "儲存",
"common.tooManyRequests": "請求過於頻繁,請稍後再試。",
"common.close": "關閉",
"common.delete": "刪除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "驗證中...",
"common.submitting": "提交中...",
"common.uploading": "上傳中...",
"common.lang": "語言",
"common.menu": "選單",
"common.pleaseWait": "請稍候...",
"common.backHome": "返回首頁",
@@ -182,9 +183,9 @@ var zhTWDict = map[string]string{
"admin.tagInputHint": "輸入後按 Enter 或逗號新增",
"admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "頁面訪問密碼",
"admin.pagePasswordHint": "設定訪問密碼(留空則關閉)",
"admin.pagePasswordHint": "設定訪問密碼(6–30 位,留空則關閉)",
"admin.askPassword": "提問密碼",
"admin.askPasswordHint": "設定提問密碼(留空則關閉)",
"admin.askPasswordHint": "設定提問密碼(6–30 位,留空則關閉)",
"admin.saveProfile": "儲存修改",
"admin.updated": "更新個人資訊成功",
"admin.logout": "登出",
@@ -618,6 +619,7 @@ var zhTWDict = map[string]string{
"vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已參與過該投票,感謝參與!",
"vote.page.resultsTitle": "投票結果",
"vote.page.anonymousNotice": "匿名投票,每人一票,結果僅供參考。",
"vote.page.totalVotes": "總參與",
"vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名",
@@ -756,6 +758,7 @@ var zhTWDict = map[string]string{
// 提問箱頁:訪客可見(P0)
"question.errWrongPassword": "密碼錯誤",
"question.errPasswordLocked": "密碼錯誤次數過多,請 %d 分鐘後再試",
"question.errAskPasswordWrong": "提問密碼錯誤",
"question.errLoginRequired": "提問箱的主人設定了僅註冊使用者才能提問,請先登入。",
"question.errSelfChat": "不能給自己發起私聊",
@@ -925,6 +928,7 @@ var zhTWDict = map[string]string{
"form.profile.name": "暱稱不能為空,且不超過 20 個字元",
"form.profile.newPassword": "新密碼長度不能超過 30 個字元",
"form.profile.passwordMax": "密碼長度不能超過 30 個字元",
"form.profile.passwordLen": "請設定 6 至 30 位的密碼",
"form.question.content": "問題內容不能為空",
"form.question.contentTooLong": "問題內容不能超過 %d 個字元",
"form.question.receiveReplyEmail": "請填寫有效的電子郵件地址以接收回覆",
+11
View File
@@ -191,6 +191,17 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
return
}
// 纵深防御(与 ConversationPage 同一口径):会话必须属于 URL 里这个箱子的真实属主。
//
// 少了这一步,domain 就只是一张"通行证":拿 A 箱的 domain 拼 B 箱的会话 token,
// 下面所有以 pageUser 为主体的判定都会落到 A 箱主身上 ——
// 被 B 箱主拉黑的提问者换个 domain 就能继续发消息,A 箱主的屏蔽词与日限额被套用到 B 的会话上。
// GET 页面早就有这条校验,POST 漏了。
if conv.OwnerID != pageUser.ID {
ctx.Redirect("/")
return
}
isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker {
+134
View File
@@ -0,0 +1,134 @@
package route
import (
"net/url"
"strings"
"github.com/tamakyi/TamaBox/internal/conf"
)
// cspMode 归一化 [security] csp 的取值。默认 enforce。
//
// 为什么默认 enforce 而不是 report:本项没有配置 report-uri 时,report-only 只是
// 「发个头、什么都不拦」,等于没装 —— 装个不生效的开关反而给人「已防护」的错觉。
// 已用真浏览器巡检 20 个页面(匿名 11 + 登录 6 + 单题/投票/分享图)零违规,见
// outputs/sec-audit-2026-10-25/csp_sweep.py。
//
// 两个方向的安全性都保留了:写错值(如 "enforce " 以外的拼写错误)退到 report,
// 宁可少拦也不让整站样式与交互崩掉;真要临时关掉写 off。
func cspMode() string {
switch strings.ToLower(strings.TrimSpace(conf.Security.CSP)) {
case "off", "none", "false", "0":
return "off"
case "report", "report-only", "reportonly":
return "report"
case "enforce", "on", "true", "1", "":
return "enforce"
default:
return "report"
}
}
// cspOrigin 把配置里的「域名」或「完整 URL」统一成 scheme://host 形态。
// 配置项经常是两种写法混用(如 image_bucket_cdn_host 常写成 host/path),
// 直接拼进 CSP 会变成非法源而被浏览器整条忽略。
func cspOrigin(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
// cspPolicy 构造 Content-Security-Policy 头。
//
// 目标是补一道纵深防御,而不是把页面卡死:XSS 过滤器是一套手写白名单,
// 任何一次改动都可能开口子;CSP 让「即使注入了 <script src="//evil">」也执行不了。
//
// 白名单口径:
// - script 收得很紧(本站 + 内联 + eval + 公共 CDN + 验证码域 + 图片 CDN);
// 'unsafe-eval' 不能省 —— Alpine 3 用 new Function 编译 x-data 表达式,去掉整站交互失效。
// - 图片 / 样式 / 字体 / 连接放宽到 https::箱主上传图、社交链接头像、主题背景都可能
// 落在任意 CDN 上,收紧到具体域名只会换来一堆「图裂了」且很难自查。
// - object-src / base-uri / form-action / frame-ancestors 一并锁死。
func cspPolicy() string {
self := "'self'"
scriptSrc := []string{
self, "'unsafe-inline'", "'unsafe-eval'",
// 公共 CDN:UIkit / Alpine / dayjs / vConsole / waline(见 templates/base/header.html)
"https://cdnjs.snrat.com",
// umami 站点统计:header.html 在 window load 后动态注入 <script>,
// 静态 grep 模板里的 <script src> 抓不到它 —— 这是 enforce 实测时踩出来的
// 第一个漏项,浏览器报 "Loading the script ... violates ... script-src"。
"https://mami.shiroko.one",
// Cloudflare Turnstile
"https://challenges.cloudflare.com",
}
frameSrc := []string{"https://challenges.cloudflare.com"}
// Sentry 浏览器 SDK:templates/base/header.html 里是 `{{ if ne SentryDSN "" }}` 包着的,
// 不配 DSN 就完全不存在。所以这里也跟着配置条件加 —— 否则要么开着 enforce 时配了 DSN
// 才发现错误上报被拦(静默失效),要么白名单里常年挂一个用不到的第三方源。
// 上报走 connect-src,那边的 https: 已经覆盖 *.ingest.sentry.io。
if strings.TrimSpace(conf.App.SentryDSN) != "" {
scriptSrc = append(scriptSrc, "https://browser.sentry-cdn.com")
}
// 图片 CDN 也托管了脚本(部分库直接放在 R2 上),所以进 script-src;
// 存储 API 端点(ImageEndpoint / AliyunEndpoint)不进 —— 浏览器从不直接加载它们。
for _, raw := range []string{
conf.Upload.ImageBucketCDNHost,
conf.Upload.AliyunBucketCDNHost,
} {
if origin := cspOrigin(raw); origin != "" {
scriptSrc = append(scriptSrc, origin)
}
}
// 验证码域既是脚本源也是 iframe 源(Turnstile / reCAPTCHA 都是 iframe 形态)
if origin := cspOrigin(conf.Recaptcha.Domain); origin != "" {
scriptSrc = append(scriptSrc, origin)
frameSrc = append(frameSrc, origin)
}
directives := []string{
"default-src " + self,
"script-src " + strings.Join(dedupeStrings(scriptSrc), " "),
"style-src " + self + " 'unsafe-inline' https:",
"img-src " + self + " data: blob: https:",
"font-src " + self + " data: https:",
"media-src " + self + " https:",
"frame-src " + strings.Join(dedupeStrings(frameSrc), " "),
"connect-src " + self + " https:",
"object-src 'none'",
"base-uri 'none'",
"form-action " + self,
"frame-ancestors 'none'",
}
if uri := strings.TrimSpace(conf.Security.CSPReportURI); uri != "" {
directives = append(directives, "report-uri "+uri)
}
return strings.Join(directives, "; ")
}
// dedupeStrings 去重并保持顺序(白名单重复项无害,但排掉便于比对与测试)。
func dedupeStrings(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
+120
View File
@@ -0,0 +1,120 @@
package route
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
// withSecurityConf 临时改 conf.Security 并在测试结束后还原,避免污染同包其它用例。
func withSecurityConf(t *testing.T, csp, reportURI string) {
t.Helper()
oldCSP, oldURI := conf.Security.CSP, conf.Security.CSPReportURI
conf.Security.CSP = csp
conf.Security.CSPReportURI = reportURI
t.Cleanup(func() {
conf.Security.CSP = oldCSP
conf.Security.CSPReportURI = oldURI
})
}
func TestCSPMode(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "enforce"}, // 未配置:默认拦截(report-only 没有上报地址等于没装)
{"report", "report"},
{"REPORT", "report"},
{"report-only", "report"},
{"off", "off"},
{"none", "off"},
{"enforce", "enforce"},
{" true ", "enforce"},
{"enfroce", "report"}, // 拼错退化为不拦截
}
for _, tc := range cases {
withSecurityConf(t, tc.in, "")
require.Equal(t, tc.want, cspMode(), "输入 %q", tc.in)
}
}
func TestCSPPolicy(t *testing.T) {
old := conf.Upload
t.Cleanup(func() { conf.Upload = old })
// 生产配置的真实写法:Host 带路径、Domain 是完整 URL。
conf.Upload.ImageBucketCDNHost = "r2.example.net/tamabox"
conf.Upload.AliyunBucketCDNHost = ""
conf.Recaptcha.Domain = "https://www.recaptcha.net"
withSecurityConf(t, "enforce", "")
policy := cspPolicy()
for _, want := range []string{
"default-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
// 脚本白名单:站内 + 内联 + eval(Alpine 需要)+ 公共 CDN + 验证码
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"https://cdnjs.snrat.com",
// umami 统计脚本是 header.html 里运行时动态注入的,静态 grep 模板看不到;
// 少这一条会让 enforce 模式直接拦掉它(已在浏览器实测中复现过)。
"https://mami.shiroko.one",
"https://challenges.cloudflare.com",
"https://www.recaptcha.net",
// 带路径的配置必须被归一化成 origin,否则整条源非法、浏览器静默忽略
"https://r2.example.net",
} {
require.Contains(t, policy, want)
}
require.NotContains(t, policy, "r2.example.net/tamabox", "带路径的配置必须裁成 origin")
require.NotContains(t, policy, "report-uri", "没配上报地址时不应出现 report-uri")
}
// TestCSPPolicySentry 覆盖「配了 sentry_dsn 才放行 Sentry CDN」这个条件白名单。
// 两个方向都会静默出错:线上没开 Sentry 时白名单里多挂一个第三方源;开了 Sentry
// 时错误上报被 enforce 直接拦掉,而页面上完全看不出异常。
func TestCSPPolicySentry(t *testing.T) {
old := conf.App
t.Cleanup(func() { conf.App = old })
conf.App.SentryDSN = ""
require.NotContains(t, cspPolicy(), "browser.sentry-cdn.com")
conf.App.SentryDSN = "https://[email protected]/1"
require.Contains(t, cspPolicy(), "https://browser.sentry-cdn.com")
}
func TestCSPPolicyReportURI(t *testing.T) {
withSecurityConf(t, "report", "https://csp.example.net/collect")
require.Contains(t, cspPolicy(), "report-uri https://csp.example.net/collect")
}
func TestIsNoStorePath(t *testing.T) {
for _, p := range []string{"/user", "/user/", "/user/profile", "/_", "/_/someone", "/_/someone/1"} {
require.True(t, isNoStorePath(p), p)
}
// 分享卡是给社交平台抓取的公开图片,且渲染成本高,刻意不套 no-store。
for _, p := range []string{"/", "/login", "/static/css/base.css", "/_foo", "/users", "/explore",
"/_/someone/share-card", "/_/someone/1/share-card"} {
require.False(t, isNoStorePath(p), p)
}
}
// TestCSPPolicyNoEmptyOrigin 防止配置留空时拼出非法源(如 "https://")。
func TestCSPPolicyNoEmptyOrigin(t *testing.T) {
old := conf.Upload
t.Cleanup(func() { conf.Upload = old })
conf.Upload.ImageBucketCDNHost = ""
conf.Upload.AliyunBucketCDNHost = ""
conf.Recaptcha.Domain = ""
policy := cspPolicy()
require.False(t, strings.Contains(policy, "https:// ") || strings.HasSuffix(policy, "https://"), policy)
}
+24 -4
View File
@@ -3,16 +3,38 @@ package route
import (
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/flamego/flamego"
"github.com/flamego/session"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/route/vote"
)
// tooManyRequests 是 429 的唯一出口。
//
// 原来两处中间件各写一遍,只有一句英文硬编码 —— 既没走 i18n(中文站给访客看英文),
// 也没有 Retry-After,客户端只能靠猜什么时候可以重试(多数会立刻重试,把限流放大)。
// Retry-After 取窗口长度:滑动窗口下真实可重试时间可能更早,这里是安全上界。
//
// 语言固定用站点默认:限流中间件跑在 Contexter 之前,此时拿不到已解析的界面语言,
// 而 429 的 body 通常由前端 JS 展示、很少直接被阅读。
func tooManyRequests(ctx flamego.Context, window time.Duration) {
w := ctx.ResponseWriter()
retry := int(window.Seconds())
if retry < 1 {
retry = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retry))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(i18n.T(i18n.Default, "common.tooManyRequests")))
}
// rateLimiter provides a simple in-memory rate limiter using a sliding window.
type rateLimiter struct {
mu sync.Mutex
@@ -140,8 +162,7 @@ func rateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
}
if !rl.allow(ip) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
tooManyRequests(ctx, window)
return
}
}
@@ -156,8 +177,7 @@ func voteSubmitRateLimitMiddleware(limit int, window time.Duration) flamego.Hand
anonymousIPRL := newRateLimiter(limit*10, window)
tooMany := func(ctx flamego.Context) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
tooManyRequests(ctx, window)
}
return func(ctx flamego.Context, s session.Session) {
+64 -3
View File
@@ -5,6 +5,7 @@
package route
import (
stdctx "context"
"encoding/gob"
"io"
"net/http"
@@ -64,15 +65,51 @@ func securityHeaders() flamego.Handler {
w := ctx.ResponseWriter()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
// X-XSS-Protection 已是废弃的响应头:Chrome 移除、新 Firefox 不实现,
// 而旧版 IE/Edge 的反射型 XSS 过滤器本身有信息泄露缺陷(CVE-2018-10016 一类)。
// 现代建议是显式关掉,把防护交给 CSP 与输出编码。
w.Header().Set("X-XSS-Protection", "0")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// 站点不需要这些能力,显式全禁,避免被嵌入页 / 第三方脚本索要权限。
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
// HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。
if strings.HasPrefix(conf.App.ExternalURL, "https") {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
// CSP:off 时完全不出现;report 只下发 Report-Only(页面行为不变);
// enforce 才真正拦截。见 csp.go 与 [security] csp 的注释。
switch cspMode() {
case "enforce":
w.Header().Set("Content-Security-Policy", cspPolicy())
case "report":
w.Header().Set("Content-Security-Policy-Report-Only", cspPolicy())
}
// 敏感页面禁止缓存:
// /user/** 箱主后台(资料、会话、私信全在里面)
// /_/** 箱主页与单题页(单题页带 t= token,等于一把钥匙)
// 不管的话,共享设备上「退出登录 → 后退键」还能翻出完整后台(bfcache / 磁盘缓存);
// 将来若给 /user 配了 CDN 的 Cache Everything,还会把个人数据跨用户回放。
// 静态资源不在此列(/static 前缀不匹配)。
if p := ctx.Request().URL.Path; isNoStorePath(p) {
w.Header().Set("Cache-Control", "no-store, private")
w.Header().Set("Pragma", "no-cache")
}
}
}
// isNoStorePath 判断该路径是否属于「不允许任何缓存」的敏感面。
func isNoStorePath(p string) bool {
// 分享卡例外:它是给社交平台抓取的图片,本来就以公开分享为目的,而且渲染成本高
// (要画图 + 上传/落盘)。给它 no-store 会让每次 OG 预览都回源重画一张。
if strings.HasSuffix(p, "/share-card") {
return false
}
return p == "/user" || strings.HasPrefix(p, "/user/") ||
p == "/_" || strings.HasPrefix(p, "/_/")
}
// newBanner 按 conf.Security 装配自动封禁器。
//
// 抽成独立函数(而不是内联在 New 里)是有代价换来的:蜜罐的封禁通道参数
@@ -115,12 +152,33 @@ func newBanner() *autoban.Banner {
if reason == autoban.ReasonHoneypot {
reasonText = "蜜罐提交凭据"
}
expiresAt := time.Now().Add(duration)
logrus.WithFields(logrus.Fields{
"ban_key": key,
"reason": reasonText,
"duration": duration.String(),
"expires_at": time.Now().Add(duration).Format("2006-01-02 15:04:05"),
"expires_at": expiresAt.Format("2006-01-02 15:04:05"),
}).Warn("已自动封禁来源(内存态,重启进程即解除;键为聚合口径,IPv6 为 /64)")
// 落库:内存态封禁重启即失效,事后没人能回答「封了哪个键、因为什么、封多久」。
// 异步 + 独立超时 + 失败只告警 —— 取证记录不能反过来拖慢或影响封禁本身。
// BanRecords 为 nil 表示未经 db.Init(单测里自建 Banner 就是这种情形)。
if db.BanRecords == nil {
return
}
record := &db.BanRecord{
BanKey: key,
Reason: reasonText,
DurationMinutes: int(duration.Minutes()),
ExpiresAt: expiresAt,
}
go func() {
ctx, cancel := stdctx.WithTimeout(stdctx.Background(), 3*time.Second)
defer cancel()
if err := db.BanRecords.Create(ctx, record); err != nil {
logrus.WithError(err).WithField("ban_key", record.BanKey).Warn("封禁记录落库失败")
}
}()
},
})
}
@@ -204,6 +262,10 @@ func New() *flamego.Flame {
},
})
// 安全响应头必须注册在静态资源之前:flamego.Static 命中后会终结请求链,
// 放在它后面等于静态资源拿不到 nosniff / HSTS。
f.Use(securityHeaders())
f.Use(flamego.Static(flamego.StaticOptions{
FileSystem: http.FS(static.FS),
Prefix: "/static",
@@ -213,7 +275,6 @@ func New() *flamego.Flame {
},
}))
f.Use(securityHeaders())
f.Use(ipBlacklistMiddleware())
f.Use(autobanMiddleware(ab, hp))
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
+114
View File
@@ -0,0 +1,114 @@
package shared
import (
"sync"
"time"
)
// AttemptLimiter 按 key 统计「失败次数」,达到阈值后在 lock 时长内锁定该 key。
//
// 适用场景:密码、验证码这类可被穷举的入口。成功的尝试应调用 Reset 清零,
// 失败的尝试调用 Fail;处于锁定状态时应直接拒绝,不要再比对密码 —— 既省掉
// 一次密码哈希的计算,也不给攻击者「比对上更快的探测通道」。
//
// 实现约束:
// - 纯内存、无后台 goroutine:仓库里已经踩过「每个限流器实例一个常驻 goroutine」
// 的泄漏坑(见 internal/route/ratelimit.go 的统一清理循环),这里改用惰性清理;
// - 键数量上限保护:恶意构造海量 key 时先清过期项,仍超限则丢弃最旧的键,
// 避免 map 无限膨胀把进程内存吃满。
type AttemptLimiter struct {
mu sync.Mutex
fails map[string][]time.Time
max int
window time.Duration
lock time.Duration
now func() time.Time // 仅测试替换为可控时钟
}
// NewAttemptLimiter 创建失败计数器:window 内失败达到 max 次 → 锁定 lock 时长。
func NewAttemptLimiter(max int, window, lock time.Duration) *AttemptLimiter {
if max < 1 {
max = 1
}
return &AttemptLimiter{
fails: make(map[string][]time.Time),
max: max,
window: window,
lock: lock,
now: time.Now,
}
}
// Blocked 判断 key 是否处于锁定状态,并返回剩余锁定时间。
//
// 锁定期满后自动清零,用户重新获得 max 次机会 —— 否则一次误触发就会永久锁死。
func (l *AttemptLimiter) Blocked(key string) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
times := l.pruneLocked(key, now)
if len(times) < l.max {
return false, 0
}
unlockAt := times[len(times)-1].Add(l.lock)
if now.Before(unlockAt) {
return true, unlockAt.Sub(now)
}
// 锁定期满:清空记录,重新开始计数。
delete(l.fails, key)
return false, 0
}
// Fail 记录一次失败。
func (l *AttemptLimiter) Fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
times := l.pruneLocked(key, now)
times = append(times, now)
l.fails[key] = times
// 条数超限时清理全部已过期的键;仍超限则整表重建(宁可丢失计数,也不能 OOM)。
if len(l.fails) > 5000 {
for k := range l.fails {
if t := l.fails[k]; len(t) == 0 || now.Sub(t[len(t)-1]) > l.window+l.lock {
delete(l.fails, k)
}
}
if len(l.fails) > 5000 {
l.fails = map[string][]time.Time{key: times}
}
}
}
// Reset 清空该 key 的失败记录(校验成功后调用)。
func (l *AttemptLimiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.fails, key)
}
// pruneLocked 丢弃窗口外的失败记录,返回窗口内的切片(调用方持有 l.mu)。
func (l *AttemptLimiter) pruneLocked(key string, now time.Time) []time.Time {
times, ok := l.fails[key]
if !ok {
return nil
}
cutoff := now.Add(-l.window)
valid := times[:0]
for _, t := range times {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(l.fails, key)
return nil
}
l.fails[key] = valid
return valid
}
+96
View File
@@ -0,0 +1,96 @@
package shared
import (
"testing"
"time"
)
// TestAttemptLimiterLockout 覆盖 P1-2 的爆破防护契约:
// window 内失败 max 次 → 锁定 lock 时长;锁定期满后自动清零(不会永久锁死);
// 成功校验(Reset)后立即恢复。
func TestAttemptLimiterLockout(t *testing.T) {
now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
l := NewAttemptLimiter(3, 10*time.Minute, 5*time.Minute)
l.now = clock
const key = "page:1:203.0.113.10"
// 前两次失败不应锁定。
for i := 0; i < 2; i++ {
l.Fail(key)
}
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("失败 2 次(阈值 3)就被锁定")
}
// 第 3 次失败达到阈值 → 锁定。
l.Fail(key)
blocked, remain := l.Blocked(key)
if !blocked {
t.Fatal("失败 3 次(阈值 3)未锁定")
}
if remain <= 0 || remain > 5*time.Minute {
t.Fatalf("剩余锁定时间异常:%v", remain)
}
// 锁定期内即使再失败也仍然锁定。
now = now.Add(time.Minute)
l.Fail(key)
if blocked, _ := l.Blocked(key); !blocked {
t.Fatal("锁定期内应保持锁定")
}
// 锁定期满 → 解锁,且计数被清零(重新拥有 max 次机会)。
now = now.Add(5 * time.Minute)
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("锁定期满后未解锁")
}
for i := 0; i < 2; i++ {
l.Fail(key)
}
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("解锁后计数未清零:2 次失败不应触发锁定")
}
// Reset(校验成功)清空计数。
l.Reset(key)
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("Reset 后仍处于锁定状态")
}
}
// TestAttemptLimiterWindowExpiry 验证窗口外的失败记录会失效:
// 慢速爆破(每次间隔超过窗口)不应该被累计成锁定,但也不应该被当成成功。
func TestAttemptLimiterWindowExpiry(t *testing.T) {
now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
l := NewAttemptLimiter(3, 1*time.Minute, 5*time.Minute)
l.now = func() time.Time { return now }
const key = "page:1:203.0.113.10"
for i := 0; i < 5; i++ {
l.Fail(key)
now = now.Add(61 * time.Second) // 每次都越过窗口
}
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("窗口外的失败记录被计入,锁定过早触发")
}
}
// TestAttemptLimiterKeyIsolation 不同 key(不同箱主 / 不同来源 IP)必须互不影响:
// 否则一个人爆破就能把整个站点的密码入口锁死。
func TestAttemptLimiterKeyIsolation(t *testing.T) {
l := NewAttemptLimiter(2, time.Minute, time.Minute)
for i := 0; i < 2; i++ {
l.Fail("page:1:203.0.113.10")
}
if blocked, _ := l.Blocked("page:1:203.0.113.10"); !blocked {
t.Fatal("目标 key 应被锁定")
}
if blocked, _ := l.Blocked("page:1:203.0.113.11"); blocked {
t.Fatal("另一个来源 IP 被误锁")
}
if blocked, _ := l.Blocked("page:2:203.0.113.10"); blocked {
t.Fatal("另一个箱主被误锁")
}
}
+70 -17
View File
@@ -5,7 +5,9 @@ import (
"net"
"net/http"
"strings"
"sync"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
)
@@ -100,33 +102,84 @@ func isTrustedProxy(ipStr string) bool {
return false
}
// ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。
// headerSourceTrusted 判断本次请求的 TCP 对端是否有资格「决定」客户端 IP。
//
// 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 先信 CDN 权威头
// CF-Connecting-IP → EO-Client-IP,(b) 仅当 TCP 对端落在 trusted_proxies 内才从右向左
// 解析 XFF / X-Real-IP,(c) 否则回退 RemoteAddr,绝不信任客户端自供的头。
// 只有两种情况成立:
// 1. 对端是回环地址 —— cloudflared / 本机反代回源,连接必然由本机进程发起,外部造不出来;
// 2. 对端命中 trusted_proxies —— 你自己的反代 / LB,或明确登记过的 CDN 回源网段。
//
// ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造
// CF-Connecting-IP / EO-Client-IP 并被本函数信任 —— 生产开启必须锁定源站只接受 CDN 回源。
// 公网对端一律不采信,哪怕它带了格式完全合法的 CF-Connecting-IP。
// 这条约束就是 P1-3 的修复核心:双 CDN(境内 EdgeOne + 境外 Cloudflare)并存时,
// EdgeOne **不剥离**客户端自带的 CF-Connecting-IP,而旧实现无条件优先读这个头,
// 于是「经 EdgeOne 进来的请求」其客户端 IP 完全由请求方决定 —— 限流 / IP 黑名单全失效,
// 且 autoban 按这个假 IP 记账,可被用来栽赃任意正常用户(连打 10 次敏感路径即封 15 分钟)。
//
// ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)时必须:① 防火墙仅放行两家回源网段;
// ② 在各家规则引擎中删除入站请求里「对方 CDN」的权威头,否则攻击者可借道另一家伪造身份,
// 绕过限流 / 自动封禁 / 单 IP 投票限制。步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
func ResolveClientIP(r *http.Request) string {
if !conf.Security.TrustProxy {
return NormalizeIP(r.RemoteAddr)
// 反过来说,只要要求「权威头只能由 CDN 写入」这一前提无法被代码验证,就不能把它当公理;
// 唯一能被代码验证的依据是 TCP 对端地址,因此这里 fail-closed。
func headerSourceTrusted(remote string) bool {
if remote == "" {
return false
}
if ip := net.ParseIP(remote); ip != nil && ip.IsLoopback() {
return true
}
return isTrustedProxy(remote)
}
// (a) CDN 权威客户端头(只能由 CDN 边缘写入):Cloudflare 优先,其次腾讯 EdgeOne。
// cdnClientIPHeader 取请求里的 CDN 权威客户端 IP(Cloudflare 优先,其次腾讯 EdgeOne)。
// 只负责取值与规范化,不判断来源是否可信 —— 可信性由 headerSourceTrusted 负责。
func cdnClientIPHeader(r *http.Request) string {
if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" {
return cfIP
}
if eoIP := NormalizeIP(r.Header.Get("EO-Client-IP")); eoIP != "" {
return eoIP
return NormalizeIP(r.Header.Get("EO-Client-IP"))
}
// warnIgnoredCDNHeaderOnce 进程内只告警一次:避免伪造请求把日志刷满,
// 同时保证「链路与配置不匹配」这件事在上线后一定能被看见。
var warnIgnoredCDNHeaderOnce sync.Once
// ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。
//
// 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 仅当 TCP 对端可信
// (回环或 trusted_proxies 命中)才采信 CDN 权威头 CF-Connecting-IP → EO-Client-IP,
// (b) 仅当同一对端落在 trusted_proxies 内才从右向左解析 XFF / X-Real-IP,
// (c) 否则回退 RemoteAddr —— 绝不采信客户端自供的头。
//
// ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造
// CF-Connecting-IP / EO-Client-IP。本函数现已 fail-closed(公网对端不采信),
// 但仍建议用 Tunnel / 仅放行回源网段锁死源站,否则攻击者虽伪造不了身份,
// 却仍能直接打到源站。
//
// ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)并存且都直连回源时,两家回源网段
// 都必须登记到 trusted_proxies,否则该项 CDN 的权威头会被忽略、退化成按边缘 IP 聚合
// (限流互相挤占、自动封禁误伤整片区域)。重新启用 EdgeOne 时请一并核对这里。
func ResolveClientIP(r *http.Request) string {
remote := NormalizeIP(r.RemoteAddr)
if !conf.Security.TrustProxy {
return remote
}
// (b)/(c) 仅当 TCP 对端是受信代理时才解析 XFF/X-Real-IP。
remote := NormalizeIP(r.RemoteAddr)
// (a) CDN 权威客户端头:只有对端可信时才采信。
if headerSourceTrusted(remote) {
if ip := cdnClientIPHeader(r); ip != "" {
return ip
}
} else if forged := cdnClientIPHeader(r); forged != "" {
// 公网对端却带了权威头:要么是 CDN 直连回源但没登记网段,要么是有人在伪造身份。
// 只告警、不改变行为(行为已由上面的分支确定为「用 remote」),避免影响业务。
warnIgnoredCDNHeaderOnce.Do(func() {
logrus.WithFields(logrus.Fields{
"remote_addr": remote,
"header_ip": forged,
}).Warn("来自不可信公网对端的请求携带了 CDN 权威客户端头,已忽略并改用 TCP 对端地址。" +
"若站点由 CDN 直连回源(非 cloudflared Tunnel),请把该 CDN 的回源网段加入 " +
"[security] trusted_proxies;若本不该出现此类请求,说明有人在尝试伪造客户端身份以绕过限流 / 封禁。")
})
}
// (b)/(c) XFF / X-Real-IP 仍只认 trusted_proxies:这两个头是多跳链、语义更弱,
// 回环对端不隐含信任它们。
if isTrustedProxy(remote) {
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" {
// 从右向左取第一个非可信代理跳:客户端的真实 IP。
+105 -10
View File
@@ -7,13 +7,31 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
)
// setTrustedProxies 在测试内临时改 trusted_proxies 并重新初始化解析结果,
// 测试结束自动还原(trustedProxyNets 是包级状态,必须还原以免污染其它用例)。
func setTrustedProxies(t *testing.T, spec string) {
t.Helper()
old := conf.Security.TrustedProxies
conf.Security.TrustedProxies = spec
if err := InitTrustedProxies(); err != nil {
t.Fatalf("InitTrustedProxies(%q) error = %v", spec, err)
}
t.Cleanup(func() {
conf.Security.TrustedProxies = old
if err := InitTrustedProxies(); err != nil {
t.Fatalf("restore InitTrustedProxies() error = %v", err)
}
})
}
func TestResolveClientIPCloudflareHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
// 回环对端 = cloudflared / 本机反代回源,这是生产实际形态。
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "192.0.2.10:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("X-Real-IP", "198.51.100.30")
if got := ResolveClientIP(r); got != "198.51.100.20" {
@@ -21,29 +39,88 @@ func TestResolveClientIPCloudflareHeader(t *testing.T) {
}
}
// TestResolveClientIPIPv6LoopbackHeader 验证 ::1 对端同样被视为本机回源。
func TestResolveClientIPIPv6LoopbackHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "[::1]:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
if got := ResolveClientIP(r); got != "198.51.100.20" {
t.Fatalf("ResolveClientIP() = %q, want Cloudflare client IP", got)
}
}
func TestResolveClientIPIgnoresHeadersWithoutTrust(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = false
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "192.0.2.10:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("EO-Client-IP", "198.51.100.99")
if got := ResolveClientIP(r); got != "192.0.2.10" {
if got := ResolveClientIP(r); got != "127.0.0.1" {
t.Fatalf("ResolveClientIP() = %q, want remote address", got)
}
}
// TestResolveClientIPEdgeOneHeader 验证腾讯 EdgeOne 的权威客户端头 EO-Client-IP
// 在 TrustProxy=true 时被信任(双 CDN 部署:境内 EdgeOne 回源不带 CF-Connecting-IP)。
func TestResolveClientIPEdgeOneHeader(t *testing.T) {
// TestResolveClientIPRejectsForgedHeaderFromPublicPeer 是 P1-3 的回归测试。
//
// 攻击场景:双 CDN 并存时经 EdgeOne 打进来,EdgeOne 不剥离客户端自带的
// CF-Connecting-IP,旧实现无条件优先读它 → 客户端 IP 完全可控(限流 / 黑名单失效,
// 且可借 autoban 栽赃任意 IP)。修复后公网对端一律不采信权威头。
func TestResolveClientIPRejectsForgedHeaderFromPublicPeer(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
setTrustedProxies(t, "") // 未登记任何代理 / 回源网段
cases := []struct {
name string
header map[string]string
}{
{"forged CF header via EdgeOne", map[string]string{
"CF-Connecting-IP": "198.51.100.20",
"EO-Client-IP": "198.51.100.99",
}},
{"forged CF header only", map[string]string{
"CF-Connecting-IP": "198.51.100.20",
}},
{"forged EO header only", map[string]string{
"EO-Client-IP": "198.51.100.99",
}},
{"forged XFF and X-Real-IP", map[string]string{
"X-Forwarded-For": "198.51.100.20",
"X-Real-IP": "198.51.100.21",
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443" // 公网对端,不在 trusted_proxies 内
for k, v := range tc.header {
r.Header.Set(k, v)
}
if got := ResolveClientIP(r); got != "203.0.113.44" {
t.Fatalf("ResolveClientIP() = %q, want TCP remote address(不可伪造的对端地址)", got)
}
})
}
}
// TestResolveClientIPTrustedProxyHonorsCDNHeader 验证主动登记的 CDN 回源网段
// (或自己的 LB)仍可采信权威头 —— 这是「CDN 直连回源」部署形态的唯一正确配置方式。
func TestResolveClientIPTrustedProxyHonorsCDNHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
setTrustedProxies(t, "203.0.113.0/24")
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443" // 模拟 EdgeOne 边缘回源
r.RemoteAddr = "203.0.113.44:443" // 落在已登记的回源网段内
r.Header.Set("EO-Client-IP", "198.51.100.99")
r.Header.Set("X-Forwarded-For", "203.0.113.99")
if got := ResolveClientIP(r); got != "198.51.100.99" {
@@ -59,7 +136,7 @@ func TestResolveClientIPPrefersCloudflareOverEdgeOne(t *testing.T) {
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("EO-Client-IP", "198.51.100.99")
if got := ResolveClientIP(r); got != "198.51.100.20" {
@@ -75,13 +152,31 @@ func TestResolveClientIPEdgeOneInvalidFallsBack(t *testing.T) {
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("EO-Client-IP", "not-an-ip")
if got := ResolveClientIP(r); got != "203.0.113.44" {
if got := ResolveClientIP(r); got != "127.0.0.1" {
t.Fatalf("ResolveClientIP() = %q, want remote address fallback", got)
}
}
// TestResolveClientIPLoopbackDoesNotTrustXFF 锁定一条容易踩的边界:
// 回环对端只隐含信任「单一权威头」,不隐含信任 XFF / X-Real-IP(多跳链语义更弱)。
// 否则本机 cloudflared 场景下客户端只要多发一个 X-Real-IP 就能顶替权威头。
func TestResolveClientIPLoopbackDoesNotTrustXFF(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
setTrustedProxies(t, "")
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("X-Forwarded-For", "198.51.100.20")
r.Header.Set("X-Real-IP", "198.51.100.21")
if got := ResolveClientIP(r); got != "127.0.0.1" {
t.Fatalf("ResolveClientIP() = %q, want remote address(回环不应隐含信任 XFF)", got)
}
}
// TestLimitKey 是 P1-4 的回归测试:限流 / 自动封禁用的聚合键必须
// - IPv4:原样返回(每地址独立计数);
// - IPv6:掩码到 /64(防止单 /64 内轮换海量地址绕过阈值、并避免键无限膨胀);
+58
View File
@@ -0,0 +1,58 @@
package shared
import (
"net/url"
"path"
"strings"
)
// SafeRedirectTarget 校验「跳转回原页面」这类参数,只放行站内绝对路径,不可信时返回空串。
//
// 为什么不能只判 `strings.HasPrefix(to, "/") && !strings.HasPrefix(to, "//")`:
// WHATWG URL 在 special scheme(http/https)下把 `\` 视作 `/`,于是
// `Location: /\evil.example` 会被浏览器解析成 `https://evil.example/` —— 一个可用的
// 钓鱼跳板("登录后跳转异常,请重新输入密码")。浏览器在解析前还会剔除 URL 内部的
// TAB/CR/LF,所以控制字符也必须先拒掉,否则服务端的判断与浏览器的解释不一致。
//
// 全站只有两个地方接受「跳到哪」的参数(登录后的 to、页面密码通过后的 next),
// 两处都必须走这里 —— 之前它们各写一份判断,其中一份就漏了反斜杠。
//
// 返回空串表示「参数不可信」,由调用方回退到默认落地页。
func SafeRedirectTarget(to string) string {
if to == "" {
return ""
}
// 控制字符与空格:前者会被浏览器剔除后重新解析,后者会被编码成 %20 改变语义。
if strings.ContainsAny(to, "\\\x00\r\n\t ") {
return ""
}
// 必须以单个斜杠开头:排掉相对路径、协议相对(//host)、绝对 URL(scheme:)。
if !strings.HasPrefix(to, "/") || strings.HasPrefix(to, "//") {
return ""
}
u, err := url.Parse(to)
if err != nil {
return ""
}
// 解析成带 scheme / host / userinfo 的形态,说明上面两条判断被编码绕过了。
if u.Scheme != "" || u.Host != "" || u.User != nil || u.Opaque != "" {
return ""
}
// url.Parse 会把 %5c、%0d%0a 等编码解码进 Path。上面那轮检查看的是原始字符串,
// 看不到解码后的形态,所以这里对解码结果再查一遍:反斜杠(浏览器当 /)与全部
// C0 控制字符(响应头注入面,%0d%0a 能在 Location 里伪造新的一行头)。
for _, r := range u.Path {
if r == '\\' || r < 0x20 || r == 0x7f {
return ""
}
}
// 归一化后再确认一次仍是站内绝对路径(吃掉 /..、/../x 之类的残留)。
cleaned := path.Clean(u.Path)
if !strings.HasPrefix(cleaned, "/") || strings.HasPrefix(cleaned, "//") {
return ""
}
if u.RawQuery == "" {
return cleaned
}
return cleaned + "?" + u.RawQuery
}
+93
View File
@@ -0,0 +1,93 @@
package shared
import (
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestSafeRedirectTarget 锁定「跳转回原页面」参数的白名单行为。
//
// 重点是那批"看起来是站内路径、浏览器却会当外站"的载荷:反斜杠变体
// (WHATWG 在 http/https 下把 \ 当 /)、协议相对引用、以及编码后的同类载荷。
// 修复前两处调用点各写一份判断,其中登录那处用 `path.Clean("/"+to)`,只挡住 //host 与绝对 URL,
// 反斜杠直接漏到 Location 头(页面密码那处同样漏)。
func TestSafeRedirectTarget(t *testing.T) {
cases := []struct {
name string
in string
want string // 空串表示"不可信,调用方回退默认落地页"
}{
// —— 合法站内路径:应当原样(或归一化后)放行 ——
{"箱主页", "/_/someone", "/_/someone"},
{"后台", "/user", "/user"},
{"单题页带 token", "/_/someone/12?t=abc", "/_/someone/12?t=abc"},
{"根路径", "/", "/"},
{"多点归一化", "/user/../explore", "/explore"},
{"编码斜杠归一化后仍在站内", "/%2f%2fevil.example", "/evil.example"},
// —— 必须拒绝的载荷 ——
{"空串", "", ""},
{"相对路径", "user", ""},
{"反斜杠跳转", `/\evil.example`, ""},
{"双反斜杠", `\\evil.example`, ""},
{"混入反斜杠的站内路径", `/_/a\b`, ""},
{"协议相对", "//evil.example", ""},
{"三斜杠", "///evil.example", ""},
{"绝对 http", "http://evil.example", ""},
{"绝对 https", "https://evil.example", ""},
{"同协议不同站", "https:/evil.example", ""},
{"javascript 伪协议", "javascript:alert(1)", ""},
{"data 伪协议", "data:text/html,<script>alert(1)</script>", ""},
{"userinfo 形态", "/@evil.example", "/@evil.example"}, // 站内路径,host 为空
{"编码反斜杠", `/%5cevil.example`, ""},
{"大写编码反斜杠", `/%5Cevil.example`, ""},
{"CRLF 注入", "/x%0d%0aSet-Cookie:%20a=b", ""},
{"内嵌 TAB", "/\t/evil.example", ""},
{"前导空格", " /evil.example", ""},
{"尾部空格", "/evil.example ", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := SafeRedirectTarget(tc.in)
require.Equal(t, tc.want, got)
if got == "" {
return
}
// 不变式:返回值必须是浏览器解析后仍留在本站的路径。
// 只要 Host/Scheme 非空,或者以 // 开头,就会被浏览器当成外站。
require.True(t, strings.HasPrefix(got, "/"), "返回值必须以 / 开头: %q", got)
require.False(t, strings.HasPrefix(got, "//"), "返回值不能是协议相对引用: %q", got)
require.NotContains(t, got, `\`, "返回值不能含反斜杠: %q", got)
u, err := url.Parse(got)
require.NoError(t, err)
require.Empty(t, u.Host, "返回值不能带 host: %q", got)
require.Empty(t, u.Scheme, "返回值不能带 scheme: %q", got)
})
}
}
// TestSafeRedirectTargetNeverLeavesSite 是全量不变式的兜底:
// 上面表的每一个输入(含被拒绝的)都必须满足"要么空、要么是站内绝对路径"。
func TestSafeRedirectTargetNeverLeavesSite(t *testing.T) {
payloads := []string{
"/\\evil.example", `\\evil.example`, "//evil.example", "///evil.example",
"https://evil.example", "http://evil.example", "//", "/%5c%5cevil.example",
"/\u0000evil", "/\u00a0evil", "\\\\evil.example/x", "/..//evil.example",
}
for _, p := range payloads {
got := SafeRedirectTarget(p)
if got == "" {
continue
}
u, err := url.Parse(got)
require.NoError(t, err)
require.True(t, strings.HasPrefix(got, "/") && !strings.HasPrefix(got, "//"), "载荷 %q 逃逸为 %q", p, got)
require.Empty(t, u.Host)
require.Empty(t, u.Scheme)
}
}
+135 -2
View File
@@ -8,11 +8,14 @@ import (
"bytes"
stdhtml "html"
"html/template"
"net/url"
"regexp"
"strings"
"github.com/russross/blackfriday/v2"
"golang.org/x/net/html"
"github.com/tamakyi/TamaBox/internal/conf"
)
// blockEndTags / reBlockEndBrList 删除块级元素闭合标签紧前的 <br>:HardLineBreak 会把
@@ -44,7 +47,8 @@ func PlainText(markdown string) string {
// MarkdownToHTML 渲染为安全的 HTML 片段。先由 blackfriday 渲染,再经白名单过滤:
// 只保留常用排版标签与属性;丢弃 script / style / iframe / form 等危险标签;
// href/src 仅允许 http/https/mailto;剥离所有 on* 事件属性。避免存储型 XSS。
// href 仅允许 http/https/mailto,img 的 src 仅允许站内路径或配置里登记的图片主机
// (见 safeImageSrc);剥离所有 on* 事件属性。避免存储型 XSS 与外链图片追踪。
func MarkdownToHTML(markdown string) template.HTML {
if strings.TrimSpace(markdown) == "" {
return ""
@@ -155,6 +159,12 @@ func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
return
}
// img 必须有合法的 src,否则整段丢弃:只删掉 src 属性会留下 <img alt="...">,
// 浏览器渲染成空占位(破图),既难看又没意义。
if name == "img" && !imgSrcAllowed(tok.Attr) {
return
}
// 反向 tabnabbing 防护:a 标签若使用 target="_blank",强制附带 rel="noopener noreferrer"。
hasTargetBlank := false
if name == "a" {
@@ -184,7 +194,14 @@ func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
}
switch key {
case "href", "src":
if !safeURL(val) {
// img 的 src 单独收紧(P1-1):<img> 会在页面加载时无条件发起请求,
// 外链图片等于把「谁在什么时候、从哪个 IP 打开了页面」送给第三方。
// 因此只允许站内路径与自己的图片 CDN,不能复用面向 a[href] 的 safeURL。
if name == "img" && key == "src" {
if !safeImageSrc(val) {
continue
}
} else if !safeURL(val) {
continue
}
case "target":
@@ -250,3 +267,119 @@ func safeURL(u string) bool {
}
return false
}
// imgSrcAllowed 判断 img 标签是否带有一个合法的 src(缺省或非法则整段丢弃)。
func imgSrcAllowed(attrs []html.Attribute) bool {
for _, attr := range attrs {
if strings.ToLower(attr.Key) == "src" {
return safeImageSrc(attr.Val)
}
}
return false
}
// normalizeHostLike 把「配置里可能出现的各种主机写法」归一化成纯主机名:
//
// https://acc.r2.cloudflarestorage.com/tamabox → acc.r2.cloudflarestorage.com
// r2.example.net/tamabox → r2.example.net
// img.example.net:8443 → img.example.net
//
// 返回值统一小写、无端口、无路径、无尾点;无法解析时回退到去掉协议与路径的原文。
//
// 为什么必须做这一步:上传配置里的 CDN 域名普遍写成「主机 + 路径」
// (如 image_bucket_cdn_host = "r2.example.net/tamabox",项目自带的示例就是这种写法),
// 若直接拿去和 URL 的 Hostname() 比较,永远不相等 —— 表现为箱主自己上传的图片
// 也被渲染层整段丢弃(P1-1 的过度拦截)。
func normalizeHostLike(c string) string {
c = strings.ToLower(strings.TrimSpace(c))
if c == "" {
return ""
}
if !strings.Contains(c, "://") {
c = "http://" + c
}
u, err := url.Parse(c)
if err != nil || u.Hostname() == "" {
// 兜底:至少去掉路径与尾点,别把整串配置当成主机名
if i := strings.IndexAny(c, "/?#"); i >= 0 {
c = c[:i]
}
c = strings.TrimPrefix(c, "http://")
return strings.TrimSuffix(c, ".")
}
return strings.TrimSuffix(u.Hostname(), ".")
}
// imageHostAllowed 判断主机名是否属于「我们自己的图片出口」:
// 上传配置里的 CDN 域名、OSS / R2 默认域名,以及站点自身域名。
func imageHostAllowed(host string) bool {
host = normalizeHostLike(host)
if host == "" {
return false
}
candidates := []string{
conf.Upload.ImageBucketCDNHost,
conf.Upload.AliyunBucketCDNHost,
conf.App.ExternalURL,
}
// <bucket>.<endpoint> 形态:阿里云 OSS 默认域名。
// 同时把 endpoint 主机本身也纳入候选 —— R2 / MinIO 走 path-style,
// 上传返回的链接是 <endpoint>/<bucket>/...,拼 bucket 前缀反而对不上。
for _, p := range [][2]string{
{conf.Upload.AliyunBucket, conf.Upload.AliyunEndpoint},
{conf.Upload.ImageBucket, conf.Upload.ImageEndpoint},
} {
bucket, epHost := p[0], normalizeHostLike(p[1])
if epHost == "" {
continue
}
candidates = append(candidates, epHost)
if bucket != "" {
candidates = append(candidates, bucket+"."+epHost)
}
}
for _, c := range candidates {
if normalizeHostLike(c) == host {
return true
}
}
return false
}
// safeImageSrc 校验 <img src>:只允许站内相对路径,或指向自己图片出口的 http(s) URL。
//
// 背景(P1-1):safeURL 放行任意 https:// 主机,而浏览器加载页面时会无条件拉取 <img>,
// 于是匿名提问者只要塞一张唯一 URL 的外链图,箱主一打开,对方服务器就拿到了读取时间、
// 来源 IP 与 UA —— 等于给箱主装了「已读回执」。Referrer-Policy 只能挡住路径与 token,
// 挡不住这两样。公开回答里的外链图还会把每个访客的 IP 送给第三方。
// 内容审核的「广告_引流」维度只在 strict 档生效,不能作为唯一防线,所以在渲染层收口。
func safeImageSrc(u string) bool {
s := strings.TrimSpace(u)
if s == "" {
return false
}
// 浏览器把反斜杠等价于斜杠,任何含反斜杠的写法都直接拒绝(/\evil.com 等价 //evil.com)。
if strings.ContainsRune(s, '\\') {
return false
}
// 协议相对(//evil.com)必须先于「以 / 开头 = 站内」的判断。
if strings.HasPrefix(s, "//") {
return false
}
// 无协议部分 → 相对路径,必然同源,允许(/static/x.png、./x.png、x.png)。
if !strings.Contains(s, ":") {
return true
}
parsed, err := url.Parse(s)
if err != nil || parsed.Host == "" {
return false
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false
}
return imageHostAllowed(parsed.Hostname())
}
+123
View File
@@ -3,6 +3,8 @@ package template
import (
"strings"
"testing"
"github.com/tamakyi/TamaBox/internal/conf"
)
func TestMarkdownToHTML_Basic(t *testing.T) {
@@ -169,3 +171,124 @@ func TestSafeURL(t *testing.T) {
}
}
// TestSafeImageSrc 是 P1-1 的回归测试:<img src> 只允许站内相对路径与自己的图片出口。
//
// 危害:<img> 会在页面加载时无条件发起请求,匿名提问者塞一张唯一 URL 的外链图,
// 箱主一打开就暴露了读取时间 / IP / UA(等于已读回执);公开回答里的外链图还会
// 把每个访客的 IP 送给第三方。safeURL 面向 a[href],必须放行任意外站,故不能复用。
func TestSafeImageSrc(t *testing.T) {
oldCDN, oldExternal := conf.Upload.ImageBucketCDNHost, conf.App.ExternalURL
defer func() {
conf.Upload.ImageBucketCDNHost = oldCDN
conf.App.ExternalURL = oldExternal
}()
conf.Upload.ImageBucketCDNHost = "img.example.net"
conf.App.ExternalURL = "https://box.example.com"
allow := []string{
"/static/img/logo.webp",
"./local.png",
"../up.png",
"plain.png", // 无协议 → 同源相对路径
"https://img.example.net/picture/2026/x.webp",
"HTTPS://IMG.EXAMPLE.NET/picture/x.webp", // 主机名大小写不敏感
"https://img.example.net:443/picture/x.webp",
"http://box.example.com/static/x.png", // 站点自身域名
}
for _, u := range allow {
if !safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = false, 期望放行", u)
}
}
deny := []string{
"https://evil.example/x.png", // 任意外站:核心回归点
"//evil.example/x.png", // 协议相对
`\\evil.example/x.png`, // 反斜杠变体
`/\evil.example`, // 反斜杠变体(浏览器视作 //evil.example)
`https:/evil.example/x.png`, // 反斜杠 + 协议
"https://img.example.net.evil.example/x.png", // 后缀把戏
"https://[email protected]/x.png", // userinfo 把戏
"https://evil.example/img.example.net", // 域名出现在路径里
"javascript:alert(1)",
"data:image/png;base64,iVBORw0KGgo=",
"ftp://img.example.net/x.png",
"",
}
for _, u := range deny {
if safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = true, 期望拒绝", u)
}
}
}
// TestSafeImageSrc_RealConfiguredHosts 锁住「配置写成 host/path」这种真实形态。
//
// 回归点:只按 url.Parse(c).Host 判断配置是否带协议时,对
// "r2.example.net/tamabox" 这种写法得到空 Host,于是整串被当成主机名去比较,
// 永远不相等 —— 表现为箱主自己上传的图片也被渲染层整段丢弃(过度拦截)。
// 项目自带的 image_bucket_cdn_host 示例正是 host/path 写法,所以这不是边角情况。
func TestSafeImageSrc_RealConfiguredHosts(t *testing.T) {
old := conf.Upload
defer func() { conf.Upload = old }()
conf.Upload.ImageBucketCDNHost = "r2.example.net/tamabox"
conf.Upload.ImageBucket = "tamabox"
conf.Upload.ImageEndpoint = "https://acc123.r2.cloudflarestorage.com/tamabox"
conf.Upload.AliyunBucketCDNHost = ""
conf.Upload.AliyunBucket = ""
conf.Upload.AliyunEndpoint = ""
allow := []string{
"https://r2.example.net/tamabox/a.png", // CDN 配置写成 host/path
"https://acc123.r2.cloudflarestorage.com/tamabox/a.png", // path-style S3(R2 实际返回形态)
"https://tamabox.acc123.r2.cloudflarestorage.com/a.png", // virtual-hosted 形态
}
for _, u := range allow {
if !safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = false, 期望放行(自有图片出口)", u)
}
}
deny := []string{
"https://acc123.r2.cloudflarestorage.com.evil.example/a.png", // 后缀把戏
"https://evil.example/tamabox/a.png",
"https://r2.example.net.evil.example/tamabox/a.png",
}
for _, u := range deny {
if safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = true, 期望拒绝(非自有图片出口)", u)
}
}
}
// TestMarkdownToHTML_ImageSource 端到端锁定渲染结果:外链图片必须整段消失,
// 站内图片正常保留;同时确认 a[href] 的外站链接不受影响(需求上允许)。
func TestMarkdownToHTML_ImageSource(t *testing.T) {
oldCDN := conf.Upload.ImageBucketCDNHost
defer func() { conf.Upload.ImageBucketCDNHost = oldCDN }()
conf.Upload.ImageBucketCDNHost = "img.example.net"
external := string(MarkdownToHTML(`![a](https://tb-track.example.org/unique.png)`))
if strings.Contains(external, "tb-track.example.org") {
t.Fatalf("外链图片未被剥离:%s", external)
}
if strings.Contains(external, "<img") {
t.Fatalf("外链图片仍渲染出 <img>:%s", external)
}
rawHTML := string(MarkdownToHTML(`<img src="https://tb-track.example.org/raw.png">`))
if strings.Contains(rawHTML, "tb-track.example.org") {
t.Fatalf("原始 <img> 外链未被剥离:%s", rawHTML)
}
internal := string(MarkdownToHTML(`![a](https://img.example.net/picture/x.webp)`))
if !strings.Contains(internal, `<img src="https://img.example.net/picture/x.webp"`) {
t.Fatalf("站内图片被误删:%s", internal)
}
link := string(MarkdownToHTML(`[x](https://external.example.org/page)`))
if !strings.Contains(link, `href="https://external.example.org/page"`) {
t.Fatalf("a[href] 外链被误删:%s", link)
}
}