fix: 完成 P3 安全修复 #14-17(文件类型校验/默认关闭 Gravatar/RSS 固定 URL/bcrypt 成本)
This commit is contained in:
19 files changed
+316
-34
No files matched your search
+20
-19
@@ -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 文件)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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[:])
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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": "后台管理",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user