fix(security): 修复安全审计的 P1/P2/P3 项,含全局复查新发现的两处越权与外跳

P1:
- 外链图片:markdown 渲染层只放行站内与自有 CDN 图片。配置里的
  image_bucket_cdn_host 常写成 host/path,统一归一化后再比对,避免把自家图一起剥掉
- 密码:页面/提问密码最小 6 位;密码比对挪到验证码之后;页面密码失败锁定 5 次/10 分钟
- 客户端 IP:CF-Connecting-IP / EO-Client-IP 只在 TCP 对端可信(回环或 trusted_proxies)
  时采信,公网对端带来的权威头一律忽略

P2/P3:
- 跳转参数收敛到 shared.SafeRedirectTarget:拒反斜杠与控制字符(url.Parse 会把 %0d%0a
  解成 CRLF,伪造 Location),最后再用 path.Clean 验一次前缀
- 注册不再暴露「邮箱已注册 / 已停用」:与全新注册共用同一个成功出口,只在日志留痕
- CSP:默认 enforce,白名单由配置派生,覆盖 umami(运行时注入)、Turnstile 与
  按 sentry_dsn 条件放行的 Sentry CDN
- /user 与 /_ 下发 no-store;补 X-XSS-Protection、Permissions-Policy,并把安全头前移到
  静态资源中间件之前(原先静态文件拿不到 nosniff/HSTS)
- 封禁事实落库 ban_records;429 统一带 Retry-After 与本地化文案
- token 比较改常量时间;探索页页码上限;JSON 响应显式 Content-Type

全局复查补的两处:
- 私聊 SendMessage 的 POST 缺 conv.OwnerID 校验,被拉黑者可换 domain 继续发消息
- 页面密码 next 与登录 to 同源的反斜杠外跳

