Files
go_blog/main.go
T
dsh 52ca450ddd fix: SECURITY_TODO #28 评论提交按 IP 限流(5 条/分钟)防灌水刷屏
评论端点未认证即可提交(默认 AllowGuest=true 且即时公开),配合默认
GuestRequireApproval=false 开箱即用状态可被灌水机刷屏。PostComment
复用 #27 的 WindowRateLimiter 按 IP 限流 5 条/分钟,超限 429 +
i18n comments_locked(中英)。

- handlers/comment.go: PostComment(db, limiter),键前缀区分
- main.go / security_test.go: 评论限流器接线
- 测试: TestCommentRateLimited(同 IP 5 次成功、第 6 次 429、
  其他 IP 不受影响)

注: 可选项“新部署默认 GuestRequireApproval=true”为产品决策,未随本项实施。
2026-08-27 21:39:28 +08:00

370 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"go_blog/config"
"go_blog/handlers"
"go_blog/middleware"
"go_blog/models"
)
// staticFiles 将静态资源(Markdown CSS/JS)嵌入二进制文件,
// 使部署只需替换可执行文件——无需向服务器复制独立的静态目录。
//
//go:embed static
var staticFiles embed.FS
func main() {
// 0. 解析命令行参数。
configFlag := flag.String("config", "", "path to config file (default: OS-aware path)")
flag.Parse()
// 1. 加载配置(不存在时自动创建)。
cfg := config.LoadConfig(*configFlag)
// 2. 初始化数据库(自动迁移、初始化管理员)。
db := models.InitDB(cfg)
// 2b. 从数据库预热平台配置缓存。
models.LoadConfigCache(db)
// 3. 创建会话存储(基于 Cookie)。
store := cookie.NewStore([]byte(cfg.Secret))
// 登录速率限制器(SECURITY_TODO #10):按 IP+用户名计数失败次数,
// 使 libcurl/字典攻击者无法猛攻登录端点。
loginLimiter := handlers.NewLoginLimiter()
store.Options(sessions.Options{
Path: "/",
MaxAge: 86400, // 24 小时
HttpOnly: true, // 防止 XSS 访问
SameSite: http.SameSiteLaxMode, // CSRF 纵深防御;令牌校验是主控措施
// Secure 在下方的中间件中按请求设置(仅 HTTPS 时)。
})
// 4. 创建 Gin 路由器。
router := gin.Default()
// 4b. 可信代理:只有列表中的 IP 才能影响客户端 IPX-Forwarded-For)。
// 如果不设置,gin 会信任所有代理,客户端就能伪造评论/文章浏览
// 中记录的 IP。
if err := router.SetTrustedProxies(cfg.Web.TrustedProxies); err != nil {
log.Fatalf("Invalid trusted_proxies in config: %v", err)
}
// 4c. 安全响应头(最先注册,确保被拒绝的响应上也包含它们)。
router.Use(middleware.SecurityHeaders())
// 5. 加载 HTML 模板。
router.LoadHTMLGlob("templates/**/*.html")
// 6. 从存储路径提供上传文件(头像等)。只暴露已知的上传子目录——
// 绝不暴露存储根目录本身,其中还包含 SQLite 数据库文件:挂载整个
// 根目录会让任何人下载 /uploads/blog.dbSECURITY_TODO #18)。
registerUploadRoutes(router.Group("/uploads"), cfg.Path, models.GetUploadConfig().StorageDir)
// 6b. 提供捆绑的静态资源(内嵌于二进制中)。
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
log.Fatalf("Failed to open embedded static assets: %v", err)
}
router.StaticFS("/static", http.FS(staticFS))
// 6. 全局会话中间件。
router.Use(sessions.Sessions("blog_session", store))
// 6a. 按请求的会话 Cookie 加固:仅 HTTPS 时设置 Secure,以及
// SameSite=Lax。按请求应用是因为应用位于 TLS 终结端
//Caddy/Cloudflare)之后,启动时无法得知客户端连接是否加密。
router.Use(func(c *gin.Context) {
opts := sessions.Options{
Path: "/",
MaxAge: 86400,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
if middleware.IsHTTPSRequest(c) {
opts.Secure = true
}
sessions.Default(c).Options(opts)
})
// 6b. 全局上下文中间件(为模板设置 IsLoggedIn、Username 等)。
// 先于 CSRF 注册:BodyLimit 的 413 文案按请求语言翻译,
// 需要这时 tr 已注入上下文。
router.Use(middleware.SetUserContext(db))
// 6c. 请求体大小限制(SECURITY_TODO #26):必须在 CSRF 之前注册——
// CSRF 解析 multipart 表单会读取整个请求体,不设上限时未认证请求
// 即可通过 multipart 解析耗尽内存/磁盘。
router.Use(middleware.BodyLimit())
// 6d. CSRF 防护(必须在会话中间件之后运行)。
router.Use(middleware.CSRFProtect())
// 7. 注册路由。
registerRoutes(router, cfg, db, loginLimiter)
// 9. 启动服务器。
webPort := cfg.Web.Port
socketPath := cfg.Web.Socket
usePort := webPort != "" && webPort != "0"
useSocket := socketPath != ""
if !usePort && !useSocket {
log.Fatalf("Neither port nor socket is configured — at least one must be enabled")
}
if usePort {
go func() {
addr := fmt.Sprintf(":%s", webPort)
log.Printf("Go Blog starting on http://localhost%s", addr)
if err := router.Run(addr); err != nil {
log.Fatalf("Failed to start HTTP server: %v", err)
}
}()
}
if useSocket {
go func() {
os.Remove(socketPath) // 移除遗留的 socket 文件(若存在)
listener, err := net.Listen("unix", socketPath)
if err != nil {
log.Fatalf("Failed to listen on unix socket %s: %v", socketPath, err)
}
log.Printf("Go Blog starting on unix socket %s", socketPath)
if err := router.RunListener(listener); err != nil {
log.Fatalf("Failed to serve on unix socket: %v", err)
}
}()
}
// 永久阻塞。
select {}
}
// registerRoutes 注册全部业务路由。独立成函数便于测试:
// 签名包含 db 与 loginLimiter,但注册阶段不会触碰它们(handler 是惰性工厂),
// 因此冒烟测试可传 nil。
func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLimiter *handlers.LoginRateLimiter) {
// 注册/评论限流器(SECURITY_TODO #27/#28):固定窗口、进程内存、map 有界。
// 单实例部署无需共享存储。
registerLimiter := handlers.NewWindowLimiter(10, time.Hour)
commentLimiter := handlers.NewWindowLimiter(5, time.Minute)
// 公开页面。
router.GET("/", handlers.HomePage(db))
router.GET("/search", handlers.SearchPage(db))
router.GET("/rss", handlers.RSSFeed(db))
router.GET("/feed", handlers.RSSFeed(db))
router.GET("/login", handlers.LoginPage())
router.GET("/register", handlers.RegisterPage(db))
router.GET("/article/:slug", handlers.ArticleDetail(db))
// 公开 JSON API。
api := router.Group("/api")
{
api.GET("/articles", handlers.HomeArticlesAPI(db))
api.POST("/auth/login", handlers.Login(db, loginLimiter))
api.POST("/auth/register", handlers.Register(db, registerLimiter))
api.POST("/auth/logout", handlers.Logout())
api.POST("/article/:slug/comments", handlers.PostComment(db, commentLimiter))
}
// 受保护的后台路由(仅管理员角色)。
admin := router.Group("/admin")
admin.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
admin.GET("", handlers.AdminDashboard(db))
admin.GET("/articles", handlers.ArticleListPage(db))
admin.GET("/articles/new", handlers.ArticleCreatePage(db))
admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db))
}
adminArticleAPI := router.Group("/api/admin/articles")
adminArticleAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
adminArticleAPI.POST("", handlers.ArticleCreate(db, "/admin"))
adminArticleAPI.PUT("/:id", handlers.ArticleUpdate(db, "/admin/articles"))
adminArticleAPI.DELETE("/:id", handlers.ArticleDelete(db, "/admin/articles"))
}
// 受保护的后台评论管理路由(仅管理员角色)。
comments := router.Group("/admin/comments")
comments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
comments.GET("", handlers.CommentListPage(db))
}
commentsAPI := router.Group("/api/admin/comments")
commentsAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
commentsAPI.POST("/:id/approve", handlers.CommentApprove(db))
commentsAPI.POST("/:id/reject", handlers.CommentReject(db))
commentsAPI.POST("/:id/delete", handlers.CommentDelete(db))
}
// 受保护的后台用户管理路由(仅管理员角色)。
users := router.Group("/admin/users")
users.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
users.GET("", handlers.UserListPage(db))
users.GET("/new", handlers.UserCreatePage(db))
users.GET("/:id/edit", handlers.UserEditPage(db))
}
usersAPI := router.Group("/api/admin/users")
usersAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
usersAPI.POST("", handlers.UserCreate(db))
usersAPI.PUT("/:id", handlers.UserUpdate(db))
usersAPI.DELETE("/:id", handlers.UserDelete(db))
}
// 受保护的后台文章附件 API / 路由(仅管理员角色)。
// 注意 /api/admin/articles/attachments 的静态段与 /:id 参数段共存,
// gin 对静态段优先,无冲突(由 main_test.go 冒烟测试验证)。
adminAPI := router.Group("/api/admin")
adminAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
adminAPI.POST("/articles/attachments", handlers.UploadAttachment(db, cfg.Path))
adminAPI.DELETE("/articles/attachments/:id", handlers.DeleteAttachment(db, cfg.Path))
adminAPI.GET("/articles/:id/attachments", handlers.ListAttachments(db))
}
// 受保护的后台设置路由(平台配置)。
settings := router.Group("/admin/settings")
settings.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
settings.GET("/site", handlers.SiteSettingsPage(db))
settings.GET("/navlinks", handlers.NavLinksSettingsPage(db))
settings.GET("/upload", handlers.UploadSettingsPage(db))
settings.GET("/download", handlers.DownloadSettingsPage(db))
settings.GET("/comments", handlers.CommentSettingsPage(db))
}
settingsAPI := router.Group("/api/admin/settings")
settingsAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
settingsAPI.POST("/site", handlers.SiteSettingsSave(db, cfg.Path))
settingsAPI.POST("/site/favicon", handlers.SiteFaviconUpload(db, cfg.Path))
settingsAPI.POST("/site/logo", handlers.SiteLogoUpload(db, cfg.Path))
settingsAPI.POST("/navlinks", handlers.NavLinksSettingsSave(db))
settingsAPI.POST("/upload", handlers.UploadSettingsSave(db))
settingsAPI.POST("/download", handlers.DownloadSettingsSave(db))
settingsAPI.POST("/comments", handlers.CommentSettingsSave(db))
}
// 受保护的后台统计路由(读取统计信息)。
analytics := router.Group("/admin/analytics")
analytics.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
analytics.GET("/views", handlers.ViewAnalyticsPage(db))
}
// 受保护的个人资料路由。
profile := router.Group("/profile")
profile.Use(middleware.AuthRequired(db))
{
profile.GET("", handlers.ProfilePage(db))
}
profileAPI := router.Group("/api/profile")
profileAPI.Use(middleware.AuthRequired(db))
{
profileAPI.POST("", handlers.UpdateProfile(db, cfg.Path))
profileAPI.POST("/avatar", handlers.UploadAvatar(db, cfg.Path))
}
// 受保护的用户文章管理路由(面向非管理员用户)。
myArticles := router.Group("/my")
myArticles.Use(middleware.AuthRequired(db))
{
myArticles.GET("/articles", handlers.MyArticlesPage(db))
myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db))
myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db))
}
// 用户文章的受保护 API(仅登录用户,含 attachments 静态段与 :id 参数段)。
myAPI := router.Group("/api/my/articles")
myAPI.Use(middleware.AuthRequired(db))
{
myAPI.POST("", handlers.MyArticleCreate(db))
myAPI.PUT("/:id", handlers.MyArticleUpdate(db))
myAPI.DELETE("/:id", handlers.MyArticleDelete(db))
myAPI.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
myAPI.DELETE("/attachments/:id", handlers.DeleteAttachment(db, cfg.Path))
myAPI.GET("/:id/attachments", handlers.ListAttachments(db))
}
}
// registerUploadRoutes 在 /uploads 组下暴露公开的上传子目录:avatars、
// logos,以及配置的附件存储目录(外加向后兼容的默认 "attachments")。
// 存储根目录绝不挂载——其中还包含 SQLite 数据库文件,
// 该文件不可被下载(SECURITY_TODO #18)。
// 禁用目录列表:仅具体文件可解析。
func registerUploadRoutes(g *gin.RouterGroup, storagePath, storageDir string) {
dirs := []string{"attachments", "avatars", "logos"}
if dir := safeStorageDir(storageDir); dir != "attachments" && dir != "avatars" && dir != "logos" {
dirs = append(dirs, dir)
}
for _, d := range dirs {
h := serveUploadDir(filepath.Join(storagePath, d))
g.GET("/"+d+"/*file", h)
g.HEAD("/"+d+"/*file", h)
}
}
// safeStorageDir 将配置的附件存储目录收窄为安全的相对路径:
// 非空、非绝对路径,且不含 ".." 或 "\"。
// 任何不安全值回退到默认的 "attachments"
// 使配置错误的 storage_dir 无法逃逸出存储根目录
// (针对 SECURITY_TODO #22 的纵深防御)。
func safeStorageDir(dir string) string {
const fallback = "attachments"
if dir == "" {
return fallback
}
cleaned := path.Clean(dir)
if path.IsAbs(cleaned) || cleaned == "." ||
strings.Contains(cleaned, "..") || strings.Contains(cleaned, "\\") {
return fallback
}
return cleaned
}
// serveUploadDir 从一个上传子目录提供具体文件。
// 目录列表和路径穿越尝试以 404 拒绝。
func serveUploadDir(root string) gin.HandlerFunc {
return func(c *gin.Context) {
rel := c.Param("file") // 始终以 "/" 开头
if strings.Contains(rel, "..") || strings.ContainsRune(rel, '\\') {
c.Status(http.StatusNotFound)
return
}
full := filepath.Join(root, rel)
if st, err := os.Stat(full); err != nil || st.IsDir() {
c.Status(http.StatusNotFound)
return
}
c.File(full)
}
}