From 7802bb20788c11faa7ab4fafd2c437bcafcbca01 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 27 Oct 2026 12:37:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E4=BF=AE=E5=A4=8D=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=AE=A1=E8=AE=A1=E7=9A=84=20P1/P2/P3=20=E9=A1=B9?= =?UTF-8?q?=EF=BC=8C=E5=90=AB=E5=85=A8=E5=B1=80=E5=A4=8D=E6=9F=A5=E6=96=B0?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E7=9A=84=E4=B8=A4=E5=A4=84=E8=B6=8A=E6=9D=83?= =?UTF-8?q?=E4=B8=8E=E5=A4=96=E8=B7=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ./... 全通过。 --- conf/app.sample.ini | 66 +++++++++++--- internal/conf/static.go | 23 ++++- internal/context/context.go | 8 ++ internal/db/ban_record.go | 74 +++++++++++++++ internal/db/ban_record_test.go | 56 ++++++++++++ internal/db/db.go | 3 + internal/form/user.go | 12 ++- internal/i18n/dict_en.go | 38 ++++---- internal/i18n/dict_ja.go | 38 ++++---- internal/i18n/dict_zh_cn.go | 38 ++++---- internal/i18n/dict_zh_tw.go | 38 ++++---- internal/route/chat/chat_handler.go | 11 +++ internal/route/csp.go | 134 +++++++++++++++++++++++++++ internal/route/csp_test.go | 120 ++++++++++++++++++++++++ internal/route/ratelimit.go | 28 +++++- internal/route/route.go | 67 +++++++++++++- internal/shared/attempt.go | 114 +++++++++++++++++++++++ internal/shared/attempt_test.go | 96 +++++++++++++++++++ internal/shared/ip.go | 87 ++++++++++++++---- internal/shared/ip_test.go | 115 +++++++++++++++++++++-- internal/shared/redirect.go | 58 ++++++++++++ internal/shared/redirect_test.go | 93 +++++++++++++++++++ internal/template/markdown.go | 137 +++++++++++++++++++++++++++- internal/template/markdown_test.go | 123 +++++++++++++++++++++++++ route/auth/login.go | 5 +- route/auth/register.go | 33 ++++++- route/question/page.go | 68 +++++++++++--- route/question/question.go | 4 +- route/user/explore.go | 7 ++ route/user/password.go | 25 +++++ templates/user/profile.html | 4 +- templates/vote/page.html | 1 + 32 files changed, 1579 insertions(+), 145 deletions(-) create mode 100644 internal/db/ban_record.go create mode 100644 internal/db/ban_record_test.go create mode 100644 internal/route/csp.go create mode 100644 internal/route/csp_test.go create mode 100644 internal/shared/attempt.go create mode 100644 internal/shared/attempt_test.go create mode 100644 internal/shared/redirect.go create mode 100644 internal/shared/redirect_test.go diff --git a/conf/app.sample.ini b/conf/app.sample.ini index c8cca5b..4000be0 100644 --- a/conf/app.sample.ini +++ b/conf/app.sample.ini @@ -66,19 +66,61 @@ trust_proxy = false ; ; —— 双 CDN(境内腾讯 EdgeOne + 境外 Cloudflare)场景 —— ; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP), - ; 本程序在 trust_proxy=true 时已同时信任这两个权威头。 - ; 两家 CDN 并存时必须:① 防火墙仅放行两家回源网段;② 在各家 CDN 的规则引擎中 - ; 删除回源请求里「对方 CDN」的权威头(经 EdgeOne 回源删 CF-Connecting-IP、 - ; 经 Cloudflare 回源删 EO-Client-IP),否则攻击者可借道另一家伪造客户端身份, - ; 绕过限流 / 自动封禁 / 单 IP 投票限制。详细步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。 + ; 本程序在 trust_proxy = true 时同时支持这两个权威头。 + ; ⚠️ 权威头只在「TCP 对端可信」时才被采信(回环地址,或命中 trusted_proxies)。 + ; 这条是代码里的硬约束,不再依赖运维纪律 —— EdgeOne 不剥离客户端自带的 CF-Connecting-IP, + ; 旧实现无条件优先读它,导致经 EdgeOne 进来的请求客户端 IP 完全可控: + ; 限流 / IP 黑名单失效,且 autoban 按假 IP 记账可被用来栽赃任意正常用户。 + ; 双 CDN 并存时:① 两家回源网段都登记到 trusted_proxies;② 防火墙仅放行两家回源网段; + ; ③ 仍建议在各家规则引擎里删掉「对方 CDN」的权威头(纵深防御)。 + ; 仅用 cloudflared Tunnel 回本机时对端是 127.0.0.1,无需任何额外配置。 + ; 重新启用 EdgeOne 前请先按本节核对,步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。 trusted_proxies = - ; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 127.0.0.1、 - ; 10.0.0.0/8)。仅当 TCP 对端落在该清单内时才解析 X-Forwarded-For / X-Real-IP。 - ; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP / - ; EO-Client-IP(需源站已锁定)。 - ; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。 -; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP + ; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 10.0.0.0/8), + ; 或「CDN 直连回源」形态下该 CDN 的回源网段。 + ; 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx + ; 回源的连接必然由本机进程发起,外部无法伪造。 + ; 除回环以外,CDN 权威头也必须对端命中本清单才会被采信 —— 公网对端带来的 + ; CF-Connecting-IP / EO-Client-IP 一律忽略,改用 TCP 对端地址。 + ; 仅当 TCP 对端命中本清单时才解析 X-Forwarded-For / X-Real-IP。 + ; 注意:此清单用于你自己的基础设施(含你选定 CDN 的回源网段), + ; 不是 CDN 的全量 CIDR 清单。 +; 内容安全策略(CSP)模式:off / report / enforce,留空等同 report。 +csp = enforce + ; enforce(默认):真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与 + ; [recaptcha] 的域名自动派生,另内置模板实际引用的 cdnjs.snrat.com + ; (UIkit/Alpine/dayjs/vConsole)、challenges.cloudflare.com 与 umami 统计域; + ; 配了 sentry_dsn 时自动放行 browser.sentry-cdn.com。 + ; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go, + ; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。 + ; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器 + ; 控制台(配了 csp_report_uri 才上报)。不给上报地址时它等于没装, + ; 所以只在「怀疑误伤、想先看证据」时临时用。 + ; off:完全不下发(排障逃生口)。 + ; 默认值的前提是白名单已用真浏览器逐页巡检过: + ; outputs/sec-audit-2026-10-25/csp_sweep.py(20 个页面零违规)。 + ; 上线后发现误伤:把这里改成 report 或 off 再重启,不用改代码。 + ; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN), + ; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。 + ; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器 + ; 控制台(配了 csp_report_uri 才上报)—— 用于上线前观察一周再切 enforce。 + ; enforce:真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与 [recaptcha] 的域名 + ; 自动派生,另内置模板实际引用的 cdnjs.snrat.com(UIkit/Alpine/dayjs/vConsole) + ; 与 challenges.cloudflare.com。 + ; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go, + ; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。 + ; off:完全不下发(排障逃生口)。 + ; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN), + ; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。 +; CSP 违规上报地址(可选)。留空时违规只进浏览器控制台,不上报。 +csp_report_uri = + ; ⚠️ 限流与自动封禁的计数器都在进程内存里(internal/route/ratelimit.go、 + ; internal/security/autoban)。单副本部署(当前形态:cloudflared Tunnel 回一台机器) + ; 下工作正常;一旦改成多副本 / 多机,每个副本各有一份计数(实际额度 × 副本数), + ; 进程重启即清零。多副本前必须先把计数迁到 Redis。 + ; 封禁「事实」已落库(ban_records 表:键 / 原因 / 时长 / 到期时间), + ; 重启丢的只是生效状态,账本还在。 enable_auto_ban = true ; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务 ban_track_notfound = false @@ -313,4 +355,4 @@ timezone = Asia/Shanghai default = default available = default,sakura,shiroko,new-sakura ; ⚠️ 没登记在 available 里的主题,即使 css 已随二进制发布也不会出现在设置页; -; 启动日志会打出「[theme] available 未登记以下主题」提醒。,shiroko +; 启动日志会打出「[theme] available 未登记以下主题」提醒。 diff --git a/internal/conf/static.go b/internal/conf/static.go index 19694f4..e6d65b2 100644 --- a/internal/conf/static.go +++ b/internal/conf/static.go @@ -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 { diff --git a/internal/context/context.go b/internal/context/context.go index 7fe3543..bb7eb01 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -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{}{ diff --git a/internal/db/ban_record.go b/internal/db/ban_record.go new file mode 100644 index 0000000..4662f03 --- /dev/null +++ b/internal/db/ban_record.go @@ -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 +} diff --git a/internal/db/ban_record_test.go b/internal/db/ban_record_test.go new file mode 100644 index 0000000..364b2d3 --- /dev/null +++ b/internal/db/ban_record_test.go @@ -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) +} diff --git a/internal/db/db.go b/internal/db/db.go index 5801fc9..6dd647b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 上;结构体已删掉这些字段, diff --git a/internal/form/user.go b/internal/form/user.go index 0960566..df9de1e 100644 --- a/internal/form/user.go +++ b/internal/form/user.go @@ -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"` // 留空表示关闭 } diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index 2fd010a..54a6963 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -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", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index e10eea4..f0c09b5 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -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": "返信を受け取るための有効なメールアドレスを入力してください", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index ce330f6..0906434 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -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": "请填写有效的电子邮箱地址以接收回复", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index b759bb7..16d10df 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -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": "請填寫有效的電子郵件地址以接收回覆", diff --git a/internal/route/chat/chat_handler.go b/internal/route/chat/chat_handler.go index 1be7c66..667f18d 100644 --- a/internal/route/chat/chat_handler.go +++ b/internal/route/chat/chat_handler.go @@ -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 { diff --git a/internal/route/csp.go b/internal/route/csp.go new file mode 100644 index 0000000..1e51adb --- /dev/null +++ b/internal/route/csp.go @@ -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 让「即使注入了 ", ""}, + {"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) + } +} diff --git a/internal/template/markdown.go b/internal/template/markdown.go index 1ebc8c6..565b470 100644 --- a/internal/template/markdown.go +++ b/internal/template/markdown.go @@ -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 删除块级元素闭合标签紧前的
: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 属性会留下 ..., + // 浏览器渲染成空占位(破图),既难看又没意义。 + 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): 会在页面加载时无条件发起请求, + // 外链图片等于把「谁在什么时候、从哪个 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, + } + // . 形态:阿里云 OSS 默认域名。 + // 同时把 endpoint 主机本身也纳入候选 —— R2 / MinIO 走 path-style, + // 上传返回的链接是 //...,拼 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 校验 :只允许站内相对路径,或指向自己图片出口的 http(s) URL。 +// +// 背景(P1-1):safeURL 放行任意 https:// 主机,而浏览器加载页面时会无条件拉取 , +// 于是匿名提问者只要塞一张唯一 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()) +} diff --git a/internal/template/markdown_test.go b/internal/template/markdown_test.go index 8ea1af0..fa74a4c 100644 --- a/internal/template/markdown_test.go +++ b/internal/template/markdown_test.go @@ -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 的回归测试: 只允许站内相对路径与自己的图片出口。 +// +// 危害: 会在页面加载时无条件发起请求,匿名提问者塞一张唯一 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://img.example.net@evil.example/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, ":%s", external) + } + + rawHTML := string(MarkdownToHTML(``)) + if strings.Contains(rawHTML, "tb-track.example.org") { + t.Fatalf("原始 外链未被剥离:%s", rawHTML) + } + + internal := string(MarkdownToHTML(`![a](https://img.example.net/picture/x.webp)`)) + if !strings.Contains(internal, ` 本来就可探测, + // 遮蔽只会让用户以为自己注册成功、后续登不上又找不到原因。 case errors.Is(err, db.ErrDuplicateDomain): ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateDomain"))) - // 已停用账号仍保留身份占位(占着邮箱/域名唯一索引),不再静默通过或抛 500, - // 而是明确告知用户该身份已被占用、可由站长核验后恢复。 - case errors.Is(err, db.ErrDeactivatedEmail): - ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedEmail"))) case errors.Is(err, db.ErrDeactivatedDomain): ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedDomain"))) case errors.Is(err, db.ErrBadCredential): @@ -88,6 +102,15 @@ func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.Re return } + registerSucceeded(ctx) +} + +// registerSucceeded 是注册接口唯一的"成功"出口。 +// +// 抽成函数是为了让"真的建成功了"和"因为邮箱已存在而没建"走同一条路径 —— +// 一旦两处分别写,响应体的措辞、跳转目标、flash 文案迟早会漂移出差异, +// 差异就是枚举信号。新增失败分支时若想遮蔽,直接调用本函数即可。 +func registerSucceeded(ctx context.Context) { // 成功提示跟随用户注册时选择的语言 lang := "zh-CN" if l, ok := ctx.Data["Lang"].(string); ok && l != "" { diff --git a/route/question/page.go b/route/question/page.go index 874e480..fda8d40 100644 --- a/route/question/page.go +++ b/route/question/page.go @@ -217,6 +217,14 @@ func formatFeatureTime(t *time.Time) string { return t.Format("2006-01-02 15:04") } +// pagePasswordGuard 页面访问密码的爆破防护(P1-2)。 +// +// 现实参数:该入口限流 10 次/分钟/IP,4 位纯数字(10^4)约 17 小时即可穷举完, +// 而页面密码正是产品对外宣传的访问控制。改成「10 分钟内错 5 次 → 锁 10 分钟」后, +// 同样的穷举被拉到数周量级,且锁定期间根本不比对密码。 +// 锁定期满会自动清零(不会一次误触就永久锁死),语义见 shared.AttemptLimiter。 +var pagePasswordGuard = shared.NewAttemptLimiter(5, 10*time.Minute, 10*time.Minute) + func PasswordPage(ctx context.Context) { domain := ctx.Param("domain") pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain) @@ -243,17 +251,33 @@ func VerifyPagePassword(ctx context.Context) { ctx.Redirect("/") return } + + // 爆破防护(P1-2):同一「箱主 + 来源 IP」在窗口内错够次数就锁一段时间。 + // 键里带 pageUser.ID,避免攻击者用自己箱子的失败次数把别人的密码入口锁死。 + // 锁定期间直接拒绝、不比对密码,免得每次失败都白算一遍哈希。 + guardKey := fmt.Sprintf("page_pwd:%d:%s", pageUser.ID, shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request))) + if blocked, remain := pagePasswordGuard.Blocked(guardKey); blocked { + minutes := int(remain/time.Minute) + 1 + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errPasswordLocked", minutes)) + ctx.Redirect("/_/" + domain + "/password") + return + } + password := ctx.Request().FormValue("password") if pageUser.CheckPagePassword(password) { + pagePasswordGuard.Reset(guardKey) passKey := fmt.Sprintf("page_pass_%d", pageUser.ID) ctx.Session.Set(passKey, true) - // 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页 - next := ctx.Request().FormValue("next") - if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.Contains(next, "://") { + // 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。 + // 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」, + // 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。 + next := shared.SafeRedirectTarget(ctx.Request().FormValue("next")) + if next == "" { next = "/_/" + domain } ctx.Redirect(next) } else { + pagePasswordGuard.Fail(guardKey) ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errWrongPassword")) ctx.Redirect("/_/" + domain + "/password") } @@ -341,16 +365,25 @@ func ListAPI(ctx context.Context) error { return ctx.JSON(resp) } -func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) { - - // 提问密码检查 - if pageUser.AskPassword != "" { - if !pageUser.CheckAskPassword(f.AskPassword) { - ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f) - ctx.Success("question/list") - return - } +// askPasswordDenied 校验提问密码,失败时写入表单错误并返回 true(调用方直接 return)。 +// +// ⚠️ 必须排在验证码校验之后调用。旧实现把它放在 New() 的第一行,于是「猜密码」的请求 +// 完全不消耗验证码:实测错误密码 + 完全无效的验证码,返回的是「提问密码错误」, +// 验证码在这个入口等于不存在 —— 30 次/分钟 × 无成本,4 位数字约 6 小时可破。 +// 挪到验证码之后,每次猜密码都要先解一道题(验证码接口自身还有 20/分钟/IP 的限额)。 +func askPasswordDenied(ctx context.Context, pageUser *db.User, f form.NewQuestion) bool { + if pageUser.AskPassword == "" { + return false } + if pageUser.CheckAskPassword(f.AskPassword) { + return false + } + ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f) + ctx.Success("question/list") + return true +} + +func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) { // 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。 // 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。 @@ -458,6 +491,12 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r return } + // 提问密码:验证码通过之后才比对(见 askPasswordDenied 注释)。 + // 私聊与提问共用同一个提问密码,这里不能漏,否则发起私聊可绕过它。 + if askPasswordDenied(ctx, pageUser, f) { + return + } + // 同 IP + 相同内容重复拦截(防刷屏) if !shared.DuplicateGuard.Allow(shared.DuplicateKey(shared.ResolveClientIP(ctx.Request().Request), f.Content)) { ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errDuplicateSubmit")) @@ -578,6 +617,11 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r return } + // 提问密码:验证码通过之后才比对(见 askPasswordDenied 注释)。 + if askPasswordDenied(ctx, pageUser, f) { + return + } + content := f.Content isPrivate := f.IsPrivate != "" diff --git a/route/question/question.go b/route/question/question.go index b25e013..37b7298 100644 --- a/route/question/question.go +++ b/route/question/question.go @@ -6,6 +6,7 @@ package question import ( stdctx "context" + "crypto/subtle" "fmt" "net/url" "unicode/utf8" @@ -62,7 +63,8 @@ func Questioner(ctx context.Context, pageUser *db.User) { if question.UserID != pageUser.ID || ((question.Answer == "" || question.IsPrivate) && (!ctx.IsLogged || ctx.User.ID != question.UserID) && - !(question.Token != "" && question.Token == token)) { + !(question.Token != "" && + subtle.ConstantTimeCompare([]byte(question.Token), []byte(token)) == 1)) { ctx.Redirect("/") return } diff --git a/route/user/explore.go b/route/user/explore.go index c7372ed..c95e2f0 100644 --- a/route/user/explore.go +++ b/route/user/explore.go @@ -143,7 +143,14 @@ func Explore(ctx context.Context, c cache.Cache) { pageStr := ctx.Query("page") page := 1 if pageStr != "" { + // 翻页深度上限:原来只校验 > 0,于是 page=2147483647 会让 Postgres 先算出 + // 一个巨大的 OFFSET 再丢掉全部结果 —— 一次请求换一次廉价慢查询。 + // 500 × 20 = 10000 个用户,远超探索页的真实翻页需求。 + const maxPage = 500 if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + if p > maxPage { + p = maxPage + } page = p } } diff --git a/route/user/password.go b/route/user/password.go index 8466e33..fd3c9e9 100644 --- a/route/user/password.go +++ b/route/user/password.go @@ -5,6 +5,8 @@ package user import ( + "unicode/utf8" + "github.com/sirupsen/logrus" "github.com/tamakyi/TamaBox/internal/context" @@ -13,6 +15,23 @@ import ( "github.com/tamakyi/TamaBox/internal/i18n" ) +// rejectTooShortPassword 二次校验密码最小长度(P1-2)。 +// +// form 层已经有 minlen 标签,这里再挡一次是为了防止将来出现绕过 form.Bind 的调用点 +// —— 密码强度一旦漏掉就是「页面访问密码形同虚设」,值得重复一道。 +// 按 rune 计数,与 govalid 的 minlen 口径一致(中文密码不会被按字节数放大)。 +func rejectTooShortPassword(ctx context.Context, password string) bool { + if password == "" { + return false // 留空 = 关闭该密码 + } + if utf8.RuneCountInString(password) >= form.PasswordMinLen { + return false + } + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "form.profile.passwordLen")) + ctx.Redirect(returnTab(ctx)) + return true +} + // UpdatePagePassword 更新页面访问密码 func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) { // 反序列化失败(如请求体超限)时零值 Password="" 会把密码静默解除——先中止, @@ -21,6 +40,9 @@ func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) { if abortOnFormError(ctx, returnTab(ctx)) { return } + if rejectTooShortPassword(ctx, f.Password) { + return + } if err := db.Users.UpdatePagePassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update page password") @@ -42,6 +64,9 @@ func UpdateAskPassword(ctx context.Context, f form.UpdateAskPassword) { if abortOnFormError(ctx, returnTab(ctx)) { return } + if rejectTooShortPassword(ctx, f.Password) { + return + } if err := db.Users.UpdateAskPassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update ask password") diff --git a/templates/user/profile.html b/templates/user/profile.html index 4f77b40..5eef9a8 100644 --- a/templates/user/profile.html +++ b/templates/user/profile.html @@ -897,7 +897,7 @@ {{t $.LangCode "admin.pagePassword"}}
- +
@@ -912,7 +912,7 @@ {{t $.LangCode "admin.askPassword"}}
- +
diff --git a/templates/vote/page.html b/templates/vote/page.html index 4434b6c..5f09c10 100644 --- a/templates/vote/page.html +++ b/templates/vote/page.html @@ -268,6 +268,7 @@ {{ if .ShowResults }}

{{t .LangCode "vote.page.resultsTitle"}}

+
{{t .LangCode "vote.page.anonymousNotice"}}
{{ if .Results }} {{ if or (eq .Results.Type "single") (eq .Results.Type "multiple") }}