Files
cmautobuy/admin/handler/web/csrf.go
T

109 lines
3.2 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 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 ""
}