package web import ( "crypto/rand" "crypto/subtle" "encoding/base64" "net/http" "github.com/gin-gonic/gin" "cmautobuy/admin/service" ) // CSRF 防护,用的是「双提交 Cookie」这个最简单的方案: // // 1. 给浏览器种一个随机 token 的 Cookie; // 2. 每个表单里带一个同值的隐藏字段; // 3. 提交时比对两者,不一致就拒绝。 // // 原理:攻击者的页面可以诱导浏览器带上 Cookie 发请求, // 但**读不到** Cookie 的值,所以拼不出正确的隐藏字段。 // // 没有引第三方库,是因为这个方案本身就几十行, // 而且多一个依赖就多一个可能要求 Go >= 1.25 的风险。 // // **只给页面路由用。** 给 Client 的 /api/v1/client/* 绝不能加—— // 它不是浏览器、没有 Cookie,加了会直接把它挡在门外。 const ( csrfCookieName = "cmautobuy_csrf" csrfFieldName = "csrf_token" csrfTokenBytes = 32 ) // CSRFMiddleware 返回 Gin 中间件。 // // GET 等安全方法只负责发 token;POST 等写操作要校验。 func CSRFMiddleware() gin.HandlerFunc { return func(c *gin.Context) { token, err := c.Cookie(csrfCookieName) if err != nil || token == "" { token, err = newCSRFToken() if err != nil { c.AbortWithStatus(http.StatusInternalServerError) return } // HttpOnly 必须为 false —— 双提交方案要让页面把值填进表单。 http.SetCookie(c.Writer, &http.Cookie{ Name: csrfCookieName, Value: token, Path: "/", HttpOnly: false, Secure: requestIsHTTPS(c.Request), SameSite: http.SameSiteLaxMode, MaxAge: 12 * 3600, }) } // 交给模板渲染成隐藏字段 c.Set(csrfFieldName, token) switch c.Request.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: c.Next() return } // multipart 表单会在下面 PostForm 时被解析,所以请求体上限必须在它之前设置。 // 放到具体上传 Handler 里已经太晚:那时文件可能已经被读进临时目录。 switch c.Request.URL.Path { case "/pdd/import": c.Request.Body = http.MaxBytesReader( c.Writer, c.Request.Body, service.MaxPddUploadBytes+1<<20) case "/shopee/import": c.Request.Body = http.MaxBytesReader( c.Writer, c.Request.Body, service.MaxShopeeUploadBytes+1<<20) } submitted := c.PostForm(csrfFieldName) if submitted == "" { submitted = c.GetHeader("X-CSRF-Token") } // 用常数时间比较,避免通过响应快慢猜 token if subtle.ConstantTimeCompare([]byte(submitted), []byte(token)) != 1 { c.HTML(http.StatusForbidden, "partials/error", gin.H{ "Title": "请求被拒绝", "Message": "表单校验失败(CSRF token 不匹配)。" + "通常是页面开太久过期了,返回上一页刷新后重试即可。", }) c.Abort() return } c.Next() } } // newCSRFToken 生成一个随机 token。 func newCSRFToken() (string, error) { buf := make([]byte, csrfTokenBytes) if _, err := rand.Read(buf); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buf), nil } // csrfToken 从上下文取出当前 token,供页面渲染隐藏字段。 func csrfToken(c *gin.Context) string { if v, ok := c.Get(csrfFieldName); ok { if s, ok := v.(string); ok { return s } } return "" }