Files
tamabox/route/question/ask_history.go
T
tamakyi 7ffab24025 安全: 修复页面密码零值绕过,导出不再泄露投票者 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,输对密码后放行、改密后旧会话立即失效,
删除迁移账本后重启可正确回填。
2026-11-05 16:31:54 +08:00

162 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 question
import (
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
)
// 一次最多查询多少条。浏览器里存的是「这台设备上提过的全部问题」,
// 正常用户不会超过几条;设上限是为了让这个公开接口不能被当成批量探测器用。
const askStatusMaxItems = 20
type askStatusItem struct {
ID uint `json:"id"`
Token string `json:"token"`
}
type askStatusRequest struct {
Domain string `json:"domain"`
Items []askStatusItem `json:"items"`
}
type askStatusResult struct {
ID uint `json:"id"`
OK bool `json:"ok"` // token 校验通过(false 表示记录已失效,前端应删除)
Answered bool `json:"answered"` // 已有回答(追问不计)
Excerpt string `json:"excerpt"` // 正文摘要,让用户一眼认出是哪一条
Created int64 `json:"created"` // 提问时间(unix 秒),前端按本地时区显示
}
// askExcerptRuneLimit 摘要长度(按 rune 计,不按字节 —— 按字节会在多字节字符中间切断)。
const askExcerptRuneLimit = 60
// askExcerpt 取正文前若干字符作为摘要。
//
// 先转纯文本再截断:正文是 markdown,直接截原文会在列表里露出
// `![图片](https://...)` 这种噪音,一条带图的提问几乎只剩链接。
func askExcerpt(content string) string {
r := []rune(strings.TrimSpace(templatepkg.PlainText(content)))
if len(r) <= askExcerptRuneLimit {
return string(r)
}
return string(r[:askExcerptRuneLimit]) + "…"
}
// AskStatusAPI 批量查询「这些提问里哪些已经被回答」。
//
// 存在的理由:不填邮箱的提问者,唯一的追踪手段是提问成功页上那条链接。
// 关掉页面就等于永久失联 —— 箱主认真答了,提问者永远不知道。
// 前端把 {domain, id, token} 存在 localStorage,再回到箱主页时问一次这个接口,
// 就能提示「你有 2 条提问已被回答」。不引入账号,也不在服务端建立访客追踪。
//
// 安全性:必须同时给出正确的 token 才返回 ok=true,token 是 12 位随机串,
// 且比较走常数时间(与 Pager 中间件同口径),所以这个接口不能用来枚举
// 「某箱主有多少提问」「某个 id 是否存在」—— 错误的组合一律 ok=false,
// 与「提问不存在」「不属于该箱主」「token 不对」三种情况无法区分。
func AskStatusAPI(ctx context.Context) error {
// 路由只注册了 POST(token 不能进 URL),这段是防御:万一将来有人把
// 路由改成 f.Any / f.Combo,GET 也不该走到解析 body 那一步。
if ctx.Request().Method != http.MethodPost {
return ctx.JSONError(40500, http.StatusText(http.StatusMethodNotAllowed))
}
var req askStatusRequest
if err := json.NewDecoder(ctx.Request().Request.Body).Decode(&req); err != nil {
return ctx.JSONError(40000, i18n.T(ctx.Lang(), "internal.errBadRequest"))
}
if req.Domain == "" || len(req.Items) == 0 {
return ctx.JSONError(40000, i18n.T(ctx.Lang(), "internal.errBadRequest"))
}
if len(req.Items) > askStatusMaxItems {
req.Items = req.Items[:askStatusMaxItems]
}
owner, err := db.Users.GetByDomain(ctx.Request().Context(), req.Domain)
if err != nil {
if errors.Is(err, db.ErrUserNotExists) {
// 箱主不存在时,也按请求的 items 原样回显 ok=false,与「箱主存在但
// 校验失败」保持完全同形。否则「空数组 vs 带元素数组」会泄露 domain
// 是否存在(含被 L1 从探索页隐藏、或设了页面密码的半公开箱)。
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})
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get box owner for ask status")
return ctx.ServerError()
}
// SEC-06:与 ListAPI(route/question/page.go)对齐,补上 L4 和页面密码检查。
// 否则持有正确问题 token 的请求者可以绕过页面访问边界,取得回答状态、
// 创建时间和前 60 字摘要。
// 两者都返回「用户不存在」以隐藏真实原因(不暴露处置状态与密码设置)。
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})
}
// 处置查询失败时不拦(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)。
ids := make([]uint, 0, len(req.Items))
for _, it := range req.Items {
if it.ID != 0 {
ids = append(ids, it.ID)
}
}
questions, err := db.Questions.ListByIDsAndUser(ctx.Request().Context(), ids, owner.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list questions for ask status")
return ctx.ServerError()
}
byID := make(map[uint]*db.Question, len(questions))
for _, q := range questions {
byID[q.ID] = q
}
out := make([]askStatusResult, 0, len(req.Items))
for _, it := range req.Items {
res := askStatusResult{ID: it.ID}
if q, found := byID[it.ID]; found && q.Token != "" && it.Token != "" &&
subtle.ConstantTimeCompare([]byte(q.Token), []byte(it.Token)) == 1 {
res.OK = true
res.Answered = q.Answer != ""
// token 已校验通过,返回摘要不额外泄露任何东西 —— 提问者本来就能用
// 同一个 token 打开全文。
res.Excerpt = askExcerpt(q.Content)
res.Created = q.CreatedAt.Unix()
}
out = append(out, res)
}
return ctx.JSON(map[string]interface{}{"items": out})
}