安全: 修复页面密码零值绕过,导出不再泄露投票者 IP
页面密码(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,输对密码后放行、改密后旧会话立即失效, 删除迁移账本后重启可正确回填。
This commit is contained in:
+35
-18
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user