From 7ffab240259872c9eda98b066f10cebc767793d1 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 5 Nov 2026 16:31:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=85=A8:=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=AF=86=E7=A0=81=E9=9B=B6=E5=80=BC=E7=BB=95?= =?UTF-8?q?=E8=BF=87=EF=BC=8C=E5=AF=BC=E5=87=BA=E4=B8=8D=E5=86=8D=E6=B3=84?= =?UTF-8?q?=E9=9C=B2=E6=8A=95=E7=A5=A8=E8=80=85=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面密码(P0,2026-11-05 复核发现) - 旧实现 `ver, _ := Session.Get(key).(uint); if ver != user.PagePasswordVer` 把「session 里没有授权记录」(断言失败得 0)与「新增列默认值 0」判成相等, 升级瞬间所有存量页面密码静默失效:匿名访客不带 cookie 即可读箱主页。 - 收口为 pagePasswordAuthorized:缺 key 一律视为未授权, key 名由 pagePassKey 统一生成,Pager / ListAPI / AskStatusAPI 三处共用。 - 新增迁移 v6 回填 page_password_ver / ask_password_ver(仅仍为 0 且已设密码的行, 幂等),让模型语义与「改过一次密码」一致,并作废升级前的布尔授权。 - AskStatusAPI 的 L4 查询补日志(保持与 Pager 一致的 fail-open 口径)。 导出(P1) - JSON 导出的投票提交不再输出 voter_ip。库里照常保留, 防刷、取证与站方 CLI 处置不受影响,只是不进箱主的自助导出文件。 测试 - route/question/page_password_test.go:8 个用例钉住零值/类型不符/轮换失效等边界。 - internal/db/page_password_migration_test.go:回填只碰该碰的行且幂等。 验证:WSL2 Debian + PostgreSQL 全量测试通过;隔离实例实测 ver=0 匿名访问返回密码页、API 401,输对密码后放行、改密后旧会话立即失效, 删除迁移账本后重启可正确回填。 --- internal/db/migrations.go | 7 ++ internal/db/page_password_migration_test.go | 58 +++++++++ internal/db/users.go | 23 ++++ route/question/ask_history.go | 23 ++-- route/question/page.go | 53 +++++--- route/question/page_password_test.go | 126 ++++++++++++++++++++ route/user/profile.go | 8 +- 7 files changed, 264 insertions(+), 34 deletions(-) create mode 100644 internal/db/page_password_migration_test.go create mode 100644 route/question/page_password_test.go diff --git a/internal/db/migrations.go b/internal/db/migrations.go index dd683fc..0fb15f0 100644 --- a/internal/db/migrations.go +++ b/internal/db/migrations.go @@ -67,6 +67,13 @@ var migrations = []Migration{ Name: "normalize harassment setting", Up: normalizeHarassmentSetting, }, + { + // 新增列 AutoMigrate 只补 0,而 0 与「无授权」的零值撞车 —— 必须回填, + // 否则升级后存量用户的页面密码会被静默绕过。 + Version: 6, + Name: "backfill page password version", + Up: backfillPagePasswordVer, + }, } // runMigrations 按版本号升序执行尚未应用的迁移。 diff --git a/internal/db/page_password_migration_test.go b/internal/db/page_password_migration_test.go new file mode 100644 index 0000000..053cf05 --- /dev/null +++ b/internal/db/page_password_migration_test.go @@ -0,0 +1,58 @@ +package db + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestBackfillPagePasswordVer 覆盖 2026-11-05 那个 P0 的数据面: +// 新增列 AutoMigrate 只补 0,而 0 恰好等于「session 里没有授权记录」的零值, +// 不回填的话存量页面密码在升级瞬间全部失效。 +func TestBackfillPagePasswordVer(t *testing.T) { + conn, clean := newTestDB(t) + defer clean("users") + + mustCreate := func(domain string, pagePassword string, pageVer uint, askPassword string, askVer uint) *User { + u := &User{ + Name: domain, + Email: domain + "@example.com", + Password: "x", + Domain: domain, + PagePassword: pagePassword, + PagePasswordVer: pageVer, + AskPassword: askPassword, + AskPasswordVer: askVer, + } + require.NoError(t, conn.Create(u).Error) + return u + } + + // 存量:有密码但版本号仍为默认 0 —— 必须被回填。 + legacy := mustCreate("legacy", "hashed-page", 0, "hashed-ask", 0) + // 已轮换过密码:版本号非 0,不能被重置。 + rotated := mustCreate("rotated", "hashed-page", 3, "hashed-ask", 5) + // 没有密码:版本号保持 0,回填不应凭空造出授权。 + noPassword := mustCreate("nopassword", "", 0, "", 0) + + require.NoError(t, backfillPagePasswordVer(conn)) + + var gotLegacy, gotRotated, gotNoPassword User + require.NoError(t, conn.First(&gotLegacy, legacy.ID).Error) + require.NoError(t, conn.First(&gotRotated, rotated.ID).Error) + require.NoError(t, conn.First(&gotNoPassword, noPassword.ID).Error) + + require.EqualValues(t, 1, gotLegacy.PagePasswordVer, "存量页面密码应回填为 1") + require.EqualValues(t, 1, gotLegacy.AskPasswordVer, "存量提问密码应回填为 1") + + require.EqualValues(t, 3, gotRotated.PagePasswordVer, "已轮换的版本号不能被覆盖") + require.EqualValues(t, 5, gotRotated.AskPasswordVer, "已轮换的版本号不能被覆盖") + + require.EqualValues(t, 0, gotNoPassword.PagePasswordVer, "没设密码的账号不应被写版本号") + require.EqualValues(t, 0, gotNoPassword.AskPasswordVer, "没设密码的账号不应被写版本号") + + // 幂等:再跑一次不应把已回填的行推到 2。 + require.NoError(t, backfillPagePasswordVer(conn)) + require.NoError(t, conn.First(&gotLegacy, legacy.ID).Error) + require.EqualValues(t, 1, gotLegacy.PagePasswordVer, "迁移必须幂等") +} diff --git a/internal/db/users.go b/internal/db/users.go index ad49849..9ec2cd0 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -885,6 +885,29 @@ func (db *users) GetHotTags(ctx context.Context, limit int) ([]string, error) { return hot, nil } +// backfillPagePasswordVer 给「已设置密码但版本号仍为 0」的存量账号补一次版本号。 +// +// 背景:PagePasswordVer / AskPasswordVer 是 2026-11-05 新增的列,AutoMigrate 只补默认值 0。 +// 而 0 同时是「session 里没有授权记录」时类型断言的零值,两者相等会让页面密码判定恒为通过 +// (见 route/question/page.go 的 pagePasswordAuthorized 注释)。 +// 把存量行统一置 1:语义与「改过一次密码」一致,升级前发出的旧授权(布尔 true) +// 也会因类型不符而自然作废,用户重输一次密码即可。 +// +// 幂等:只更新仍为 0 的行,重复执行不会把版本号越推越大。 +func backfillPagePasswordVer(db *gorm.DB) error { + if err := db.Model(&User{}). + Where("page_password <> '' AND page_password_ver = 0"). + Update("page_password_ver", 1).Error; err != nil { + return errors.Wrap(err, "backfill page password version") + } + if err := db.Model(&User{}). + Where("ask_password <> '' AND ask_password_ver = 0"). + Update("ask_password_ver", 1).Error; err != nil { + return errors.Wrap(err, "backfill ask password version") + } + return nil +} + func (db *users) UpdatePagePassword(ctx context.Context, id uint, password string) error { user, err := db.GetByID(ctx, id) if err != nil { diff --git a/route/question/ask_history.go b/route/question/ask_history.go index 9961d22..6b8b27a 100644 --- a/route/question/ask_history.go +++ b/route/question/ask_history.go @@ -7,7 +7,6 @@ package question import ( "crypto/subtle" "encoding/json" - "fmt" "net/http" "strings" @@ -106,23 +105,23 @@ func AskStatusAPI(ctx context.Context) error { // 否则持有正确问题 token 的请求者可以绕过页面访问边界,取得回答状态、 // 创建时间和前 60 字摘要。 // 两者都返回「用户不存在」以隐藏真实原因(不暴露处置状态与密码设置)。 - if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), owner.ID); err == nil && level == db.RestrictionL4 { + emptyResult := func() error { out := make([]askStatusResult, 0, len(req.Items)) for _, it := range req.Items { out = append(out, askStatusResult{ID: it.ID}) } return ctx.JSON(map[string]interface{}{"items": out}) } - if owner.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == owner.ID) { - passKey := fmt.Sprintf("page_pass_%d", owner.ID) - sessionVer, _ := ctx.Session.Get(passKey).(uint) - if sessionVer != owner.PagePasswordVer { - out := make([]askStatusResult, 0, len(req.Items)) - for _, it := range req.Items { - out = append(out, askStatusResult{ID: it.ID}) - } - return ctx.JSON(map[string]interface{}{"items": out}) - } + // 处置查询失败时不拦(fail-open),与 Pager / ListAPI 的既定口径一致: + // 这一层不是内容外传通道,DB 异常时再 fail-closed 只会让所有提问箱一起白屏。 + // 与它们唯一的差别是这里补了日志,不再静默吞掉错误。 + if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), owner.ID); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction for ask status") + } else if level == db.RestrictionL4 { + return emptyResult() + } + if !pagePasswordAuthorized(ctx, owner) { + return emptyResult() } // 收集合法 ID 并一次查回(IN 查询,避免逐条 N+1)。 diff --git a/route/question/page.go b/route/question/page.go index ae98274..31f9646 100644 --- a/route/question/page.go +++ b/route/question/page.go @@ -85,16 +85,12 @@ func Pager(ctx context.Context) { ctx.Data["ThemeSlug"] = theme.Resolve(pageUser.Theme) // 页面密码检查:session 中保存授权时的 PagePasswordVer 版本号, // 与当前版本号比对,修改密码后旧授权立即失效(SEC-06)。 - if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) { - passKey := fmt.Sprintf("page_pass_%d", pageUser.ID) - sessionVer, _ := ctx.Session.Get(passKey).(uint) - if sessionVer != pageUser.PagePasswordVer { - // 记录原始请求地址(含 query,如带 token 的单题链接),验证通过后跳回此处而非默认首页 - ctx.Data["Next"] = ctx.Request().URL.RequestURI() - ctx.Data["PageUser"] = pageUser - ctx.Success("question/password") - return - } + if !pagePasswordAuthorized(ctx, pageUser) { + // 记录原始请求地址(含 query,如带 token 的单题链接),验证通过后跳回此处而非默认首页 + ctx.Data["Next"] = ctx.Request().URL.RequestURI() + ctx.Data["PageUser"] = pageUser + ctx.Success("question/password") + return } // 分页加载公开已回答的问题(首次加载 20 条,置顶优先) @@ -257,6 +253,32 @@ func PasswordPage(ctx context.Context) { ctx.Success("question/password") } +// pagePasswordAuthorized 判断当前访客是否已通过该箱主的页面密码校验。 +// +// ⚠️ 这里是页面密码的唯一判定入口,HTML 页面与列表 API 都必须走它,不要各写一份。 +// +// 关键点:session 里没有对应键时必须判为**未授权**。 +// 曾用 `sessionVer, _ := ctx.Session.Get(key).(uint); if sessionVer != user.PagePasswordVer` +// 的写法:断言失败同样得到零值 0,而 PagePasswordVer 对存量用户也是 0(新增列默认值), +// 于是 0 != 0 为假 —— 升级瞬间所有既有页面密码静默失效,匿名访客无需密码即可看箱主页。 +// 因此必须先用 ok 断言「确实存在授权记录」,再比较版本号。 +func pagePasswordAuthorized(ctx context.Context, owner *db.User) bool { + if owner.PagePassword == "" { + return true + } + // 箱主本人不受页面密码限制。 + if ctx.IsLogged && ctx.User.ID == owner.ID { + return true + } + ver, ok := ctx.Session.Get(pagePassKey(owner.ID)).(uint) + return ok && ver == owner.PagePasswordVer +} + +// pagePassKey 是页面密码授权在 session 里的键名,读写两侧共用,避免格式漂移。 +func pagePassKey(userID uint) string { + return fmt.Sprintf("page_pass_%d", userID) +} + func VerifyPagePassword(ctx context.Context) { domain := ctx.Param("domain") pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain) @@ -279,9 +301,8 @@ func VerifyPagePassword(ctx context.Context) { password := ctx.Request().FormValue("password") if pageUser.CheckPagePassword(password) { pagePasswordGuard.Reset(guardKey) - passKey := fmt.Sprintf("page_pass_%d", pageUser.ID) // 保存当前密码版本号到 session,修改密码后旧授权自动失效(SEC-06) - ctx.Session.Set(passKey, pageUser.PagePasswordVer) + ctx.Session.Set(pagePassKey(pageUser.ID), pageUser.PagePasswordVer) // 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。 // 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」, // 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。 @@ -331,12 +352,8 @@ func ListAPI(ctx context.Context) error { if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID); err == nil && level == db.RestrictionL4 { return ctx.JSONError(40400, i18n.T(ctx.Lang(), "auth.errUserNotFound")) } - if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) { - passKey := fmt.Sprintf("page_pass_%d", pageUser.ID) - sessionVer, _ := ctx.Session.Get(passKey).(uint) - if sessionVer != pageUser.PagePasswordVer { - return ctx.JSONError(40100, i18n.T(ctx.Lang(), "password.desc")) - } + if !pagePasswordAuthorized(ctx, pageUser) { + return ctx.JSONError(40100, i18n.T(ctx.Lang(), "password.desc")) } pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{ diff --git a/route/question/page_password_test.go b/route/question/page_password_test.go new file mode 100644 index 0000000..1cf3954 --- /dev/null +++ b/route/question/page_password_test.go @@ -0,0 +1,126 @@ +package question + +import ( + "net/http" + "testing" + + "github.com/tamakyi/TamaBox/internal/context" + "github.com/tamakyi/TamaBox/internal/db" +) + +// stubSession 只实现 pagePasswordAuthorized 用得到的 Get/Set,其余方法给空实现。 +type stubSession struct { + data map[interface{}]interface{} +} + +func newStubSession() *stubSession { + return &stubSession{data: map[interface{}]interface{}{}} +} + +func (s *stubSession) ID() string { return "test-session" } +func (s *stubSession) RegenerateID(http.ResponseWriter, *http.Request) error { + return nil +} +func (s *stubSession) Get(key interface{}) interface{} { return s.data[key] } +func (s *stubSession) Set(key, val interface{}) { s.data[key] = val } +func (s *stubSession) SetFlash(val interface{}) {} +func (s *stubSession) Delete(key interface{}) { delete(s.data, key) } +func (s *stubSession) Flush() { s.data = map[interface{}]interface{}{} } +func (s *stubSession) Encode() ([]byte, error) { return nil, nil } +func (s *stubSession) HasChanged() bool { return false } + +// TestPagePasswordAuthorized 是 2026-11-05 P0 的回归用例。 +// +// 旧实现 `ver, _ := Session.Get(key).(uint); if ver != user.PagePasswordVer` +// 把「session 里没有授权记录」(断言失败 → 0)与「版本号默认值 0」判成相等, +// 于是升级瞬间所有存量页面密码失效——匿名访客不带任何 cookie 就能看箱主页。 +// 这里把「无 key + ver=0」钉成必须拒绝。 +func TestPagePasswordAuthorized(t *testing.T) { + const userID = 7 + + cases := []struct { + name string + pagePassword string + pageVer uint + sessionFill func(*stubSession) // 预置 session,nil 表示完全没有授权键 + logged bool + viewerID uint + want bool + }{ + { + name: "未设页面密码时直接放行", + pagePassword: "", + pageVer: 0, + want: true, + }, + { + name: "存量账号 ver=0 且 session 无授权键必须拒绝", + pagePassword: "hashed", + pageVer: 0, + want: false, + }, + { + name: "升级前留下的布尔授权(类型不符)作废", + pagePassword: "hashed", + pageVer: 0, + sessionFill: func(s *stubSession) { s.Set(pagePassKey(userID), true) }, + want: false, + }, + { + name: "版本号一致的授权通过", + pagePassword: "hashed", + pageVer: 1, + sessionFill: func(s *stubSession) { s.Set(pagePassKey(userID), uint(1)) }, + want: true, + }, + { + name: "改过密码后旧版本授权失效", + pagePassword: "hashed", + pageVer: 2, + sessionFill: func(s *stubSession) { s.Set(pagePassKey(userID), uint(1)) }, + want: false, + }, + { + name: "session 里是别的类型(如字符串)一律拒绝", + pagePassword: "hashed", + pageVer: 1, + sessionFill: func(s *stubSession) { s.Set(pagePassKey(userID), "1") }, + want: false, + }, + { + name: "箱主本人不受页面密码限制", + pagePassword: "hashed", + pageVer: 9, + logged: true, + viewerID: userID, + want: true, + }, + { + name: "其他登录用户仍需通过密码", + pagePassword: "hashed", + pageVer: 9, + logged: true, + viewerID: userID + 1, + want: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + session := newStubSession() + if c.sessionFill != nil { + c.sessionFill(session) + } + ctx := context.Context{ + Session: session, + IsLogged: c.logged, + User: &db.User{ID: c.viewerID}, + } + owner := &db.User{ID: userID, PagePassword: c.pagePassword, PagePasswordVer: c.pageVer} + + if got := pagePasswordAuthorized(ctx, owner); got != c.want { + t.Fatalf("pagePasswordAuthorized() = %v, want %v", got, c.want) + } + }) + } +} diff --git a/route/user/profile.go b/route/user/profile.go index c423a32..3f36af2 100644 --- a/route/user/profile.go +++ b/route/user/profile.go @@ -694,9 +694,10 @@ func ExportProfileJSON(ctx context.Context) { CreatedAt time.Time `json:"created_at"` } type exportedVoteSubmission struct { - ID uint `json:"id"` - OptionID uint `json:"option_id"` - VoterIP string `json:"voter_ip"` + ID uint `json:"id"` + OptionID uint `json:"option_id"` + // 参与者 IP 属于第三方个人数据,不随箱主自助导出外流。 + // 库里照常保留(防刷、取证、站方 CLI 处置都要用),只是不进这份给箱主的文件。 FormData datatypes.JSON `json:"form_data"` CustomText string `json:"custom_text"` CreatedAt time.Time `json:"created_at"` @@ -818,7 +819,6 @@ func ExportProfileJSON(ctx context.Context) { exportSubs = append(exportSubs, exportedVoteSubmission{ ID: s.ID, OptionID: s.OptionID, - VoterIP: s.VoterIP, FormData: s.FormData, CustomText: s.CustomText, CreatedAt: s.CreatedAt,