验收:verify_p1_fixes 4/4、verify_round2 19/19、verify_page_next 6/6、
verify_chat_owner 3/3(含变异对照);单测与 go build ./... 全通过。
This commit is contained in:
tamakyi
2026-10-27 12:37:40 +08:00
parent a3cf99a8da
commit 7802bb2078
32 changed files with 1579 additions and 145 deletions
+21 -17
View File
@@ -16,21 +16,22 @@ var enDict = map[string]string{
"nav.lang": "Language",
// Common
"common.login": "Log in",
"common.forgot": "Forgot password",
"common.submit": "Submit",
"common.cancel": "Cancel",
"common.save": "Save",
"common.close": "Close",
"common.delete": "Delete",
"common.moveUp": "Move up",
"common.moveDown": "Move down",
"common.share": "Share",
"common.verifying": "Verifying...",
"common.submitting": "Submitting...",
"common.uploading": "Uploading...",
"common.lang": "Language",
"common.menu": "Menu",
"common.login": "Log in",
"common.forgot": "Forgot password",
"common.submit": "Submit",
"common.cancel": "Cancel",
"common.save": "Save",
"common.tooManyRequests": "Too many requests. Please try again later.",
"common.close": "Close",
"common.delete": "Delete",
"common.moveUp": "Move up",
"common.moveDown": "Move down",
"common.share": "Share",
"common.verifying": "Verifying...",
"common.submitting": "Submitting...",
"common.uploading": "Uploading...",
"common.lang": "Language",
"common.menu": "Menu",
"common.pleaseWait": "Please wait...",
"common.backHome": "Back to Home",
@@ -182,9 +183,9 @@ var enDict = map[string]string{
"admin.tagInputHint": "Type and press Enter or comma to add",
"admin.tagInputDuplicate": "Already exists, ignored",
"admin.pagePassword": "Page access password",
"admin.pagePasswordHint": "Set a page password (leave empty to disable)",
"admin.pagePasswordHint": "Set a page password (6–30 characters, leave empty to disable)",
"admin.askPassword": "Ask password",
"admin.askPasswordHint": "Set a password for asking (leave empty to disable)",
"admin.askPasswordHint": "Set a password for asking (6–30 characters, leave empty to disable)",
"admin.saveProfile": "Save changes",
"admin.updated": "Profile updated successfully",
"admin.logout": "Log out",
@@ -619,6 +620,7 @@ var enDict = map[string]string{
"vote.page.submit": "Submit vote",
"vote.page.alreadyVoted": "You have already participated in this poll. Thanks!",
"vote.page.resultsTitle": "Poll Results",
"vote.page.anonymousNotice": "Anonymous poll — one vote per person, results are indicative only.",
"vote.page.totalVotes": "Total",
"vote.page.avgRating": "Average",
"vote.page.avgRank": "Avg rank",
@@ -757,6 +759,7 @@ var enDict = map[string]string{
// Question box page: visitor-visible (P0)
"question.errWrongPassword": "Wrong password",
"question.errPasswordLocked": "Too many wrong attempts. Please try again in %d minutes.",
"question.errAskPasswordWrong": "Wrong ask password",
"question.errLoginRequired": "The owner only allows registered users to ask questions. Please log in first.",
"question.errSelfChat": "You cannot start a chat with yourself",
@@ -926,6 +929,7 @@ var enDict = map[string]string{
"form.profile.name": "Nickname is required and must be at most 20 characters",
"form.profile.newPassword": "New password must be at most 30 characters",
"form.profile.passwordMax": "Password must be at most 30 characters",
"form.profile.passwordLen": "Please set a password of 6–30 characters",
"form.question.content": "Question content is required",
"form.question.contentTooLong": "Question content must be within %d characters",
"form.question.receiveReplyEmail": "Please enter a valid email address to receive replies",
+21 -17
View File
@@ -16,21 +16,22 @@ var jaDict = map[string]string{
"nav.lang": "言語",
// 共通
"common.login": "ログイン",
"common.forgot": "パスワードを忘れた",
"common.submit": "送信",
"common.cancel": "キャンセル",
"common.save": "保存",
"common.close": "閉じる",
"common.delete": "削除",
"common.moveUp": "上へ移動",
"common.moveDown": "下へ移動",
"common.share": "共有",
"common.verifying": "認証中...",
"common.submitting": "送信中...",
"common.uploading": "アップロード中...",
"common.lang": "言語",
"common.menu": "メニュー",
"common.login": "ログイン",
"common.forgot": "パスワードを忘れた",
"common.submit": "送信",
"common.cancel": "キャンセル",
"common.save": "保存",
"common.tooManyRequests": "リクエストが多すぎます。しばらくしてから再度お試しください。",
"common.close": "閉じる",
"common.delete": "削除",
"common.moveUp": "上へ移動",
"common.moveDown": "下へ移動",
"common.share": "共有",
"common.verifying": "認証中...",
"common.submitting": "送信中...",
"common.uploading": "アップロード中...",
"common.lang": "言語",
"common.menu": "メニュー",
"common.pleaseWait": "お待ちください...",
"common.backHome": "ホームに戻る",
@@ -182,9 +183,9 @@ var jaDict = map[string]string{
"admin.tagInputHint": "入力して Enter またはカンマで追加",
"admin.tagInputDuplicate": "既に存在するため無視しました",
"admin.pagePassword": "ページアクセスパスワード",
"admin.pagePasswordHint": "アクセスパスワードを設定(空欄で無効)",
"admin.pagePasswordHint": "アクセスパスワードを設定(6〜30文字、空欄で無効)",
"admin.askPassword": "質問パスワード",
"admin.askPasswordHint": "質問用パスワードを設定(空欄で無効)",
"admin.askPasswordHint": "質問用パスワードを設定(6〜30文字、空欄で無効)",
"admin.saveProfile": "変更を保存",
"admin.updated": "プロフィールを更新しました",
"admin.logout": "ログアウト",
@@ -618,6 +619,7 @@ var jaDict = map[string]string{
"vote.page.submit": "投票する",
"vote.page.alreadyVoted": "この投票にはすでに参加済みです。ありがとうございます!",
"vote.page.resultsTitle": "投票結果",
"vote.page.anonymousNotice": "匿名投票(お一人一票)のため、結果は参考値です。",
"vote.page.totalVotes": "総参加",
"vote.page.avgRating": "平均点",
"vote.page.avgRank": "平均順位",
@@ -756,6 +758,7 @@ var jaDict = map[string]string{
// 質問箱ページ:訪問者に表示(P0)
"question.errWrongPassword": "パスワードが正しくありません",
"question.errPasswordLocked": "パスワードの誤入力が多すぎます。%d 分後にもう一度お試しください。",
"question.errAskPasswordWrong": "質問用パスワードが正しくありません",
"question.errLoginRequired": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
"question.errSelfChat": "自分自身とのチャットは開始できません",
@@ -925,6 +928,7 @@ var jaDict = map[string]string{
"form.profile.name": "ニックネームは必須で、20文字以内である必要があります",
"form.profile.newPassword": "新しいパスワードは30文字以内である必要があります",
"form.profile.passwordMax": "パスワードは30文字以内である必要があります",
"form.profile.passwordLen": "パスワードは6〜30文字で設定してください",
"form.question.content": "質問内容を入力してください",
"form.question.contentTooLong": "質問内容は%d文字以内にしてください",
"form.question.receiveReplyEmail": "返信を受け取るための有効なメールアドレスを入力してください",
+21 -17
View File
@@ -16,21 +16,22 @@ var zhCNDict = map[string]string{
"nav.lang": "语言",
// 通用
"common.login": "登录",
"common.forgot": "忘记密码",
"common.submit": "提交",
"common.cancel": "取消",
"common.save": "保存",
"common.close": "关闭",
"common.delete": "删除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "验证中...",
"common.submitting": "提交中...",
"common.uploading": "上传中...",
"common.lang": "语言",
"common.menu": "菜单",
"common.login": "登录",
"common.forgot": "忘记密码",
"common.submit": "提交",
"common.cancel": "取消",
"common.save": "保存",
"common.tooManyRequests": "请求过于频繁,请稍后再试。",
"common.close": "关闭",
"common.delete": "删除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "验证中...",
"common.submitting": "提交中...",
"common.uploading": "上传中...",
"common.lang": "语言",
"common.menu": "菜单",
"common.pleaseWait": "请稍候...",
"common.backHome": "返回首页",
@@ -182,9 +183,9 @@ var zhCNDict = map[string]string{
"admin.tagInputHint": "输入后按回车或逗号添加",
"admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "页面访问密码",
"admin.pagePasswordHint": "设置访问密码(留空则关闭)",
"admin.pagePasswordHint": "设置访问密码(6–30 位,留空则关闭)",
"admin.askPassword": "提问密码",
"admin.askPasswordHint": "设置提问密码(留空则关闭)",
"admin.askPasswordHint": "设置提问密码(6–30 位,留空则关闭)",
"admin.saveProfile": "保存修改",
"admin.updated": "更新个人信息成功",
"admin.logout": "退出登录",
@@ -619,6 +620,7 @@ var zhCNDict = map[string]string{
"vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已参与过该投票,感谢参与!",
"vote.page.resultsTitle": "投票结果",
"vote.page.anonymousNotice": "匿名投票,每人一票,结果仅供参考。",
"vote.page.totalVotes": "总参与",
"vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名",
@@ -757,6 +759,7 @@ var zhCNDict = map[string]string{
// 提问箱页:访客可见(P0)
"question.errWrongPassword": "密码错误",
"question.errPasswordLocked": "密码错误次数过多,请 %d 分钟后再试",
"question.errAskPasswordWrong": "提问密码错误",
"question.errLoginRequired": "提问箱的主人设置了仅注册用户才能提问,请先登录。",
"question.errSelfChat": "不能给自己发起私聊",
@@ -938,6 +941,7 @@ var zhCNDict = map[string]string{
"form.profile.name": "昵称不能为空,且不超过 20 个字符",
"form.profile.newPassword": "新密码长度不能超过 30 个字符",
"form.profile.passwordMax": "密码长度不能超过 30 个字符",
"form.profile.passwordLen": "请设置 6 至 30 位的密码",
"form.question.content": "问题内容不能为空",
"form.question.contentTooLong": "问题内容不能超过 %d 个字符",
"form.question.receiveReplyEmail": "请填写有效的电子邮箱地址以接收回复",
+21 -17
View File
@@ -16,21 +16,22 @@ var zhTWDict = map[string]string{
"nav.lang": "語言",
// 通用
"common.login": "登入",
"common.forgot": "忘記密碼",
"common.submit": "送出",
"common.cancel": "取消",
"common.save": "儲存",
"common.close": "關閉",
"common.delete": "刪除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "驗證中...",
"common.submitting": "提交中...",
"common.uploading": "上傳中...",
"common.lang": "語言",
"common.menu": "選單",
"common.login": "登入",
"common.forgot": "忘記密碼",
"common.submit": "送出",
"common.cancel": "取消",
"common.save": "儲存",
"common.tooManyRequests": "請求過於頻繁,請稍後再試。",
"common.close": "關閉",
"common.delete": "刪除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "驗證中...",
"common.submitting": "提交中...",
"common.uploading": "上傳中...",
"common.lang": "語言",
"common.menu": "選單",
"common.pleaseWait": "請稍候...",
"common.backHome": "返回首頁",
@@ -182,9 +183,9 @@ var zhTWDict = map[string]string{
"admin.tagInputHint": "輸入後按 Enter 或逗號新增",
"admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "頁面訪問密碼",
"admin.pagePasswordHint": "設定訪問密碼(留空則關閉)",
"admin.pagePasswordHint": "設定訪問密碼(6–30 位,留空則關閉)",
"admin.askPassword": "提問密碼",
"admin.askPasswordHint": "設定提問密碼(留空則關閉)",
"admin.askPasswordHint": "設定提問密碼(6–30 位,留空則關閉)",
"admin.saveProfile": "儲存修改",
"admin.updated": "更新個人資訊成功",
"admin.logout": "登出",
@@ -618,6 +619,7 @@ var zhTWDict = map[string]string{
"vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已參與過該投票,感謝參與!",
"vote.page.resultsTitle": "投票結果",
"vote.page.anonymousNotice": "匿名投票,每人一票,結果僅供參考。",
"vote.page.totalVotes": "總參與",
"vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名",
@@ -756,6 +758,7 @@ var zhTWDict = map[string]string{
// 提問箱頁:訪客可見(P0)
"question.errWrongPassword": "密碼錯誤",
"question.errPasswordLocked": "密碼錯誤次數過多,請 %d 分鐘後再試",
"question.errAskPasswordWrong": "提問密碼錯誤",
"question.errLoginRequired": "提問箱的主人設定了僅註冊使用者才能提問,請先登入。",
"question.errSelfChat": "不能給自己發起私聊",
@@ -925,6 +928,7 @@ var zhTWDict = map[string]string{
"form.profile.name": "暱稱不能為空,且不超過 20 個字元",
"form.profile.newPassword": "新密碼長度不能超過 30 個字元",
"form.profile.passwordMax": "密碼長度不能超過 30 個字元",
"form.profile.passwordLen": "請設定 6 至 30 位的密碼",
"form.question.content": "問題內容不能為空",
"form.question.contentTooLong": "問題內容不能超過 %d 個字元",
"form.question.receiveReplyEmail": "請填寫有效的電子郵件地址以接收回覆",