安全: 修复页面密码零值绕过,导出不再泄露投票者 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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user