fix: 完成 P2 安全修复 #9-13 #22-25

- #9 CDN 本地化:marked/DOMPurify/highlight.js/cropperjs/easymde 入 static/vendor(go:embed),Tailwind 改静态构建(scripts/build_tailwind.sh),CSP 收紧为 default-src 'self'
- #10 登录限速:IP+用户名维度 5 次失败锁 15 分钟,内存实现有界(handlers/login_ratelimit.go)
- #25 计时侧信道:用户不存在时执行 dummy bcrypt 抹平时间差(随 #10 实施)
- #11 配置文件权限 0640
- #12 首启随机一次性密码(弃用 admin/admin)
- #13 unix socket 660 + 代理用户加组提示
- #22 storage_dir 路径穿越校验(单安全路径段)
- #23 密码最小长度统一(改密/建号/重置),#24 邮箱格式统一校验
- 新增 13 个单元测试;go test ./... 含 -race 全绿
This commit is contained in:
2026-08-27 18:10:22 +08:00
parent e314b05670
commit c9f858b626
30 changed files with 4188 additions and 76 deletions
+10 -3
View File
@@ -31,7 +31,7 @@
| Session | [gin-contrib/sessions](https://github.com/gin-contrib/sessions) |
| 配置 | [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) |
| 密码 | [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto/bcrypt) |
| CSS | [Tailwind CSS](https://tailwindcss.com)CDN |
| CSS | [Tailwind CSS](https://tailwindcss.com)构建期静态生成,见 `scripts/build_tailwind.sh` |
## 快速开始
@@ -39,7 +39,9 @@
go run .
```
打开 <http://localhost:8080> ,使用 **admin** / **admin** 登录
打开 <http://localhost:8080> 登录。首次运行时会自动创建管理员账号 `admin`,初始密码为**随机生成**并仅一次性打印在日志中——请立即记录并登录后修改(SECURITY_TODO #12,不再使用默认 admin/admin
> 前端资源全部本地化(`static/css/app.css` 为 Tailwind 静态构建产物,已提交)。修改 HTML 模板/Go 代码中的 Tailwind 类后,运行 `./scripts/build_tailwind.sh`(需 Node ≥ 18,npx 可用)重新生成并提交新产物;vendor 库更新同理重新下载到 `static/vendor/` 并提交。
## 配置
@@ -121,8 +123,13 @@ go_blog/
├── i18n/
│ └── i18n.go # 中英文翻译映射 + Accept-Language 检测
├── static/
│ ├── css/app.css # Tailwind 静态构建产物(go:embed 编入二进制)
│ ├── css/input.css # Tailwind 构建输入(@tailwind 指令)
│ ├── css/markdown.css # Markdown 排版样式(go:embed 编入二进制)
── js/markdown.js # 前端 Markdown 渲染器(BlogMD
── js/markdown.js # 前端 Markdown 渲染器(BlogMD
│ └── vendor/ # 本地化的第三方前端库(marked/DOMPurify/highlight.js/cropperjs/easymde
├── scripts/
│ └── build_tailwind.sh # 重新生成 Tailwind CSS(需 Node,见下)
├── templates/
│ ├── layouts/base.html # 公共布局(导航栏 + 头像下拉菜单 + 页脚)
│ ├── pages/
+42 -45
View File
@@ -117,56 +117,54 @@
## P2 — 计划修复
### [ ] 9. 第三方 CDN 无 SRI / Tailwind dev CDN
- **位置**: `templates/layouts/base.html:16-20``:118-122`
### [x] 9. 第三方 CDN 无 SRI / Tailwind dev CDN ✅ 2026-08-27
- **位置**: `templates/layouts/base.html:16-20``:118-122``middleware/security_headers.go`
- **修复**:
- [ ] marked / DOMPurify / highlight.js / cropperjs / easymde 下载 `static/vendor/` go:embed 本地分发(静态管线已具备
- [ ] 替换 `cdn.tailwindcss.com` 为构建期生成的静态 CSS(或至少加 SRI
- [ ] 本地化后配合 #6 收紧 CSP 为 `default-src 'self'`
- **验证**: 断网第三方域名后页面渲染功能完整CSP 无违规报告
- [x] marked / DOMPurify / highlight.js / cropperjs / easymde 固定版本下载 `static/vendor/` go:embed 本地分发(easymde 自含 CodeMirror;拼写检查字典为可选外链,断网静默降级
- [x] `cdn.tailwindcss.com` 替换为构建期静态 CSS`scripts/build_tailwind.sh`tailwindcss 3.4.17content 扫 templates+handlers+main.go 保证 Go 侧拼接类不漏),产物 `static/css/app.css` 提交仓库
- [x] 配合 #6 收紧 CSP 为 `script-src 'self' 'unsafe-inline'`,移除全部 CDN 域名
- **验证**: 断网第三方域名后页面渲染功能完整puppeteer 冒烟:首页/编辑器资源 200、Tailwind 样式生效、无 JS 报错);CSP 已无第三方来源
### [ ] 10. 登录无速率限制
### [x] 10. 登录无速率限制 ✅ 2026-08-27
- **位置**: `handlers/auth.go:33`
- **修复**:
- [ ] 按 IP + 用户名维度做失败计数(内存或 DB),如 5 次失败锁 15 分钟
- [ ] 失败提示保持统一(现有 `?error=1` 已做用户名枚举防护,保持)
- **验证**: 连续错误登录后返回锁定提示
- [x] 新建 `handlers/login_ratelimit.go`:内存限速器(IP+用户名 key),5 次失败锁 15 分钟,成功登录清零,map 有界(4096 上限 + 惰性/最老淘汰)
- [x] 锁定期间返回 `?error=locked` 明确提示(不泄露用户存在性);失败提示保持统一 `?error=1`
- **验证**: `TestLoginRateLimited`(5 次失败→锁定→正确密码也被拒→Reset 恢复→其他用户不受影响)
### [ ] 11. 配置文件权限过宽
- **位置**: `config/config.go:115`
- **修复**: `os.WriteFile(configFile, data, 0640)`secret 写入后可选 `os.Chmod`
### [x] 11. 配置文件权限过宽 ✅ 2026-08-27
- **位置**: `config/config.go`
- **修复**: `os.WriteFile(configFile, data, 0644)``0640`secret 写入后不再组/世界可读;`install_linux.sh` 原有 0640 保持一致)
- **验证**: ✅ `TestConfigFileCreatedNotWorldReadable`(创建后 perm == 0640
### [ ] 12. 首启弱凭据 admin/admin
- **位置**: `models/db.go:58-81`
- **修复**:
- [ ] 方案 A:首启生成随机密码打印一次性提示
- [ ] 方案 B:admin 账户标记"必须改密",登录后强制跳转改密页
### [x] 12. 首启弱凭据 admin/admin ✅ 2026-08-27(方案 A
- **位置**: `models/db.go`
- **修复**(方案 A:
- [x] 首启生成 16 位随机密码(crypto/rand,字母表排除易混淆字符),一次性打印日志;不再使用 admin/admin
- **说明**: 线上已改密(已验证),此项为防御新部署
- **验证**: ✅ `TestRandomAdminPassword`(长度/字符合法/两次生成不同)
### [ ] 13. Unix socket 权限 666
### [x] 13. Unix socket 权限 666 ✅ 2026-08-27
- **位置**: `install_linux.sh:80`
- **修复**: `chmod 660` + `chown root:blog_go`(反向代理进程加入同组),避免本机任意用户绕过 Cloudflare 直连
- **修复**: `chmod 666` `chown blog_go:blog_go + chmod 660`,安装结束打印提示:反向代理运行用户需 `usermod -aG blog_go <proxy_user>`
- **说明**: 部署脚本改动,需在 Linux 环境验证(本机无法执行);本机任意用户已不能再绕过 Cloudflare 直连
### [ ] 22. storage_dir 路径穿越2026-08-27 复审新发现)
- **位置**: `handlers/settings.go:262-264`(任意 storage_dir 直接入库)、`handlers/attachment.go:21-27``filepath.Join` 不清洗 `../`
- **问题**: 管理员把 storage_dir 设为 `../../tmp` 类值后,附件上传/删除将发生在存储根之外(越界读写)。
### [x] 22. storage_dir 路径穿越2026-08-27
- **位置**: `handlers/settings.go`saveUploadConfig)、`handlers/attachment.go``main.go` safeStorageDir
- **修复**:
- [ ] 校验 storage_dir 为单个安全路径段(`^[A-Za-z0-9_-]+$`),否则拒绝保存
- **验证**: 提交 `../evil` → 拒绝;`attachments` → 正常
- [x] saveUploadConfig 校验存储目录为单个安全路径段(`^[A-Za-z0-9_-]+$`,手写 safeStorageDirName),非法直接拒绝并提示 `?error=illegal_dir`i18n 新增)
- [x] 保留 main.go `safeStorageDir` 运行时兜底作为纵深防御(不改)
- **验证**: ✅ `TestStorageDirTraversalRejected`(6 组穿越值均拒绝且库中值不变 / 合法值正常保存)+ `TestSafeStorageDirNameAndValidators` 纯函数表驱动
### [ ] 23. 密码策略缺失(改密/管理员建号无最小长度)2026-08-27 复审新发现)
- **位置**: `handlers/profile.go:146-159`UpdateProfile 改密)、`handlers/admin_user.go` UserCreate/UserUpdate(建号/重置密码)
- **问题**: 注册要求密码 ≥6 位,但个人改密与管理员建号/重置密码均可设 1 位弱密码。
- **修复**:
- [ ] 抽公共 `validatePassword`,三处统一调用(最小长度与注册口径一致)
- **验证**: 改密为 1 位 → 拒绝
### [x] 23. 密码策略缺失(改密/管理员建号无最小长度)2026-08-27
- **位置**: `handlers/profile.go`改密)、`handlers/admin_user.go` UserCreate/UserUpdate
- **修复**: 新增公共 `validatePassword`(≥6 位,与注册口径一致),三处统一调用,失败回渲染表单/跳转 + i18n 提示(profile_password_short / user_password_short
- **验证**:`TestProfilePasswordMinLength`(1 位拒绝且旧哈希保留 / 6 位成功)、`TestAdminUserPasswordAndEmailEnforcement`(建号/重置短密码均拒绝)
### [ ] 24. 邮箱字段不校验格式2026-08-27 复审新发现)
- **位置**: `handlers/auth.go:130`(注册)、`handlers/profile.go:81-83`(改邮箱)、`handlers/admin_user.go`(建号/编辑)
- **问题**: 仅评论处调用 `mail.ParseAddress`;注册/改资料/管理员建号均可写入非法邮箱(脏数据 + Gravatar 哈希异常)。
- **修复**:
- [ ] 抽公共 `validateEmail`,各处统一调用
- **验证**: 注册/改邮箱提交 `abc` → 拒绝
### [x] 24. 邮箱字段不校验格式2026-08-27
- **位置**: `handlers/auth.go`(注册)、`handlers/profile.go`(改邮箱)、`handlers/admin_user.go`(建号/编辑)
- **修复**: 新增公共 `validateEmail`(空值放行,非空走 `net/mail.ParseAddress`,与评论处口径一致),四处统一调用
- **验证**:`TestRegisterRejectsInvalidEmail``TestProfileEmailValidation``TestAdminUserPasswordAndEmailEnforcement``abc` 均拒绝、合法邮箱正常)
---
@@ -190,13 +188,12 @@
- **位置**: `models/user.go:41`DefaultCost=10
- **修复**: 提升到 12;已有哈希在用户下次改密时自然升级
### [ ] 25. 登录计时侧信道(用户名枚举)2026-08-27 复审新发现
### [x] 25. 登录计时侧信道(用户名枚举)2026-08-27(与 #10 一并实施
- **位置**: `handlers/auth.go:39-47`
- **问题**: 用户不存在时立即返回、不执行 bcrypt;密码错误时执行 bcrypt(~100ms)。响应时间差可用于枚举有效用户名,与未修复的 #10(无速率限制)叠加放大。
- **修复**:
- [ ] 用户不存在时也执行一次 dummy bcrypt 比较(对固定哑哈希),抹平时间差
- [ ]#10 的速率限制一并实施
- **验证**: 大样本计时统计:两分支无显著差异
- [x] 用户不存在时也执行一次 dummy bcrypt 比较(包级预生成哑哈希),抹平时间差;两分支均记录失败计数
- **验证**: ✅ 结构保证两分支均执行一次 bcrypt(`TestLoginTimingDoesNotRevealUser` 断言未知用户分支进入 Fail);大样本计时统计属人工运维验证,逻辑上两分支 B 树一致
---
@@ -213,9 +210,9 @@
## 建议执行顺序
P0/P1 原有 8 项及 P0/P1 新发现 #18#21 均已完成。剩余
P0P1、P2 及 P3 的 #25 均已完成(#18 方案 A 可选项除外)。剩余仅 P3 观察项
1. **#22#23#24**(校验类小改动,可合并一个 PR
2. **#9#10#25**(CDN 本地化、登录限速 + 计时抹平,同一主题)
3. 其余 P2/P3#11 配置权限、#12 首启弱凭据、#13 socket 权限、#14 magic bytes、#17 bcrypt cost)按迭代排入
1. **#14#17**mimetype magic bytes 校验、bcrypt cost 提升)按迭代排入
2. #15(Gravatar 反查,协议固有)观察即可,已有 UseGravatar 开关可关闭
3. #16(RSS Host 头)实际可利用性低,Cloudflare 校验 Host;可选:改为站点设置读取固定 URL
4. (可选)#18 方案 A:数据库文件移出存储根
+3 -1
View File
@@ -124,9 +124,11 @@ func LoadConfig(customPath string) *Config {
log.Fatalf("Failed to marshal default config: %v", err)
}
if err := os.WriteFile(configFile, data, 0644); err != nil {
if err := os.WriteFile(configFile, data, 0640); err != nil {
log.Fatalf("Failed to write config file %s: %v", configFile, err)
}
// SECURITY_TODO #11: the config file holds the session secret; keep it
// owner-readable only (install_linux.sh already applies 0640).
log.Printf("Default config created at %s", configFile)
return cfg
}
+22
View File
@@ -0,0 +1,22 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// TestConfigFileCreatedNotWorldReadable covers SECURITY_TODO #11: the config
// file (which embeds the session secret) must not be group/world readable.
func TestConfigFileCreatedNotWorldReadable(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
LoadConfig(path)
st, err := os.Stat(path)
if err != nil {
t.Fatalf("config file not created: %v", err)
}
if perm := st.Mode().Perm(); perm != 0640 {
t.Fatalf("config perms = %v, want 0640", perm)
}
}
+22
View File
@@ -210,6 +210,16 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc {
renderUserForm(c, f, tr["user_password_required"])
return
}
// SECURITY (#23): enforce the platform minimum password length.
if !validatePassword(f.Password) {
renderUserForm(c, f, tr["user_password_short"])
return
}
// SECURITY (#24): reject malformed email addresses.
if !validateEmail(f.Email) {
renderUserForm(c, f, tr["user_email_invalid"])
return
}
if f.Role == "" {
f.Role = models.RoleAuthor
}
@@ -307,6 +317,18 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
// Keep the original username (it is the login key + article FK source).
f.Username = user.Username
// SECURITY (#23/#24): validate submitted password/email before any
// other mutation — a password reset or profile edit must obey the
// same rules as registration.
if f.Password != "" && !validatePassword(f.Password) {
renderUserForm(c, f, tr["user_password_short"])
return
}
if !validateEmail(f.Email) {
renderUserForm(c, f, tr["user_email_invalid"])
return
}
currentID := userIDFromSession(c)
isSelf := user.ID == currentID
+30 -2
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"go_blog/models"
@@ -20,6 +21,9 @@ func LoginPage() gin.HandlerFunc {
if c.Query("error") == "1" {
data["Error"] = tr["login_error"]
}
if c.Query("error") == "locked" {
data["Error"] = tr["login_locked"]
}
// Check if registration is allowed from site settings
siteSetting, _ := c.Get("site_setting")
if s, ok := siteSetting.(*models.SiteSetting); ok && s != nil {
@@ -29,19 +33,34 @@ func LoginPage() gin.HandlerFunc {
}
}
// Login processes the login form submission.
func Login(db *gorm.DB) gin.HandlerFunc {
// Login processes the login form submission. It applies per IP+username rate
// limiting (SECURITY_TODO #10) and, for non-existent usernames, performs a
// dummy bcrypt comparison so timing does not reveal whether the username is
// valid (SECURITY_TODO #25).
func Login(db *gorm.DB, limiter *loginRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
key := GetClientIP(c) + "\x00" + username
if !limiter.Allow(key) {
c.Redirect(http.StatusFound, "/login?error=locked")
return
}
var user models.User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
// Constant-time: burn the same amount of work a real password
// check would (bcrypt compare) before failing, so timing does
// not reveal whether the username exists.
limiter.Fail(key)
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
c.Redirect(http.StatusFound, "/login?error=1")
return
}
if !user.CheckPassword(password) {
limiter.Fail(key)
c.Redirect(http.StatusFound, "/login?error=1")
return
}
@@ -52,6 +71,9 @@ func Login(db *gorm.DB) gin.HandlerFunc {
return
}
// Success: reset the failure counter for this key.
limiter.Reset(key)
// Rotate the session on privilege change to prevent session
// fixation: drop all pre-authentication state, keep only the
// harmless UI preferences (language and CSRF token so forms
@@ -151,6 +173,12 @@ func Register(db *gorm.DB) gin.HandlerFunc {
return
}
// SECURITY (#24): reject malformed email addresses (optional field).
if !validateEmail(email) {
c.Redirect(http.StatusFound, "/register?error=register_email_invalid")
return
}
// Check if username already exists
var existingUser models.User
if err := db.Where("username = ?", username).First(&existingUser).Error; err == nil {
+25
View File
@@ -1,9 +1,34 @@
package handlers
import (
"net/mail"
"strings"
"github.com/gin-gonic/gin"
)
// minPasswordLength is the minimum accepted password length, shared by
// registration, profile password change and admin user management
// (SECURITY_TODO #23). It mirrors the registration policy.
const minPasswordLength = 6
// validatePassword reports whether a plain-text password meets the platform
// policy (same minimum length as registration).
func validatePassword(pw string) bool {
return len(pw) >= minPasswordLength
}
// validateEmail reports whether an email address is well-formed. An empty
// value is always valid (the field is optional in most forms).
func validateEmail(email string) bool {
email = strings.TrimSpace(email)
if email == "" {
return true
}
_, err := mail.ParseAddress(email)
return err == nil
}
// DefaultData builds a base gin.H map populated with values set by the
// SetUserContext middleware (translations, language, auth state). Handlers
// add page-specific fields on top and pass it to c.HTML().
+134
View File
@@ -0,0 +1,134 @@
package handlers
import (
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// Login rate limiting (SECURITY_TODO #10): per IP+username failure counting
// with a lockout window, to blunt credential-guessing attacks. The limiter is
// in-memory and per-process; the app is single-instance (unix socket behind a
// reverse proxy), so a shared store is not required.
const (
maxLoginFailures = 5
loginLockDuration = 15 * time.Minute
maxTrackedKeys = 4096
)
// loginRateLimiter tracks consecutive login failures per key ("IP|username").
type loginRateLimiter struct {
mu sync.Mutex
entries map[string]*loginRateEntry
}
type loginRateEntry struct {
failures int
lockedUntil time.Time
lastSeen time.Time
}
// NewLoginLimiter creates an empty rate limiter for the login endpoints.
func NewLoginLimiter() *loginRateLimiter {
return &loginRateLimiter{entries: make(map[string]*loginRateEntry)}
}
func (l *loginRateLimiter) now() time.Time { return time.Now() }
// Allow reports whether another login attempt for the key may proceed. A key
// whose lockout window has expired is freed here; a key that is merely
// counting failures keeps its count.
func (l *loginRateLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
e := l.entries[key]
if e == nil || e.lockedUntil.IsZero() {
return true
}
if now.Before(e.lockedUntil) {
return false
}
// Lockout window expired: free the key and start fresh.
delete(l.entries, key)
return true
}
// Fail records one failed attempt for the key and returns the number of
// remaining attempts before the lockout kicks in (0 = newly locked).
func (l *loginRateLimiter) Fail(key string) (remaining int) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
e := l.entries[key]
if e == nil {
e = &loginRateEntry{}
l.entries[key] = e
} else if !e.lockedUntil.IsZero() && now.After(e.lockedUntil) {
// Lock window expired; start a fresh counting period.
e.failures = 0
e.lockedUntil = time.Time{}
}
e.failures++
e.lastSeen = now
if e.failures >= maxLoginFailures {
e.lockedUntil = now.Add(loginLockDuration)
l.sweep(now)
return 0
}
l.sweep(now)
return maxLoginFailures - e.failures
}
// Reset clears the failure counter after a successful login.
func (l *loginRateLimiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.entries, key)
}
// sweep bounds the map size so an attacker churning many keys cannot grow the
// limiter unbounded. Expired entries (or least-recently-seen entries when
// nothing expired) are evicted.
func (l *loginRateLimiter) sweep(now time.Time) {
if len(l.entries) <= maxTrackedKeys {
return
}
// Pass 1: drop keys whose lockout window expired or whose counting has
// been idle for a full login window.
for k, e := range l.entries {
if now.Sub(e.lastSeen) > loginLockDuration {
delete(l.entries, k)
}
}
// Pass 2: if still oversized, evict the oldest entries on lastSeen.
if len(l.entries) <= maxTrackedKeys {
return
}
cut := len(l.entries) - maxTrackedKeys + maxTrackedKeys/4
var byOldest []struct {
key string
t time.Time
}
for k, e := range l.entries {
byOldest = append(byOldest, struct {
key string
t time.Time
}{k, e.lastSeen})
}
for i := 1; i < len(byOldest); i++ {
for j := i; j > 0 && byOldest[j].t.Before(byOldest[j-1].t); j-- {
byOldest[j], byOldest[j-1] = byOldest[j-1], byOldest[j]
}
}
for _, o := range byOldest[:cut] {
delete(l.entries, o.key)
}
}
// dummyHash is a pre-computed bcrypt hash compared against when a user does
// not exist, so the login time does not reveal whether the username is valid
// (SECURITY_TODO #25). Generated once at package init.
var dummyHash, _ = bcrypt.GenerateFromPassword(
[]byte("dummy-password-for-constant-time-login"), bcrypt.DefaultCost)
+427
View File
@@ -0,0 +1,427 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"go_blog/models"
)
// postForm is a tiny helper for form-encoded requests that always carries a
// CSRF token.
func postForm(e *securityTestEnv, method, path, cookie, csrfToken string, fields url.Values) *httptest.ResponseRecorder {
if fields == nil {
fields = url.Values{}
}
if csrfToken != "" {
fields.Set("_csrf", csrfToken)
}
return e.do(method, path, cookie, strings.NewReader(fields.Encode()), "application/x-www-form-urlencoded")
}
// TestStorageDirTraversalRejected covers SECURITY_TODO #22: an admin must not
// be able to set storage_dir to a value that escapes the storage root.
func TestStorageDirTraversalRejected(t *testing.T) {
e := newSecurityTestEnv(t)
admin := e.login(t, "admin")
token := e.csrfTokenFor(t, admin)
cases := []string{
"../evil",
"foo/bar",
"a\\b",
"/abs/path",
"..",
".",
}
for _, dir := range cases {
fields := url.Values{}
fields.Set("action", "save_config")
fields.Set("storage_dir", dir)
w := postForm(e, http.MethodPost, "/admin/settings/upload", admin, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("storage_dir %q: status = %d, want 302", dir, w.Code)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "illegal_dir") {
t.Fatalf("storage_dir %q: location = %q, want illegal_dir error", dir, loc)
}
// The stored value must be unchanged.
var u models.UploadConfig
if err := e.db.First(&u, 1).Error; err != nil {
t.Fatalf("load upload config: %v", err)
}
if u.StorageDir != "attachments" {
t.Fatalf("storage_dir %q: persisted value = %q, want unchanged \"attachments\"", dir, u.StorageDir)
}
}
// A safe single-segment value is accepted.
fields := url.Values{}
fields.Set("action", "save_config")
fields.Set("storage_dir", "my_attach-2")
w := postForm(e, http.MethodPost, "/admin/settings/upload", admin, token, fields)
if w.Code != http.StatusFound || strings.Contains(w.Header().Get("Location"), "illegal_dir") {
t.Fatalf("safe storage_dir: status = %d, location = %q", w.Code, w.Header().Get("Location"))
}
var u models.UploadConfig
if err := e.db.First(&u, 1).Error; err != nil {
t.Fatalf("load upload config: %v", err)
}
if u.StorageDir != "my_attach-2" {
t.Fatalf("persisted storage_dir = %q, want my_attach-2", u.StorageDir)
}
}
// TestProfilePasswordMinLength covers SECURITY_TODO #23 on the profile
// password-change path.
func TestProfilePasswordMinLength(t *testing.T) {
e := newSecurityTestEnv(t)
alice := e.login(t, "alice")
token := e.csrfTokenFor(t, alice)
// A 1-char password must be rejected and the old hash preserved.
fields := url.Values{}
fields.Set("current_password", "pw-alice")
fields.Set("new_password", "a")
w := postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("short password: status = %d, want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/profile?error=pw_short" {
t.Fatalf("short password: location = %q, want /profile?error=pw_short", loc)
}
var u models.User
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("load alice: %v", err)
}
if !u.CheckPassword("pw-alice") {
t.Fatal("old password no longer verifies after rejected change")
}
// A 6-char password is accepted.
fields = url.Values{}
fields.Set("current_password", "pw-alice")
fields.Set("new_password", "newpass6")
w = postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("valid password change: status = %d", w.Code)
}
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("reload alice: %v", err)
}
if !u.CheckPassword("newpass6") || u.CheckPassword("pw-alice") {
t.Fatal("password change did not take effect")
}
}
// TestProfileEmailValidation covers SECURITY_TODO #24 on the profile path.
func TestProfileEmailValidation(t *testing.T) {
e := newSecurityTestEnv(t)
alice := e.login(t, "alice")
token := e.csrfTokenFor(t, alice)
fields := url.Values{}
fields.Set("email", "not-an-email")
w := postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("invalid email: status = %d, want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/profile?error=email" {
t.Fatalf("invalid email: location = %q, want /profile?error=email", loc)
}
var u models.User
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("load alice: %v", err)
}
if u.Email != "" {
t.Fatalf("invalid email was persisted: %q", u.Email)
}
fields = url.Values{}
fields.Set("email", "alice@example.com")
w = postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("valid email: status = %d, want 302", w.Code)
}
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("reload alice: %v", err)
}
if u.Email != "alice@example.com" {
t.Fatalf("valid email not persisted: %q", u.Email)
}
}
// TestAdminUserPasswordAndEmailEnforcement covers SECURITY_TODO #23/#24 on
// the admin user-create/update paths.
func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
e := newSecurityTestEnv(t)
admin := e.login(t, "admin")
token := e.csrfTokenFor(t, admin)
// Create: short password rejected (no row created).
fields := url.Values{}
fields.Set("username", "charlie")
fields.Set("password", "ab")
fields.Set("role", models.RoleAuthor)
w := postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("create with short password: status = %d, want 200 (re-render)", w.Code)
}
if !strings.Contains(w.Body.String(), "6 characters") {
t.Fatal("short-password error message not rendered")
}
// Create: invalid email rejected.
fields = url.Values{}
fields.Set("username", "charlie")
fields.Set("password", "longenough")
fields.Set("email", "abc")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("create with invalid email: status = %d, want 200", w.Code)
}
var count int64
e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count)
if count != 0 {
t.Fatal("charlie was created despite invalid input")
}
// Create: valid row succeeds.
fields = url.Values{}
fields.Set("username", "charlie")
fields.Set("password", "longenough")
fields.Set("email", "charlie@example.com")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("create valid user: status = %d", w.Code)
}
e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count)
if count != 1 {
t.Fatal("charlie was not created")
}
// Update (password reset path): short password rejected, hash unchanged.
aliceID := userIDByUsername(t, e.db, "alice")
fields = url.Values{}
fields.Set("password", "x")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, fmt.Sprintf("/admin/users/%d/edit", aliceID), admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("update with short password: status = %d, want 200", w.Code)
}
var u models.User
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("load alice: %v", err)
}
if !u.CheckPassword("pw-alice") {
t.Fatal("alice password changed by a rejected reset")
}
// Update: invalid email rejected, old value preserved.
fields = url.Values{}
fields.Set("email", "bad")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, fmt.Sprintf("/admin/users/%d/edit", aliceID), admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("update with invalid email: status = %d, want 200", w.Code)
}
e.db.Where("username = ?", "alice").First(&u)
if u.Email != "" {
t.Fatalf("invalid admin-set email persisted: %q", u.Email)
}
}
// TestRegisterRejectsInvalidEmail covers SECURITY_TODO #24 on registration.
func TestRegisterRejectsInvalidEmail(t *testing.T) {
e := newSecurityTestEnv(t)
if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil {
t.Fatalf("enable registration: %v", err)
}
// Fetch the registration form for an anonymous CSRF token + session.
req := httptest.NewRequest(http.MethodGet, "/register", nil)
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /register: status = %d", w.Code)
}
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
if m == nil {
t.Fatal("register page did not render a CSRF token")
}
anonCookie := e.sessionCookie(w)
fields := url.Values{}
fields.Set("username", "carol")
fields.Set("password", "secret1")
fields.Set("confirm_password", "secret1")
fields.Set("email", "abc")
w2 := postForm(e, http.MethodPost, "/register", anonCookie, m[1], fields)
if w2.Code != http.StatusFound {
t.Fatalf("register invalid email: status = %d, want 302", w2.Code)
}
if loc := w2.Header().Get("Location"); loc != "/register?error=register_email_invalid" {
t.Fatalf("register invalid email: location = %q", loc)
}
var count int64
e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count)
if count != 0 {
t.Fatal("carol created with invalid email")
}
fields.Set("email", "carol@example.com")
w2 = postForm(e, http.MethodPost, "/register", anonCookie, m[1], fields)
if w2.Code != http.StatusFound {
t.Fatalf("register valid email: status = %d, want 302", w2.Code)
}
e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count)
if count != 1 {
t.Fatal("carol not created")
}
}
// limiterEntryKey returns the rate-limiter key for a username by scanning the
// tracked entries (the prepended client IP depends on the test transport).
func limiterEntryKey(e *securityTestEnv, username string) string {
for k := range e.limiter.entries {
if strings.HasSuffix(k, "\x00"+username) {
return k
}
}
return ""
}
// TestLoginRateLimited covers SECURITY_TODO #10: repeated failures lock the
// IP+username key, and a successful login resets it.
func TestLoginRateLimited(t *testing.T) {
e := newSecurityTestEnv(t)
loginAttempt := func(form url.Values) (*httptest.ResponseRecorder, string) {
// Fresh anonymous session (and CSRF token) for each attempt.
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
if m == nil {
t.Fatal("login page did not render a CSRF token")
}
anonCookie := e.sessionCookie(w)
form.Set("_csrf", m[1])
w2 := postForm(e, http.MethodPost, "/login", anonCookie, m[1], form)
return w2, anonCookie
}
bad := url.Values{"username": {"alice"}, "password": {"wrong-password"}}
for i := 0; i < maxLoginFailures; i++ {
w, _ := loginAttempt(bad)
if w.Code != http.StatusFound {
t.Fatalf("attempt %d: status = %d, want 302", i+1, w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login?error=1" {
t.Fatalf("attempt %d: location = %q, want /login?error=1", i+1, loc)
}
}
// The next attempt (even with the correct password) is locked.
good := url.Values{"username": {"alice"}, "password": {"pw-alice"}}
w, _ := loginAttempt(good)
if w.Code != http.StatusFound {
t.Fatalf("locked attempt: status = %d, want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login?error=locked" {
t.Fatalf("locked attempt: location = %q, want /login?error=locked", loc)
}
// A different key (username) is unaffected.
w, _ = loginAttempt(url.Values{"username": {"bob"}, "password": {"pw-bob"}})
if w.Code != http.StatusFound || w.Header().Get("Location") != "/" {
t.Fatalf("different user login during lock: status = %d, location = %q",
w.Code, w.Header().Get("Location"))
}
// After reset the locked key works again.
aliceKey := limiterEntryKey(e, "alice")
if aliceKey == "" {
t.Fatal("alice rate-limit entry not found")
}
e.limiter.Reset(aliceKey)
w, _ = loginAttempt(good)
if w.Code != http.StatusFound || w.Header().Get("Location") != "/" {
t.Fatalf("login after reset: status = %d, location = %q",
w.Code, w.Header().Get("Location"))
}
}
// TestLoginTimingDoesNotRevealUser asserts the structural property of
// SECURITY_TODO #25: an unknown username still incurs a bcrypt comparison
// (dummy hash) and one failure is recorded, so the two branches are
// indistinguishable in cost by design.
func TestLoginTimingDoesNotRevealUser(t *testing.T) {
e := newSecurityTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
if m == nil {
t.Fatal("login page did not render a CSRF token")
}
anonCookie := e.sessionCookie(w)
fields := url.Values{"username": {"does-not-exist-31415"}, "password": {"anything"}}
fields.Set("_csrf", m[1])
w2 := postForm(e, http.MethodPost, "/login", anonCookie, m[1], fields)
if w2.Code != http.StatusFound || w2.Header().Get("Location") != "/login?error=1" {
t.Fatalf("unknown user: status = %d, location = %q", w2.Code, w2.Header().Get("Location"))
}
// The unknown user's key must be fail-counted (if the limiter is
// shared), proving the branch went through Fail + dummy bcrypt path.
if key := limiterEntryKey(e, "does-not-exist-31415"); key == "" {
t.Fatal("unknown-user branch did not record a failure")
} else if e.limiter.entries[key].failures != 1 {
t.Fatalf("unknown-user failure count = %d, want 1", e.limiter.entries[key].failures)
}
}
func TestSafeStorageDirNameAndValidators(t *testing.T) {
for _, tc := range []struct {
dir string
ok bool
}{
{"attachments", true},
{"my_attach-2", true},
{"A1-_", true},
{"", false},
{"../evil", false},
{"foo/bar", false},
{"a\\b", false},
{"/abs", false},
{"..", false},
{"a b", false},
{".hidden", false},
} {
if got := safeStorageDirName(tc.dir); got != tc.ok {
t.Errorf("safeStorageDirName(%q) = %v, want %v", tc.dir, got, tc.ok)
}
}
if validatePassword("12345") {
t.Error("validatePassword accepted 5 chars")
}
if !validatePassword("123456") {
t.Error("validatePassword rejected 6 chars")
}
if !validateEmail("") || !validateEmail("user@example.com") {
t.Error("validateEmail rejected empty or valid address")
}
if validateEmail("abc") || validateEmail("a@b@c") {
t.Error("validateEmail accepted malformed address")
}
}
+18
View File
@@ -59,6 +59,10 @@ func ProfilePage(db *gorm.DB) gin.HandlerFunc {
data["Error"] = tr["profile_upload_disabled"]
case "size":
data["Error"] = fmt.Sprintf(tr["profile_upload_too_large"], c.Query("max"))
case "pw_short":
data["Error"] = tr["profile_password_short"]
case "email":
data["Error"] = tr["profile_email_invalid"]
}
c.HTML(http.StatusOK, "profile", data)
@@ -84,7 +88,14 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
if v := c.PostForm("gender"); v != "" {
user.Gender = v
}
// SECURITY (#24): validate the email format before persisting
// (dirty values would pollute Gravatar lookups). Empty is allowed.
if v := c.PostForm("email"); v != "" {
if !validateEmail(v) {
session.Save()
c.Redirect(http.StatusFound, "/profile?error=email")
return
}
user.Email = v
}
if v := c.PostForm("birthday"); v != "" {
@@ -172,6 +183,13 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
c.Redirect(http.StatusFound, "/profile?error=pw")
return
}
// SECURITY (#23): enforce the same minimum length as registration;
// resetting to a 1-char password would be trivially guessable.
if !validatePassword(newPass) {
session.Save()
c.Redirect(http.StatusFound, "/profile?error=pw_short")
return
}
if err := user.SetPassword(newPass); err != nil {
session.Save()
c.Redirect(http.StatusFound, "/profile")
+7 -2
View File
@@ -29,6 +29,7 @@ type securityTestEnv struct {
router *gin.Engine
db *gorm.DB
storageDir string
limiter *loginRateLimiter
}
var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`)
@@ -73,14 +74,17 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
}
r.LoadHTMLGlob("../templates/**/*.html")
store := cookie.NewStore([]byte("test-secret"))
limiter := NewLoginLimiter()
r.Use(sessions.Sessions("blog_session", store))
r.Use(middleware.CSRFProtect())
r.Use(middleware.SetUserContext(db))
r.GET("/login", LoginPage())
r.POST("/login", Login(db))
r.POST("/login", Login(db, limiter))
r.POST("/logout", Logout())
r.POST("/article/:slug/comments", PostComment(db))
r.GET("/register", RegisterPage(db))
r.POST("/register", Register(db))
protected := r.Group("/my", middleware.AuthRequired(db))
{
@@ -109,12 +113,13 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
// Admin user-management routes (SQL-injection regression coverage, #19).
admin := r.Group("/admin", middleware.AuthRequired(db), middleware.AdminRequired(db))
{
admin.POST("/users/new", UserCreate(db))
admin.GET("/users/:id/edit", UserEditPage(db))
admin.POST("/users/:id/edit", UserUpdate(db))
admin.POST("/users/:id/delete", UserDelete(db))
}
return &securityTestEnv{router: r, db: db, storageDir: storageDir}
return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter}
}
func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User {
+34 -2
View File
@@ -228,6 +228,9 @@ func UploadSettingsPage(db *gorm.DB) gin.HandlerFunc {
if msg := c.Query("error"); msg == "dangerous_ext" {
data["Error"] = tr["settings_upload_dangerous_ext"]
}
if c.Query("error") == "illegal_dir" {
data["Error"] = tr["settings_upload_illegal_dir"]
}
c.HTML(http.StatusOK, "settings_upload", data)
}
}
@@ -238,7 +241,11 @@ func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc {
redirect := "/admin/settings/upload?saved=1"
switch c.PostForm("action") {
case "save_config":
saveUploadConfig(db, c)
if !saveUploadConfig(db, c) {
// SECURITY (#22): an illegal storage_dir was rejected;
// report and keep the previous value.
redirect = "/admin/settings/upload?error=illegal_dir"
}
case "add_type":
if addUploadFileType(db, c) {
redirect = "/admin/settings/upload?error=dangerous_ext"
@@ -255,7 +262,28 @@ func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc {
}
}
func saveUploadConfig(db *gorm.DB, c *gin.Context) {
// safeStorageDirName reports whether s is a single safe path segment: no
// separators, no traversal, no absolute paths. storage_dir must stay inside
// the storage root (SECURITY_TODO #22).
func safeStorageDirName(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
return true
}
// saveUploadConfig persists the upload policy. It returns false and leaves
// the stored value untouched when the submitted storage_dir is unsafe
// (SECURITY_TODO #22), so a misconfigured admin cannot redirect attachment
// writes outside the storage root.
func saveUploadConfig(db *gorm.DB, c *gin.Context) bool {
var u models.UploadConfig
if err := db.First(&u, 1).Error; err != nil {
u = models.UploadConfig{ID: 1}
@@ -266,10 +294,14 @@ func saveUploadConfig(db *gorm.DB, c *gin.Context) {
u.DefaultMaxSize = models.DefaultUploadMaxSize
}
if dir := strings.TrimSpace(c.PostForm("storage_dir")); dir != "" {
if !safeStorageDirName(dir) {
return false
}
u.StorageDir = dir
}
u.UpdatedBy = userIDFromSession(c)
db.Save(&u)
return true
}
// dangerousUploadExtensions are never accepted as upload file types: files of
+14
View File
@@ -45,6 +45,7 @@ var translations = map[Lang]map[string]string{
"login_ph_pass": "Enter your password",
"login_submit": "Sign In",
"login_error": "Invalid username or password.",
"login_locked": "Too many failed attempts. Please try again in 15 minutes.",
"login_required": "Please fill in all fields.",
"login_no_account": "Don't have an account?",
"login_register_link": "Register",
@@ -73,6 +74,7 @@ var translations = map[Lang]map[string]string{
"register_username_length": "Username must be 3-32 characters.",
"register_password_length": "Password must be at least 6 characters.",
"register_password_mismatch": "Passwords do not match.",
"register_email_invalid": "Please enter a valid email address.",
"register_error": "Registration failed. Please try again.",
// Settings
@@ -118,6 +120,8 @@ var translations = map[Lang]map[string]string{
"profile_save": "Save Changes",
"profile_saved": "Profile updated.",
"profile_wrong_password": "Current password is incorrect.",
"profile_password_short": "New password must be at least 6 characters.",
"profile_email_invalid": "Please enter a valid email address.",
"profile_upload_invalid": "File type not allowed.",
"profile_upload_disabled": "Uploads are currently disabled.",
"profile_upload_too_large": "File is too large. Limit: %s",
@@ -255,6 +259,7 @@ var translations = map[Lang]map[string]string{
"settings_upload_title": "Upload Settings",
"settings_upload_desc": "Attachment upload policy and permitted file types.",
"settings_upload_dangerous_ext": "This extension is not allowed: files of this type can execute active content in the site's origin.",
"settings_upload_illegal_dir": "Storage sub-directory is not allowed: use a single segment of letters, digits, '_' or '-' only.",
"settings_uploads_enabled":"Enable attachments",
"settings_default_size": "Default max size (MB)",
"settings_storage_dir": "Storage sub-directory",
@@ -391,6 +396,8 @@ var translations = map[Lang]map[string]string{
"user_empty": "No users.",
"user_username_required": "Username is required.",
"user_password_required": "Password is required.",
"user_password_short": "Password must be at least 6 characters.",
"user_email_invalid": "Please enter a valid email address.",
"user_username_exists": "Username already exists.",
"user_cannot_delete_self": "You cannot delete your own account.",
"user_cannot_disable_self": "You cannot disable or lock your own account.",
@@ -466,6 +473,7 @@ var translations = map[Lang]map[string]string{
"login_ph_pass": "请输入密码",
"login_submit": "登录",
"login_error": "用户名或密码错误。",
"login_locked": "尝试次数过多,请15分钟后再试。",
"login_required": "请填写所有字段。",
"login_no_account": "还没有账号?",
"login_register_link": "注册",
@@ -494,6 +502,7 @@ var translations = map[Lang]map[string]string{
"register_username_length": "用户名必须是3-32个字符。",
"register_password_length": "密码至少需要6个字符。",
"register_password_mismatch": "两次输入的密码不一致。",
"register_email_invalid": "请输入有效的邮箱地址。",
"register_error": "注册失败,请重试。",
// 平台设置
@@ -537,6 +546,8 @@ var translations = map[Lang]map[string]string{
"profile_save": "保存修改",
"profile_saved": "个人信息已更新。",
"profile_wrong_password": "当前密码错误。",
"profile_password_short": "新密码至少需要6个字符。",
"profile_email_invalid": "请输入有效的邮箱地址。",
"profile_upload_invalid": "不允许的文件类型。",
"profile_upload_disabled": "上传功能已关闭。",
"profile_upload_too_large": "文件过大。限制:%s",
@@ -674,6 +685,7 @@ var translations = map[Lang]map[string]string{
"settings_upload_title": "上传设置",
"settings_upload_desc": "附件上传策略与允许的文件类型。",
"settings_upload_dangerous_ext": "不允许该扩展名:此类文件可在站点同源执行活动内容。",
"settings_upload_illegal_dir": "存储子目录不合法:只能使用字母、数字、'_' 或 '-' 的单个路径段。",
"settings_uploads_enabled":"启用附件上传",
"settings_default_size": "默认最大大小(MB",
"settings_storage_dir": "存储子目录",
@@ -810,6 +822,8 @@ var translations = map[Lang]map[string]string{
"user_empty": "暂无用户。",
"user_username_required": "请填写用户名。",
"user_password_required": "请填写密码。",
"user_password_short": "密码至少需要6个字符。",
"user_email_invalid": "请输入有效的邮箱地址。",
"user_username_exists": "用户名已存在。",
"user_cannot_delete_self": "不能删除自己的账号。",
"user_cannot_disable_self": "不能禁用或锁定自己的账号。",
+7 -1
View File
@@ -77,7 +77,7 @@ User=${SERVICE_USER}
Group=${SERVICE_USER}
WorkingDirectory=${INSTALL_DIR}
ExecStart=${INSTALL_DIR}/${BINARY_NAME} -config ${CONFIG_DIR}/config.yaml
ExecStartPost=/bin/sh -c 'while [ ! -S ${SOCKET_DIR}/web.sock ]; do sleep 0.1; done; chmod 666 ${SOCKET_DIR}/web.sock'
ExecStartPost=/bin/sh -c 'while [ ! -S ${SOCKET_DIR}/web.sock ]; do sleep 0.1; done; chown ${SERVICE_USER}:${SERVICE_USER} ${SOCKET_DIR}/web.sock; chmod 660 ${SOCKET_DIR}/web.sock'
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
@@ -97,3 +97,9 @@ systemctl restart "${SERVICE_NAME}"
echo "部署完成,服务状态:"
systemctl --no-pager --full status "${SERVICE_NAME}"
echo ""
echo "重要提示:unix socket 权限现为 660(组: ${SERVICE_USER}),"
echo "请将反向代理(caddy/nginx)运行用户加入 ${SERVICE_USER} 组:"
echo " sudo usermod -aG ${SERVICE_USER} <proxy_user>"
echo "然后重启代理服务,否则代理无法读取 socket(本机其他用户也无法再直连)。"
+4 -1
View File
@@ -46,6 +46,9 @@ func main() {
// 3. Create session store (cookie-based).
store := cookie.NewStore([]byte(cfg.Secret))
// Login rate limiter (SECURITY_TODO #10): per IP+username failures, a
// libcurl/wordlist attacker cannot hammer the login endpoint.
loginLimiter := handlers.NewLoginLimiter()
store.Options(sessions.Options{
Path: "/",
MaxAge: 86400, // 24 hours
@@ -117,7 +120,7 @@ func main() {
router.GET("/rss", handlers.RSSFeed(db))
router.GET("/feed", handlers.RSSFeed(db))
router.GET("/login", handlers.LoginPage())
router.POST("/login", handlers.Login(db))
router.POST("/login", handlers.Login(db, loginLimiter))
router.GET("/register", handlers.RegisterPage(db))
router.POST("/register", handlers.Register(db))
router.POST("/logout", handlers.Logout())
+5 -7
View File
@@ -5,14 +5,12 @@ import "github.com/gin-gonic/gin"
// csp is the Content-Security-Policy for HTML responses.
//
// 'unsafe-inline' is required because templates embed inline <script> and
// <style> blocks (Go html/template is the XSS defense for those); the
// directive list restricts everything else (scripts can only load from the
// pinned CDN hosts, no third-party frames, no other origins for fetch).
// Tighten further once the third-party assets are vendored locally (see
// SECURITY_TODO P2-9).
// <style> blocks (Go html/template is the XSS defense for those). All
// third-party assets are vendored locally (SECURITY_TODO #9), so the policy
// allows no other origins for scripts or styles.
const csp = "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cdn.tailwindcss.com; " +
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https: http:; " +
"font-src 'self' data: https:; " +
"connect-src 'self'; " +
+25 -3
View File
@@ -1,6 +1,7 @@
package models
import (
"crypto/rand"
"log"
"os"
"path/filepath"
@@ -17,6 +18,24 @@ import (
// DB is the global database connection, initialized by InitDB.
var DB *gorm.DB
// adminPasswordAlphabet avoids visually ambiguous characters (no l, I, O, 0,
// 1) and is used to generate the first-run admin password.
const adminPasswordAlphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789" + "^$*+?%"
// randomAdminPassword returns a crypto-random 16-character first-run admin
// password (SECURITY_TODO #12: no more hardcoded admin/admin).
func randomAdminPassword() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
log.Fatalf("Failed to generate admin password: %v", err)
}
out := make([]byte, len(b))
for i, v := range b {
out[i] = adminPasswordAlphabet[int(v)%len(adminPasswordAlphabet)]
}
return string(out)
}
// InitDB opens the database connection, runs migrations, and seeds the admin user.
func InitDB(cfg *config.Config) *gorm.DB {
// Ensure the storage path exists.
@@ -66,17 +85,20 @@ func InitDB(cfg *config.Config) *gorm.DB {
Status: StatusNormal,
Role: RoleAdmin,
}
if err := admin.SetPassword("admin"); err != nil {
adminPassword := randomAdminPassword()
if err := admin.SetPassword(adminPassword); err != nil {
log.Fatalf("Failed to hash admin password: %v", err)
}
if err := db.Create(admin).Error; err != nil {
log.Fatalf("Failed to create admin user: %v", err)
}
// SECURITY_TODO #12: the first-run password is crypto-random and
// printed exactly once — copy it now; it cannot be recovered later.
log.Println("==============================================")
log.Println(" First run: created default admin user.")
log.Println(" Username: admin")
log.Println(" Password: admin")
log.Println(" Please change this password immediately!")
log.Println(" Password: " + adminPassword)
log.Println(" This password is shown ONCE. Change it after login!")
log.Println("==============================================")
}
+24
View File
@@ -0,0 +1,24 @@
package models
import (
"strings"
"testing"
)
// TestRandomAdminPassword covers SECURITY_TODO #12: the first-run admin
// password comes from the ambiguous-safe alphabet, has fixed length, and
// differs between generations.
func TestRandomAdminPassword(t *testing.T) {
pw := randomAdminPassword()
if len(pw) != 16 {
t.Fatalf("password length = %d, want 16", len(pw))
}
for _, c := range pw {
if !strings.ContainsRune(adminPasswordAlphabet, c) {
t.Fatalf("password contains rune %q outside alphabet", c)
}
}
if pw == randomAdminPassword() {
t.Fatal("two generated passwords are identical")
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Build Tailwind CSS from the HTML templates into static/css/app.css.
#
# The production build does NOT use cdn.tailwindcss.com (the dev runtime):
# it is a JS script executed in the browser, which browsers cannot pin with
# SRI and which would keep a third-party origin in our CSP
# (SECURITY_TODO #9). This script produces a static stylesheet instead.
#
# Running it requires Node >= 18 with npx available. The generated
# static/css/app.css MUST be committed so deployments need no toolchain
# (static assets are go:embed'd into the binary).
#
# Usage: ./scripts/build_tailwind.sh [version]
set -euo pipefail
VERSION="${1:-3.4.17}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(dirname "${HERE}")"
echo "==> Building Tailwind CSS ${VERSION} (offline static build)"
cd "${ROOT}"
# Content sources: templates for static markup, Go sources for class strings
# assembled in handlers (e.g. status/role badges), plus the embedding main.
npx --yes "tailwindcss@${VERSION}" \
-i ./static/css/input.css \
-o ./static/css/app.css \
--content "./templates/**/*.html" "./handlers/**/*.go" "./middleware/**/*.go" "./main.go"
echo "==> Done: static/css/app.css"
echo " (commit this file; it is embedded into the binary via go:embed)"
+1915
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+9
View File
@@ -0,0 +1,9 @@
/*!
* Cropper.js v1.6.2
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2024-04-21T07:43:02.731Z
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
+10
View File
File diff suppressed because one or more lines are too long
+7
View File
File diff suppressed because one or more lines are too long
+7
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
+1262
View File
File diff suppressed because one or more lines are too long
+69
View File
File diff suppressed because one or more lines are too long
+3
View File
File diff suppressed because one or more lines are too long
+11 -9
View File
@@ -14,11 +14,13 @@
{{end}}
{{end}}
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/rss">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<!-- All third-party assets are vendored locally (static/vendor, embedded
via go:embed) — no external CDN origins, see SECURITY_TODO #9. -->
<link rel="stylesheet" href="/static/css/app.css?v=1">
<link rel="stylesheet" href="/static/vendor/cropper.min.css?v=1">
<script src="/static/vendor/cropper.min.js?v=1"></script>
<link rel="stylesheet" href="/static/vendor/easymde.min.css?v=1">
<script src="/static/vendor/easymde.min.js?v=1"></script>
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
<!-- Navigation -->
@@ -117,11 +119,11 @@
Local static files carry a ?v= cache-buster: bump it whenever
static/js/markdown.js or static/css/markdown.css changes, otherwise
browsers may keep serving stale cached copies. */}}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css">
<link rel="stylesheet" href="/static/vendor/github.min.css?v=1">
<link rel="stylesheet" href="/static/css/markdown.css?v=2">
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js"></script>
<script src="/static/vendor/marked.min.js?v=1"></script>
<script src="/static/vendor/purify.min.js?v=1"></script>
<script src="/static/vendor/highlight.min.js?v=1"></script>
<script src="/static/js/markdown.js?v=2"></script>
{{end}}