From c9f858b626ff3d52224639fba680cd24f0a39bf1 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 27 Aug 2026 18:10:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E6=88=90=20P2=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=BF=AE=E5=A4=8D=20#9-13=20#22-25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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 全绿 --- README.md | 13 +- SECURITY_TODO.md | 87 +- config/config.go | 4 +- config/config_test.go | 22 + handlers/admin_user.go | 22 + handlers/auth.go | 32 +- handlers/helpers.go | 25 + handlers/login_ratelimit.go | 134 +++ handlers/p2_validation_test.go | 427 +++++++ handlers/profile.go | 18 + handlers/security_test.go | 9 +- handlers/settings.go | 36 +- i18n/i18n.go | 14 + install_linux.sh | 8 +- main.go | 5 +- middleware/security_headers.go | 12 +- models/db.go | 28 +- models/db_test.go | 24 + scripts/build_tailwind.sh | 29 + static/css/app.css | 1915 ++++++++++++++++++++++++++++++++ static/css/input.css | 3 + static/vendor/cropper.min.css | 9 + static/vendor/cropper.min.js | 10 + static/vendor/easymde.min.css | 7 + static/vendor/easymde.min.js | 7 + static/vendor/github.min.css | 10 + static/vendor/highlight.min.js | 1262 +++++++++++++++++++++ static/vendor/marked.min.js | 69 ++ static/vendor/purify.min.js | 3 + templates/layouts/base.html | 20 +- 30 files changed, 4188 insertions(+), 76 deletions(-) create mode 100644 config/config_test.go create mode 100644 handlers/login_ratelimit.go create mode 100644 handlers/p2_validation_test.go create mode 100644 models/db_test.go create mode 100755 scripts/build_tailwind.sh create mode 100644 static/css/app.css create mode 100644 static/css/input.css create mode 100644 static/vendor/cropper.min.css create mode 100644 static/vendor/cropper.min.js create mode 100644 static/vendor/easymde.min.css create mode 100644 static/vendor/easymde.min.js create mode 100644 static/vendor/github.min.css create mode 100644 static/vendor/highlight.min.js create mode 100644 static/vendor/marked.min.js create mode 100644 static/vendor/purify.min.js diff --git a/README.md b/README.md index a1caf23..87c0da9 100644 --- a/README.md +++ b/README.md @@ -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 . ``` -打开 ,使用 **admin** / **admin** 登录。 +打开 登录。首次运行时会自动创建管理员账号 `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/ diff --git a/SECURITY_TODO.md b/SECURITY_TODO.md index e54813a..f167a92 100644 --- a/SECURITY_TODO.md +++ b/SECURITY_TODO.md @@ -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.17,content 扫 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 ` +- **说明**: 部署脚本改动,需在 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 均已完成。剩余: +P0–P1、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:数据库文件移出存储根 diff --git a/config/config.go b/config/config.go index bd40507..f0ced64 100644 --- a/config/config.go +++ b/config/config.go @@ -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 } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..67ad3eb --- /dev/null +++ b/config/config_test.go @@ -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) + } +} diff --git a/handlers/admin_user.go b/handlers/admin_user.go index f0137e9..aef988c 100644 --- a/handlers/admin_user.go +++ b/handlers/admin_user.go @@ -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 diff --git a/handlers/auth.go b/handlers/auth.go index c6c8258..e02ab11 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -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 { diff --git a/handlers/helpers.go b/handlers/helpers.go index 66d18b3..21671e7 100644 --- a/handlers/helpers.go +++ b/handlers/helpers.go @@ -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(). diff --git a/handlers/login_ratelimit.go b/handlers/login_ratelimit.go new file mode 100644 index 0000000..cf55df9 --- /dev/null +++ b/handlers/login_ratelimit.go @@ -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) diff --git a/handlers/p2_validation_test.go b/handlers/p2_validation_test.go new file mode 100644 index 0000000..5925730 --- /dev/null +++ b/handlers/p2_validation_test.go @@ -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") + } +} diff --git a/handlers/profile.go b/handlers/profile.go index afe4736..34b9376 100644 --- a/handlers/profile.go +++ b/handlers/profile.go @@ -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") diff --git a/handlers/security_test.go b/handlers/security_test.go index 952c898..41a92c2 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -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 { diff --git a/handlers/settings.go b/handlers/settings.go index de2b54b..a0a4bd8 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -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 diff --git a/i18n/i18n.go b/i18n/i18n.go index 3795200..4f04064 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -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": "不能禁用或锁定自己的账号。", diff --git a/install_linux.sh b/install_linux.sh index f11dc5e..398c900 100755 --- a/install_linux.sh +++ b/install_linux.sh @@ -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} " +echo "然后重启代理服务,否则代理无法读取 socket(本机其他用户也无法再直连)。" diff --git a/main.go b/main.go index 58711eb..1e33182 100644 --- a/main.go +++ b/main.go @@ -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()) diff --git a/middleware/security_headers.go b/middleware/security_headers.go index 89ebb72..ac2f8ac 100644 --- a/middleware/security_headers.go +++ b/middleware/security_headers.go @@ -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 - - - - + + + + + + @@ -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. */}} - + - - - + + + {{end}}