评论端点未认证即可提交(默认 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”为产品决策,未随本项实施。
370 lines
13 KiB
Go
370 lines
13 KiB
Go
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 才能影响客户端 IP(X-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.db(SECURITY_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)
|
||
}
|
||
}
|