P0(高危): - 新增全局 CSRF 中间件(同步器令牌),覆盖全部 30 个表单与 AJAX 请求 - 修复附件上传/列表/删除越权(IDOR),增加 admin/上传者/文章作者所有权校验 - 登录/注册成功后会话轮换,修复会话固定 - 会话密钥改用 crypto/rand 生成,配置缺失 secret 时拒绝启动 P1(中危): - session 与 comment_uid cookie 增加 Secure/SameSite 标志 - 新增安全响应头:CSP、X-Content-Type-Options、X-Frame-Options、HSTS 等 - 新增 web.trusted_proxies 配置,修复 X-Forwarded-For 伪造 - 修复浏览量记录 goroutine 访问已回收 gin.Context 的数据竞争 补充 17 个安全回归测试(middleware/handlers),go test -race 全绿
94 lines
2.8 KiB
Go
94 lines
2.8 KiB
Go
package middleware
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-contrib/sessions"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// CSRF protection uses the synchronizer-token pattern on top of the existing
|
|
// session store:
|
|
// - Safe methods (GET/HEAD/OPTIONS): a per-session token is created on first
|
|
// use and exposed to templates / JS so it can be embedded in forms.
|
|
// - Unsafe methods (POST/PUT/PATCH/DELETE): the request must carry the token
|
|
// either as the "_csrf" form field (regular forms, multipart uploads) or
|
|
// in the "X-CSRF-Token" header (AJAX). A mismatch aborts with 403.
|
|
//
|
|
// The token is bound to the session, so it works for anonymous visitors (e.g.
|
|
// the comment form) as well as for logged-in users.
|
|
|
|
const (
|
|
// CSRFFieldName is the form field carrying the token.
|
|
CSRFFieldName = "_csrf"
|
|
// CSRFHeaderName is the HTTP header carrying the token (AJAX).
|
|
CSRFHeaderName = "X-CSRF-Token"
|
|
// CSRFSessionKey stores the token server-side.
|
|
CSRFSessionKey = "csrf_token"
|
|
// CSRFContextKey exposes the token to handlers/templates via c.Set.
|
|
CSRFContextKey = "csrf_token"
|
|
)
|
|
|
|
// newCSRFToken returns a 256-bit random hex token. A crypto/rand failure is
|
|
// unrecoverable; panic rather than degrade the defense.
|
|
func newCSRFToken() string {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic("csrf: failed to read random bytes: " + err.Error())
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// csrfTokensEqual compares two tokens in constant time.
|
|
func csrfTokensEqual(a, b string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
var v byte
|
|
for i := 0; i < len(a); i++ {
|
|
v |= a[i] ^ b[i]
|
|
}
|
|
return v == 0
|
|
}
|
|
|
|
// CSRFProtect validates unsafe requests against the per-session CSRF token.
|
|
// It must be registered after the sessions middleware.
|
|
func CSRFProtect() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
session := sessions.Default(c)
|
|
token, _ := session.Get(CSRFSessionKey).(string)
|
|
|
|
switch c.Request.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
|
// Safe method: make sure a token exists and hand it to the
|
|
// template layer.
|
|
if token == "" {
|
|
token = newCSRFToken()
|
|
session.Set(CSRFSessionKey, token)
|
|
_ = session.Save()
|
|
}
|
|
c.Set(CSRFContextKey, token)
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Unsafe method: require a matching token.
|
|
supplied := c.PostForm(CSRFFieldName)
|
|
if supplied == "" {
|
|
supplied = c.GetHeader(CSRFHeaderName)
|
|
}
|
|
if token == "" || supplied == "" || !csrfTokensEqual(token, supplied) {
|
|
c.Header("Cache-Control", "no-store")
|
|
c.Header("Content-Type", "text/plain; charset=utf-8")
|
|
c.String(http.StatusForbidden, "403 Forbidden: CSRF token missing or invalid ("+strconv.Quote(c.Request.Method)+" "+c.Request.URL.Path+")")
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Set(CSRFContextKey, token)
|
|
c.Next()
|
|
}
|
|
}
|