← 中文资源

简体中文版本

CSRF 双提交令牌中间件

在安全请求上发放随机 CSRF Cookie,对写操作要求请求头携带相同令牌,并用常量时间比较防止时序侧信道。

阅读英文原文

功能说明

跨站请求伪造利用浏览器自动携带 Cookie 的行为,让用户在已登录状态下执行非预期写操作。双提交模式在 Cookie 中放一个随机令牌,同时要求前端从页面读取该值并放到自定义请求头。攻击者的站点可以触发请求并带上 Cookie,但受同源策略限制无法读取 Cookie 值,也就无法伪造请求头。服务端比较两者是否一致即可拒绝伪造请求。

中间件在 GET、HEAD、OPTIONS 等安全方法上检查 Cookie,缺失时生成三十二字节随机数并写入。写方法必须同时具备 Cookie 与匹配的请求头,否则返回四百零三。前端需要用 JavaScript 读取 Cookie 并设置请求头,因此该 Cookie 不能设为 HttpOnly。

关键代码

newToken 用 crypto/rand 生成三十二字节,再以无填充的 URL 安全 Base64 编码,避免加号和斜杠带来的转义问题。CSRFMiddleware 先取 Cookie,对安全方法仅在缺失时补发,并设置 Path、SameSite 和按是否 TLS 决定 Secure。写方法先拒绝缺失 Cookie 的请求,再用 subtle.ConstantTimeCompare 比较请求头与 Cookie 值,长度不同也返回零,避免逐字节提前退出泄露信息。main 把中间件包在业务处理器外。

使用注意

双提交只适用于同源前端;如果前端与接口跨站,需要正确配置 CORS 与凭据,且 Cookie 的 SameSite 策略要匹配。应同时校验 Origin 或 Referer,作为纵深防御。令牌必须每次会话随机生成,不能使用可预测值。不要在 URL 查询参数中传递令牌,它会进入日志和浏览器历史。登出时应清除 Cookie。若站点存在 XSS,攻击者可以读取令牌,CSRF 防护不能替代输出编码和内容安全策略。

验证方式

启动服务后先请求 GET /submit,响应会带上 csrf_token Cookie。再发 POST 请求并复制该 Cookie 值到 X-CSRF-Token 请求头,应返回 accepted。省略请求头或改成错误值应返回四百零三和 invalid csrf token,说明写操作被拒绝。

代码示例

package main

import (
	"crypto/rand"
	"crypto/subtle"
	"encoding/base64"
	"fmt"
	"net/http"
)

const (
	csrfCookie = "csrf_token"
	csrfHeader = "X-CSRF-Token"
)

func newToken() (string, error) {
	buffer := make([]byte, 32)
	if _, err := rand.Read(buffer); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(buffer), nil
}

func CSRFMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cookie, cookieErr := r.Cookie(csrfCookie)
		switch r.Method {
		case http.MethodGet, http.MethodHead, http.MethodOptions:
			if cookieErr != nil {
				token, err := newToken()
				if err != nil {
					http.Error(w, "cannot issue csrf token", http.StatusInternalServerError)
					return
				}
				http.SetCookie(w, &http.Cookie{
					Name:     csrfCookie,
					Value:    token,
					Path:     "/",
					HttpOnly: false,
					SameSite: http.SameSiteLaxMode,
					Secure:   r.TLS != nil,
				})
			}
			next.ServeHTTP(w, r)
			return
		}

		if cookieErr != nil {
			http.Error(w, "missing csrf cookie", http.StatusForbidden)
			return
		}
		header := r.Header.Get(csrfHeader)
		if subtle.ConstantTimeCompare([]byte(header), []byte(cookie.Value)) != 1 {
			http.Error(w, "invalid csrf token", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func main() {
	handler := CSRFMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "accepted")
	}))
	http.Handle("/submit", handler)
	_ = http.ListenAndServe(":8080", nil)
}