安全: 修复页面密码零值绕过,导出不再泄露投票者 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:
tamakyi
2026-11-05 16:31:54 +08:00
parent 25aee35030
commit 7ffab24025
7 changed files with 264 additions and 34 deletions
+11 -12
View File
@@ -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)。
+35 -18
View File
@@ -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{
+126
View File
@@ -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)
}
})
}
}
+4 -4
View File
@@ -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,