fix: 完成 P3 安全修复 #14-17(文件类型校验/默认关闭 Gravatar/RSS 固定 URL/bcrypt 成本)

This commit is contained in:
2026-08-27 18:34:17 +08:00
parent c9f858b626
commit 46d6f3cd94
19 files changed
+316 -34

No files matched your search

+20 -19
View File
@@ -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) → 12models 包常量 `bcryptCost`,dummy 哈希同 cost);已有哈希自适应不失效,下次改密自然升级
- **验证**: ✅ 现有认证/限速测试全绿(含 -race);成本升级使登录延迟 ~300ms,配合 #10 限速可接受
### [x] 25. 登录计时侧信道(用户名枚举)✅ 2026-08-27(与 #10 一并实施)
- **位置**: `handlers/auth.go:39-47`
@@ -210,9 +213,7 @@
## 建议执行顺序
P0P1、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. #15Gravatar 反查,协议固有)观察即可,已有 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 文件)
+2 -2
View File
@@ -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
+13 -1
View File
@@ -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
+11 -1
View File
@@ -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[:])
+5 -2
View File
@@ -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)
+156
View File
@@ -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)
}
})
}
}
+13
View File
@@ -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 {
+16 -5
View File
@@ -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
+2
View File
@@ -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}
+3
View File
@@ -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)
+32
View File
@@ -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)
}
+4
View File
@@ -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": "后台管理",
+4 -2
View File
@@ -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,
}
}
+9
View File
@@ -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")
}
}
+4 -1
View File
@@ -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)
+3
View File
@@ -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"`
+7 -1
View File
@@ -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
}
+4
View File
@@ -35,7 +35,11 @@
{{range .Comments}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex items-start gap-3">
{{if .GravatarURL}}
<img src="{{.GravatarURL}}" alt="" class="w-10 h-10 rounded-full bg-gray-100">
{{else}}
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold" style="background:{{.AvatarColor}}">{{.Initial}}</div>
{{end}}
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="font-semibold text-gray-800">{{.AuthorName}}</span>
+8
View File
@@ -129,6 +129,14 @@
<p class="text-xs text-gray-400">{{index .Tr "settings_leave_blank"}}</p>
<!-- Canonical site URL (RSS) -->
<div class="pt-4 border-t border-gray-200">
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_site_url"}}</label>
<input type="url" name="site_url" value="{{.Site.SiteURL}}" placeholder="https://example.com"
class="w-full border border-gray-300 rounded-lg px-3 py-2">
<p class="text-xs text-gray-500 mt-1">{{index .Tr "settings_site_url_hint"}}</p>
</div>
<!-- Registration settings -->
<div class="pt-4 border-t border-gray-200">
<label class="flex items-center gap-3 cursor-pointer">