`))
+ if strings.Contains(rawHTML, "tb-track.example.org") {
+ t.Fatalf("原始
本来就可探测,
+ // 遮蔽只会让用户以为自己注册成功、后续登不上又找不到原因。
case errors.Is(err, db.ErrDuplicateDomain):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateDomain")))
- // 已停用账号仍保留身份占位(占着邮箱/域名唯一索引),不再静默通过或抛 500,
- // 而是明确告知用户该身份已被占用、可由站长核验后恢复。
- case errors.Is(err, db.ErrDeactivatedEmail):
- ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedEmail")))
case errors.Is(err, db.ErrDeactivatedDomain):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedDomain")))
case errors.Is(err, db.ErrBadCredential):
@@ -88,6 +102,15 @@ func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.Re
return
}
+ registerSucceeded(ctx)
+}
+
+// registerSucceeded 是注册接口唯一的"成功"出口。
+//
+// 抽成函数是为了让"真的建成功了"和"因为邮箱已存在而没建"走同一条路径 ——
+// 一旦两处分别写,响应体的措辞、跳转目标、flash 文案迟早会漂移出差异,
+// 差异就是枚举信号。新增失败分支时若想遮蔽,直接调用本函数即可。
+func registerSucceeded(ctx context.Context) {
// 成功提示跟随用户注册时选择的语言
lang := "zh-CN"
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
diff --git a/route/question/page.go b/route/question/page.go
index 874e480..fda8d40 100644
--- a/route/question/page.go
+++ b/route/question/page.go
@@ -217,6 +217,14 @@ func formatFeatureTime(t *time.Time) string {
return t.Format("2006-01-02 15:04")
}
+// pagePasswordGuard 页面访问密码的爆破防护(P1-2)。
+//
+// 现实参数:该入口限流 10 次/分钟/IP,4 位纯数字(10^4)约 17 小时即可穷举完,
+// 而页面密码正是产品对外宣传的访问控制。改成「10 分钟内错 5 次 → 锁 10 分钟」后,
+// 同样的穷举被拉到数周量级,且锁定期间根本不比对密码。
+// 锁定期满会自动清零(不会一次误触就永久锁死),语义见 shared.AttemptLimiter。
+var pagePasswordGuard = shared.NewAttemptLimiter(5, 10*time.Minute, 10*time.Minute)
+
func PasswordPage(ctx context.Context) {
domain := ctx.Param("domain")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
@@ -243,17 +251,33 @@ func VerifyPagePassword(ctx context.Context) {
ctx.Redirect("/")
return
}
+
+ // 爆破防护(P1-2):同一「箱主 + 来源 IP」在窗口内错够次数就锁一段时间。
+ // 键里带 pageUser.ID,避免攻击者用自己箱子的失败次数把别人的密码入口锁死。
+ // 锁定期间直接拒绝、不比对密码,免得每次失败都白算一遍哈希。
+ guardKey := fmt.Sprintf("page_pwd:%d:%s", pageUser.ID, shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)))
+ if blocked, remain := pagePasswordGuard.Blocked(guardKey); blocked {
+ minutes := int(remain/time.Minute) + 1
+ ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errPasswordLocked", minutes))
+ ctx.Redirect("/_/" + domain + "/password")
+ return
+ }
+
password := ctx.Request().FormValue("password")
if pageUser.CheckPagePassword(password) {
+ pagePasswordGuard.Reset(guardKey)
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
ctx.Session.Set(passKey, true)
- // 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页
- next := ctx.Request().FormValue("next")
- if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.Contains(next, "://") {
+ // 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。
+ // 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」,
+ // 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。
+ next := shared.SafeRedirectTarget(ctx.Request().FormValue("next"))
+ if next == "" {
next = "/_/" + domain
}
ctx.Redirect(next)
} else {
+ pagePasswordGuard.Fail(guardKey)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errWrongPassword"))
ctx.Redirect("/_/" + domain + "/password")
}
@@ -341,16 +365,25 @@ func ListAPI(ctx context.Context) error {
return ctx.JSON(resp)
}
-func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) {
-
- // 提问密码检查
- if pageUser.AskPassword != "" {
- if !pageUser.CheckAskPassword(f.AskPassword) {
- ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f)
- ctx.Success("question/list")
- return
- }
+// askPasswordDenied 校验提问密码,失败时写入表单错误并返回 true(调用方直接 return)。
+//
+// ⚠️ 必须排在验证码校验之后调用。旧实现把它放在 New() 的第一行,于是「猜密码」的请求
+// 完全不消耗验证码:实测错误密码 + 完全无效的验证码,返回的是「提问密码错误」,
+// 验证码在这个入口等于不存在 —— 30 次/分钟 × 无成本,4 位数字约 6 小时可破。
+// 挪到验证码之后,每次猜密码都要先解一道题(验证码接口自身还有 20/分钟/IP 的限额)。
+func askPasswordDenied(ctx context.Context, pageUser *db.User, f form.NewQuestion) bool {
+ if pageUser.AskPassword == "" {
+ return false
}
+ if pageUser.CheckAskPassword(f.AskPassword) {
+ return false
+ }
+ ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f)
+ ctx.Success("question/list")
+ return true
+}
+
+func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) {
// 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。
// 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。
@@ -458,6 +491,12 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
+ // 提问密码:验证码通过之后才比对(见 askPasswordDenied 注释)。
+ // 私聊与提问共用同一个提问密码,这里不能漏,否则发起私聊可绕过它。
+ if askPasswordDenied(ctx, pageUser, f) {
+ return
+ }
+
// 同 IP + 相同内容重复拦截(防刷屏)
if !shared.DuplicateGuard.Allow(shared.DuplicateKey(shared.ResolveClientIP(ctx.Request().Request), f.Content)) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errDuplicateSubmit"))
@@ -578,6 +617,11 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
+ // 提问密码:验证码通过之后才比对(见 askPasswordDenied 注释)。
+ if askPasswordDenied(ctx, pageUser, f) {
+ return
+ }
+
content := f.Content
isPrivate := f.IsPrivate != ""
diff --git a/route/question/question.go b/route/question/question.go
index b25e013..37b7298 100644
--- a/route/question/question.go
+++ b/route/question/question.go
@@ -6,6 +6,7 @@ package question
import (
stdctx "context"
+ "crypto/subtle"
"fmt"
"net/url"
"unicode/utf8"
@@ -62,7 +63,8 @@ func Questioner(ctx context.Context, pageUser *db.User) {
if question.UserID != pageUser.ID ||
((question.Answer == "" || question.IsPrivate) &&
(!ctx.IsLogged || ctx.User.ID != question.UserID) &&
- !(question.Token != "" && question.Token == token)) {
+ !(question.Token != "" &&
+ subtle.ConstantTimeCompare([]byte(question.Token), []byte(token)) == 1)) {
ctx.Redirect("/")
return
}
diff --git a/route/user/explore.go b/route/user/explore.go
index c7372ed..c95e2f0 100644
--- a/route/user/explore.go
+++ b/route/user/explore.go
@@ -143,7 +143,14 @@ func Explore(ctx context.Context, c cache.Cache) {
pageStr := ctx.Query("page")
page := 1
if pageStr != "" {
+ // 翻页深度上限:原来只校验 > 0,于是 page=2147483647 会让 Postgres 先算出
+ // 一个巨大的 OFFSET 再丢掉全部结果 —— 一次请求换一次廉价慢查询。
+ // 500 × 20 = 10000 个用户,远超探索页的真实翻页需求。
+ const maxPage = 500
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
+ if p > maxPage {
+ p = maxPage
+ }
page = p
}
}
diff --git a/route/user/password.go b/route/user/password.go
index 8466e33..fd3c9e9 100644
--- a/route/user/password.go
+++ b/route/user/password.go
@@ -5,6 +5,8 @@
package user
import (
+ "unicode/utf8"
+
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
@@ -13,6 +15,23 @@ import (
"github.com/tamakyi/TamaBox/internal/i18n"
)
+// rejectTooShortPassword 二次校验密码最小长度(P1-2)。
+//
+// form 层已经有 minlen 标签,这里再挡一次是为了防止将来出现绕过 form.Bind 的调用点
+// —— 密码强度一旦漏掉就是「页面访问密码形同虚设」,值得重复一道。
+// 按 rune 计数,与 govalid 的 minlen 口径一致(中文密码不会被按字节数放大)。
+func rejectTooShortPassword(ctx context.Context, password string) bool {
+ if password == "" {
+ return false // 留空 = 关闭该密码
+ }
+ if utf8.RuneCountInString(password) >= form.PasswordMinLen {
+ return false
+ }
+ ctx.SetErrorFlash(i18n.T(ctx.Lang(), "form.profile.passwordLen"))
+ ctx.Redirect(returnTab(ctx))
+ return true
+}
+
// UpdatePagePassword 更新页面访问密码
func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) {
// 反序列化失败(如请求体超限)时零值 Password="" 会把密码静默解除——先中止,
@@ -21,6 +40,9 @@ func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) {
if abortOnFormError(ctx, returnTab(ctx)) {
return
}
+ if rejectTooShortPassword(ctx, f.Password) {
+ return
+ }
if err := db.Users.UpdatePagePassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update page password")
@@ -42,6 +64,9 @@ func UpdateAskPassword(ctx context.Context, f form.UpdateAskPassword) {
if abortOnFormError(ctx, returnTab(ctx)) {
return
}
+ if rejectTooShortPassword(ctx, f.Password) {
+ return
+ }
if err := db.Users.UpdateAskPassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update ask password")
diff --git a/templates/user/profile.html b/templates/user/profile.html
index 4f77b40..5eef9a8 100644
--- a/templates/user/profile.html
+++ b/templates/user/profile.html
@@ -897,7 +897,7 @@