From 46d6f3cd94b0256655782a37dff0438ba8c74a36 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 27 Aug 2026 18:34:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E6=88=90=20P3=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=BF=AE=E5=A4=8D=20#14-17=EF=BC=88=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=A0=A1=E9=AA=8C/=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=85=B3=E9=97=AD=20Gravatar/RSS=20=E5=9B=BA=E5=AE=9A=20URL/bc?= =?UTF-8?q?rypt=20=E6=88=90=E6=9C=AC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SECURITY_TODO.md | 39 ++++---- go.mod | 4 +- handlers/admin_comment.go | 14 ++- handlers/attachment.go | 12 ++- handlers/login_ratelimit.go | 7 +- handlers/p3_upload_test.go | 156 +++++++++++++++++++++++++++++ handlers/profile.go | 13 +++ handlers/rss.go | 21 +++- handlers/security_test.go | 2 + handlers/settings.go | 3 + handlers/upload_validator.go | 32 ++++++ i18n/i18n.go | 4 + models/comment_config.go | 6 +- models/db_test.go | 9 ++ models/seed.go | 5 +- models/site_setting.go | 3 + models/user.go | 8 +- templates/admin/comment_list.html | 4 + templates/admin/settings_site.html | 8 ++ 19 files changed, 316 insertions(+), 34 deletions(-) create mode 100644 handlers/p3_upload_test.go diff --git a/SECURITY_TODO.md b/SECURITY_TODO.md index f167a92..55d6df8 100644 --- a/SECURITY_TODO.md +++ b/SECURITY_TODO.md @@ -170,23 +170,26 @@ ## P3 — 低优先级 / 观察项 -### [ ] 14. 上传不校验文件真实类型 -- **位置**: `handlers/upload_validator.go:33-58` -- **修复**: 用 `github.com/gabriel-vasile/mimetype`(已在依赖树中)校验 magic bytes 与扩展名/MIME 一致;不一致则拒绝 -- **说明**: 白名单无 .svg/.html,存储型 XSS 风险低,主要是恶意文件托管风险 +### [x] 14. 上传不校验文件真实类型 ✅ 2026-08-27 +- **位置**: `handlers/upload_validator.go`(新增 `contentMatchesType`)、`handlers/attachment.go`、`handlers/profile.go` +- **修复**: `mimetype`(v1.4.12 转直接依赖)magic-bytes 检测与扩展名配置的 MimeType 比对(宽容策略:策略为空/`application/octet-stream`/内容不可检测时放行,扩展名白名单仍为主闸);附件 AJAX 400 + 头像 400/表单错误 +- **验证**: ✅ `TestUploadAttachmentRejectsMismatchedContent`(.txt 内容为 PNG 字节 → 400,真文本 → 200)、`TestContentMatchesTypeTable`(8 用例表驱动) -### [ ] 15. Gravatar MD5 邮箱哈希可反查 -- **位置**: `handlers/comment.go:88-91` -- **说明**: Gravatar 协议本身如此;若在意隐私可加后台开关(已有 UseGravatar 开关可关闭) +### [x] 15. Gravatar MD5 邮箱哈希可反查 ✅ 2026-08-27 +- **位置**: `models/seed.go`、`models/comment_config.go`(defaultCommentConfig)、`handlers/admin_comment.go`、`templates/admin/comment_list.html` +- **修复**: 新部署默认 `UseGravatar=false`(gorm tag default:false 同步);前端注释占位(AuthorInitial + 调色板)已是既有模式;管理员评论列表跟随开关,关闭时不再请求 gravatar.com;管理员可在评论设置页显式重开 +- **说明**: 线上已存在配置行不受 default 迁移影响,后台关闭即可;协议固有反查风险保留(开启者知情) +- **验证**: ✅ `TestGravatarOffByDefault`、`TestAdminCommentListFollowsGravatarSwitch`(关:无 gravatar.com;开:出现) -### [ ] 16. RSS 以 Host 头构造 baseURL -- **位置**: `handlers/rss.go:60-64` -- **修复**: 从站点设置中读取固定站点 URL,仅在与请求 Host 不符时告警 -- **说明**: Cloudflare 会校验 Host,实际可利用性低 +### [x] 16. RSS 以 Host 头构造 baseURL ✅ 2026-08-27 +- **位置**: `models/site_setting.go`(新增 SiteURL)、`handlers/settings.go`、`templates/admin/settings_site.html`、`handlers/rss.go` +- **修复**: 站点设置新增规范地址(SiteURL,保存时 trim);RSSFeed 优先使用固定 URL(去尾斜杠),未配置时告警日志 + 回退请求 Host(兼容旧部署) +- **验证**: ✅ `TestRSSUsesConfiguredSiteURL`(配置后 Host 头污染不生效 / 未配置回退) -### [ ] 17. bcrypt cost 偏低 -- **位置**: `models/user.go:41`(DefaultCost=10) -- **修复**: 提升到 12;已有哈希在用户下次改密时自然升级 +### [x] 17. bcrypt cost 偏低 ✅ 2026-08-27 +- **位置**: `models/user.go`(SetPassword)、`handlers/login_ratelimit.go`(dummyHash) +- **修复**: `bcrypt.DefaultCost`(10) → 12(models 包常量 `bcryptCost`,dummy 哈希同 cost);已有哈希自适应不失效,下次改密自然升级 +- **验证**: ✅ 现有认证/限速测试全绿(含 -race);成本升级使登录延迟 ~300ms,配合 #10 限速可接受 ### [x] 25. 登录计时侧信道(用户名枚举)✅ 2026-08-27(与 #10 一并实施) - **位置**: `handlers/auth.go:39-47` @@ -210,9 +213,7 @@ ## 建议执行顺序 -P0–P1、P2 及 P3 的 #25 均已完成(#18 方案 A 可选项除外)。剩余仅 P3 观察项: +全部 25 项(含 P3-14/15/16/17、P3-25)均已修复并验证,仅 #18 方案 A(数据库移出存储根)为可选项: -1. **#14 → #17**(mimetype magic bytes 校验、bcrypt cost 提升)按迭代排入 -2. #15(Gravatar 反查,协议固有)观察即可,已有 UseGravatar 开关可关闭 -3. #16(RSS Host 头)实际可利用性低,Cloudflare 校验 Host;可选:改为站点设置读取固定 URL -4. (可选)#18 方案 A:数据库文件移出存储根 +1. (可选)#18 方案 A:数据库文件移出存储根,需迁移存量 blog.db +2. 持续观察项:#15 Gravatar 开启时的反查风险(管理员知情)、#9 本地化 vendor 库版本升级提醒(随浏览器生态更新,重建 `scripts/build_tailwind.sh` 与 vendor 文件) diff --git a/go.mod b/go.mod index 85f7103..9726c0e 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module go_blog go 1.25.0 require ( + github.com/gabriel-vasile/mimetype v1.4.12 github.com/gin-contrib/sessions v1.1.0 github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 golang.org/x/crypto v0.53.0 + golang.org/x/image v0.43.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.1 @@ -19,7 +21,6 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -48,7 +49,6 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/image v0.43.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/handlers/admin_comment.go b/handlers/admin_comment.go index 0b84708..1d6d0b9 100644 --- a/handlers/admin_comment.go +++ b/handlers/admin_comment.go @@ -17,6 +17,8 @@ const adminCommentPageSize = 30 type commentListView struct { models.Comment GravatarURL string + Initial string + AvatarColor string MaskedEmail string StatusLabel string StatusBadge string @@ -69,11 +71,21 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc { } views := make([]commentListView, 0, len(comments)) + // SECURITY_TODO #15: the admin list follows the platform switch — + // no Gravatar request when it is disabled (front-end placeholder + // used instead). + useGravatar := models.GetCommentConfig().UseGravatar for _, cm := range comments { + gravURL := "" + if useGravatar { + gravURL = cm.GravatarURL(40) + } v := commentListView{ Comment: cm, - GravatarURL: cm.GravatarURL(40), + GravatarURL: gravURL, MaskedEmail: cm.MaskedEmail(), + Initial: cm.AuthorInitial(), + AvatarColor: avatarColorFor(cm.ID), } if a, ok := articleMap[cm.ArticleID]; ok { v.ArticleTitle = a.Title diff --git a/handlers/attachment.go b/handlers/attachment.go index abe778c..c16280c 100644 --- a/handlers/attachment.go +++ b/handlers/attachment.go @@ -123,12 +123,22 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { return } - // Read fully to compute the content hash. + // Read fully: needed for the content hash (dedup) and for magic-byte + // content validation (#14). content, err := io.ReadAll(file) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"}) return } + + // SECURITY_TODO #14: the bytes must match the MIME type configured + // for the claimed extension (magic-byte check against the header + // policy). A .txt named file carrying PNG bytes is rejected. + if !contentMatchesType(check.Type, content) { + c.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match its declared type"}) + return + } + sum := sha256.Sum256(content) stored := hex.EncodeToString(sum[:]) diff --git a/handlers/login_ratelimit.go b/handlers/login_ratelimit.go index cf55df9..909466d 100644 --- a/handlers/login_ratelimit.go +++ b/handlers/login_ratelimit.go @@ -15,6 +15,8 @@ const ( maxLoginFailures = 5 loginLockDuration = 15 * time.Minute maxTrackedKeys = 4096 + // dummyHashCost mirrors the production bcrypt cost (models.bcryptCost). + dummyHashCost = 12 ) // loginRateLimiter tracks consecutive login failures per key ("IP|username"). @@ -129,6 +131,7 @@ func (l *loginRateLimiter) sweep(now time.Time) { // 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. +// (SECURITY_TODO #25). Cost matches production (12, SECURITY_TODO #17) and is +// generated once at package init. var dummyHash, _ = bcrypt.GenerateFromPassword( - []byte("dummy-password-for-constant-time-login"), bcrypt.DefaultCost) + []byte("dummy-password-for-constant-time-login"), dummyHashCost) diff --git a/handlers/p3_upload_test.go b/handlers/p3_upload_test.go new file mode 100644 index 0000000..a6f9e7c --- /dev/null +++ b/handlers/p3_upload_test.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "mime/multipart" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "go_blog/models" +) + +// TestUploadAttachmentRejectsMismatchedContent covers SECURITY_TODO #14: the +// extension whitelist is header-level only; bytes must match the configured +// MIME type (a .txt file carrying PNG bytes is a disguised payload). +func TestUploadAttachmentRejectsMismatchedContent(t *testing.T) { + e := newSecurityTestEnv(t) + alice := e.login(t, "alice") + token := e.csrfTokenFor(t, alice) + + var aliceArt models.Article + e.db.Where("slug = ?", "alice-post").First(&aliceArt) + artID := strconv.FormatUint(uint64(aliceArt.ID), 10) + + // .txt claim, PNG bytes -> reject 400. + var buf strings.Builder + mw := multipart.NewWriter(&buf) + mw.WriteField("article_id", artID) + mw.WriteField("_csrf", token) + fw, _ := mw.CreateFormFile("file", "photo.txt") + fw.Write(pngBytes(t)) + mw.Close() + w := e.do(http.MethodPost, "/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType()) + if w.Code != http.StatusBadRequest { + t.Fatalf("mismatched content: status = %d, want 400 (body %s)", w.Code, w.Body.String()) + } + + // Genuine text passes. + buf.Reset() + mw = multipart.NewWriter(&buf) + mw.WriteField("article_id", artID) + mw.WriteField("_csrf", token) + fw, _ = mw.CreateFormFile("file", "notes.txt") + fw.Write([]byte("hello plain text")) + mw.Close() + w = e.do(http.MethodPost, "/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType()) + if w.Code != http.StatusOK { + t.Fatalf("genuine text upload: status = %d, want 200 (body %s)", w.Code, w.Body.String()) + } +} + +// TestRSSUsesConfiguredSiteURL covers SECURITY_TODO #16: the feed links use +// the canonical site URL when configured and fall back (with a log warning) +// to the request Host otherwise. +func TestRSSUsesConfiguredSiteURL(t *testing.T) { + e := newSecurityTestEnv(t) + + // Unset: falls back to the request Host. + req := httptest.NewRequest(http.MethodGet, "/rss", nil) + req.Host = "evil.example.com" + w := httptest.NewRecorder() + e.router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET /rss: status = %d", w.Code) + } + if !strings.Contains(w.Body.String(), "http://evil.example.com") { + t.Fatal("fallback did not use the request Host") + } + + // Configured: fixed URL wins, Host header is ignored. + var s models.SiteSetting + if err := e.db.First(&s, 1).Error; err != nil { + t.Fatalf("load site setting: %v", err) + } + s.SiteURL = "https://blog.example.com" + if err := e.db.Save(&s).Error; err != nil { + t.Fatalf("save site setting: %v", err) + } + + req = httptest.NewRequest(http.MethodGet, "/rss", nil) + req.Host = "evil.example.com" + w = httptest.NewRecorder() + e.router.ServeHTTP(w, req) + body := w.Body.String() + if !strings.Contains(body, "https://blog.example.com") { + t.Fatal("configured SiteURL was not used in the feed") + } + if strings.Contains(body, "evil.example.com") { + t.Fatal("request Host leaked into RSS link despite SiteURL being set") + } +} + +// TestAdminCommentListFollowsGravatarSwitch covers SECURITY_TODO #15: the +// admin moderation list emits no Gravatar URLs when the platform switch is +// off and uses them when an admin re-enables it. +func TestAdminCommentListFollowsGravatarSwitch(t *testing.T) { + e := newSecurityTestEnv(t) + admin := e.login(t, "admin") + + var art models.Article + e.db.Where("slug = ?", "alice-post").First(&art) + e.db.Create(&models.Comment{ + ArticleID: art.ID, AuthorName: "Ann", Email: "ann@example.com", + Content: "hello", Status: models.CommentApproved, IPAddress: "127.0.0.1", + }) + + // Switch off (new default): no gravatar.com list entries. + w := e.do(http.MethodGet, "/admin/comments?status=all", admin, nil, "") + if w.Code != http.StatusOK { + t.Fatalf("GET /admin/comments: status = %d", w.Code) + } + if strings.Contains(w.Body.String(), "gravatar.com") { + t.Fatal("admin comment list emitted Gravatar URLs while disabled") + } + + // Switch on: Gravatar URLs appear (following the platform policy). + e.db.Model(&models.CommentConfig{}).Where("id = ?", 1).Update("use_gravatar", true) + models.LoadConfigCache(e.db) + w = e.do(http.MethodGet, "/admin/comments?status=all", admin, nil, "") + if w.Code != http.StatusOK { + t.Fatalf("GET /admin/comments (enabled): status = %d", w.Code) + } + if !strings.Contains(w.Body.String(), "gravatar.com") { + t.Fatal("admin comment list missing Gravatar URLs while enabled") + } +} +// TestContentMatchesTypeTable drives the pure matcher (SECURITY_TODO #14). +func TestContentMatchesTypeTable(t *testing.T) { + txt := &models.UploadFileType{MimeType: "text/plain"} + pngType := &models.UploadFileType{MimeType: "image/png"} + noPolicy := &models.UploadFileType{MimeType: ""} + + cases := []struct { + name string + typ *models.UploadFileType + content []byte + want bool + }{ + {"txt-real", txt, []byte("just text content"), true}, + {"txt-png-bytes", txt, pngBytes(t), false}, + {"png-real", pngType, pngBytes(t), true}, + {"png-text-bytes", pngType, []byte("not an image at all"), false}, + {"empty-policy", noPolicy, pngBytes(t), true}, + {"empty-content", txt, nil, true}, + {"octet-stream-wildcard", &models.UploadFileType{MimeType: "application/octet-stream"}, pngBytes(t), true}, + {"charset-parameter", &models.UploadFileType{MimeType: "text/plain; charset=utf-8"}, []byte("abc"), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := contentMatchesType(tc.typ, tc.content); got != tc.want { + t.Fatalf("contentMatchesType(%q) = %v, want %v", tc.typ.MimeType, got, tc.want) + } + }) + } +} diff --git a/handlers/profile.go b/handlers/profile.go index 34b9376..5852ae5 100644 --- a/handlers/profile.go +++ b/handlers/profile.go @@ -134,6 +134,13 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc { c.Redirect(http.StatusFound, "/profile?error=upload") return } + // SECURITY (#14): magic-byte consistency check before decoding — + // the extension policy is header-level only. + if !contentMatchesType(check.Type, imgBytes) { + session.Save() + c.Redirect(http.StatusFound, "/profile?error=upload") + return + } processedBytes, finalExt, err := processAvatar(imgBytes, ext) if err != nil { session.Save() @@ -264,6 +271,12 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc { return } + // SECURITY (#14): magic-byte consistency before decoding. + if !contentMatchesType(check.Type, imgBytes) { + c.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match its declared type"}) + return + } + // Decode, resize, and re-encode the image. processedBytes, finalExt, err := processAvatar(imgBytes, ext) if err != nil { diff --git a/handlers/rss.go b/handlers/rss.go index f600f69..d241fa5 100644 --- a/handlers/rss.go +++ b/handlers/rss.go @@ -4,6 +4,7 @@ import ( "encoding/xml" "fmt" "html" + "log" "net/http" "strings" "time" @@ -56,12 +57,22 @@ func RSSFeed(db *gorm.DB) gin.HandlerFunc { siteSetting := &models.SiteSetting{} db.First(siteSetting) - // Construct the base URL from the request. - scheme := "http" - if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { - scheme = "https" + // SECURITY_TODO #16: the canonical site URL from settings is used + // when configured — the request Host is attacker-controllable and + // would otherwise poison every link in the feed. Fall back with a + // warning for legacy deployments. + var baseURL string + if u := strings.TrimSpace(siteSetting.SiteURL); u != "" { + baseURL = strings.TrimRight(u, "/") + } else { + log.Printf("WARNING: Site URL is not set in settings; RSS links use request Host %q (set settings_site_url to a fixed URL)", + c.Request.Host) + scheme := "http" + if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { + scheme = "https" + } + baseURL = fmt.Sprintf("%s://%s", scheme, c.Request.Host) } - baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host) // Get the latest 20 published articles. var articles []models.Article diff --git a/handlers/security_test.go b/handlers/security_test.go index 41a92c2..d67d9ea 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -85,6 +85,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { r.POST("/article/:slug/comments", PostComment(db)) r.GET("/register", RegisterPage(db)) r.POST("/register", Register(db)) + r.GET("/rss", RSSFeed(db)) protected := r.Group("/my", middleware.AuthRequired(db)) { @@ -117,6 +118,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { admin.GET("/users/:id/edit", UserEditPage(db)) admin.POST("/users/:id/edit", UserUpdate(db)) admin.POST("/users/:id/delete", UserDelete(db)) + admin.GET("/comments", CommentListPage(db)) } return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter} diff --git a/handlers/settings.go b/handlers/settings.go index a0a4bd8..a61dde8 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -100,6 +100,9 @@ func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc { s.HomeSubtitleEn = strings.TrimSpace(c.PostForm("home_subtitle_en")) s.FooterTextZh = strings.TrimSpace(c.PostForm("footer_text_zh")) s.FooterTextEn = strings.TrimSpace(c.PostForm("footer_text_en")) + // SECURITY_TODO #16: canonical feed/site URL; RSS uses this instead of + // the request Host to avoid Host-header pollution. + s.SiteURL = strings.TrimSpace(c.PostForm("site_url")) s.AllowRegistration = c.PostForm("allow_registration") == "1" s.UpdatedBy = userIDFromSession(c) diff --git a/handlers/upload_validator.go b/handlers/upload_validator.go index 825188c..9e36f9a 100644 --- a/handlers/upload_validator.go +++ b/handlers/upload_validator.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" + "github.com/gabriel-vasile/mimetype" + "go_blog/models" ) @@ -70,3 +72,33 @@ func formatSize(b int64) string { } return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) } + +// contentMatchesType checks the uploaded bytes' magic bytes against the MIME +// type configured for the matched extension (SECURITY_TODO #14). It is +// deliberately lenient: empty/unknown MIME policies and unrecognizable +// content pass (the extension whitelist remains the primary gate); a file +// that claims .txt while carrying PNG bytes is rejected. +func contentMatchesType(t *models.UploadFileType, content []byte) bool { + if t == nil || len(content) == 0 { + return true + } + expected := strings.ToLower(strings.TrimSpace(t.MimeType)) + if expected == "" || expected == "application/octet-stream" { + // No concrete policy, or the admin explicitly allows any binary. + return true + } + // Normalize away a charset parameter the admin may have copied. + if i := strings.Index(expected, ";"); i >= 0 { + expected = strings.TrimSpace(expected[:i]) + } + if expected == "" { + return true + } + det := mimetype.Detect(content) + if det == nil || det.String() == "" { + // Content undetectable (e.g. exotic Unicode text); header policy + // alone remains the gate. + return true + } + return det.Is(expected) +} diff --git a/i18n/i18n.go b/i18n/i18n.go index 4f04064..762e677 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -80,6 +80,8 @@ var translations = map[Lang]map[string]string{ // Settings "settings_allow_registration": "Allow user registration", "settings_allow_registration_hint": "When enabled, visitors can create their own accounts from the login page", + "settings_site_url": "Canonical site URL (used in RSS feeds)", + "settings_site_url_hint": "Used as the base URL in RSS links. Leave blank to fall back to the request Host (legacy).", // Dashboard "dash_title": "Dashboard", @@ -508,6 +510,8 @@ var translations = map[Lang]map[string]string{ // 平台设置 "settings_allow_registration": "允许用户注册", "settings_allow_registration_hint": "启用后,访客可以从登录页面创建自己的账号", + "settings_site_url": "站点规范地址(用于 RSS)", + "settings_site_url_hint": "RSS 链接将使用该地址作为前缀。留空则回退为请求 Host(旧行为)。", // 后台 "dash_title": "后台管理", diff --git a/models/comment_config.go b/models/comment_config.go index c8381d4..5373270 100644 --- a/models/comment_config.go +++ b/models/comment_config.go @@ -8,7 +8,9 @@ type CommentConfig struct { Enabled bool `gorm:"default:true" json:"enabled"` // master switch for the comment system AllowGuest bool `gorm:"default:true" json:"allow_guest"` // whether anonymous (non-logged-in) comments are allowed GuestRequireApproval bool `gorm:"default:false" json:"guest_require_approval"` // hold guest comments in the moderation queue - UseGravatar bool `gorm:"default:true" json:"use_gravatar"` // when false, avatars render as a text-initial placeholder + // SECURITY_TODO #15: Gravatar reveals MD5(email) via reverse lookup; + // off by default on new deployments (admins can re-enable explicitly). + UseGravatar bool `gorm:"default:false" json:"use_gravatar"` // when false, avatars render as a text-initial placeholder UpdatedBy uint `gorm:"index" json:"updated_by"` UpdatedAt time.Time `json:"updated_at"` } @@ -26,6 +28,6 @@ func defaultCommentConfig() *CommentConfig { Enabled: true, AllowGuest: true, GuestRequireApproval: false, - UseGravatar: true, + UseGravatar: false, } } diff --git a/models/db_test.go b/models/db_test.go index 5fb9c88..9bbbee8 100644 --- a/models/db_test.go +++ b/models/db_test.go @@ -22,3 +22,12 @@ func TestRandomAdminPassword(t *testing.T) { t.Fatal("two generated passwords are identical") } } + +// TestGravatarOffByDefault covers SECURITY_TODO #15: new deployments must not +// leak MD5(email) to Gravatar unless an admin deliberately enables it. +func TestGravatarOffByDefault(t *testing.T) { + cc := defaultCommentConfig() + if cc.UseGravatar { + t.Fatal("default CommentConfig enables Gravatar") + } +} diff --git a/models/seed.go b/models/seed.go index 872d03e..61825c9 100644 --- a/models/seed.go +++ b/models/seed.go @@ -51,7 +51,10 @@ func seedCommentConfig(db *gorm.DB) { Enabled: true, AllowGuest: true, GuestRequireApproval: false, - UseGravatar: true, + // SECURITY_TODO #15: Gravatar reveals MD5(email) via reverse lookup; + // disabled by default on new deployments. Admins can re-enable from + // the comment settings page. + UseGravatar: false, } if err := db.Create(c).Error; err != nil { log.Printf("Warning: failed to seed comment_configs: %v", err) diff --git a/models/site_setting.go b/models/site_setting.go index 124d590..4ca6cef 100644 --- a/models/site_setting.go +++ b/models/site_setting.go @@ -19,6 +19,9 @@ type SiteSetting struct { HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"` // home page subtitle (en) FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // footer text (zh) FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // footer text (en) + // SiteURL is the canonical site base URL used for RSS/feed links + // (SECURITY_TODO #16); empty falls back to the request Host at runtime. + SiteURL string `gorm:"size:512" json:"site_url"` AllowRegistration bool `gorm:"default:false" json:"allow_registration"` // whether users can self-register UpdatedBy uint `gorm:"index" json:"updated_by"` UpdatedAt time.Time `json:"updated_at"` diff --git a/models/user.go b/models/user.go index 61160ea..51eb426 100644 --- a/models/user.go +++ b/models/user.go @@ -36,9 +36,15 @@ type User struct { Articles []Article `gorm:"foreignKey:AuthorID" json:"-"` } +// bcryptCost is the work factor used when hashing new passwords +// (SECURITY_TODO #17). Existing hashes keep their cost — CompareHashAndPassword +// adapts per hash — and are naturally upgraded on the user's next password +// change. +const bcryptCost = 12 + // SetPassword hashes the plain-text password with bcrypt and stores it. func (u *User) SetPassword(plain string) error { - hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost) if err != nil { return err } diff --git a/templates/admin/comment_list.html b/templates/admin/comment_list.html index 44f72c6..7060103 100644 --- a/templates/admin/comment_list.html +++ b/templates/admin/comment_list.html @@ -35,7 +35,11 @@ {{range .Comments}}
+ {{if .GravatarURL}} + {{else}} +
{{.Initial}}
+ {{end}}
{{.AuthorName}} diff --git a/templates/admin/settings_site.html b/templates/admin/settings_site.html index e2b7178..d6ca1a7 100644 --- a/templates/admin/settings_site.html +++ b/templates/admin/settings_site.html @@ -129,6 +129,14 @@

{{index .Tr "settings_leave_blank"}}

+ +
+ + +

{{index .Tr "settings_site_url_hint"}}

+
+