Files
cs-note/hhs/DEV/鉴权策略/Cookie/SameSite 与 CSRF 防护.md
T
2026-05-24 11:42:38 +08:00

245 lines
8.7 KiB
Markdown
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.
---
tags: [Cookie, CSRF, SameSite, web-security, CORS]
create time: 2026-05-22 10:00
---
# SameSite 与 CSRF 防护
## 概述
`SameSite` 是 Cookie 的一个安全属性,用来控制浏览器在**跨站请求**中是否发送 Cookie。它是防御 CSRF(Cross-Site Request Forgery,跨站请求伪造)攻击最直接的手段——不需要额外的 Token,不需要检查 Origin 头,只需要一个 Cookie 属性就能从浏览器层面阻断攻击。
> [!question] 什么是 CSRF?
> 想象你登录了银行网站 `bank.com`,浏览器里存着银行的认证 Cookie。此时你打开了一个恶意网站 `evil.com`,该页面偷偷向 `bank.com/api/transfer` 发了一个 POST 请求——由于浏览器会自动带上 `bank.com` 的 Cookie,银行服务器以为是你本人操作,转账就成功了。
>
> **你什么都没点,钱就没了。** 这就是 CSRF。
## SameSite 三个值
### 一图总览
```mermaid
flowchart LR
subgraph Origin["当前页面 origin.com"]
A["发起请求"]
end
A --> B{"SameSite 设置?"}
B -->|"Strict"| C["跨站请求不发送 Cookie"]
B -->|"Lax"| D["顶级导航 GET 发送,其他不发送"]
B -->|"None"| E["所有请求都发送(必须 Secure)"]
style C fill:#c8e6c9
style D fill:#fff9c4
style E fill:#ffcdd2
```
### Strict — 最严格
```http
Set-Cookie: session_id=abc; SameSite=Strict; Secure; HttpOnly
```
**行为**:只有从本站发起的请求才会携带 Cookie。即使是用户点击外部链接跳转进来,第一次请求也不带 Cookie。
**优点**:CSRF 彻底无效。
**缺点**:用户体验差——从搜索引擎或书签点击链接进入网站时,用户会发现"明明之前登录了,怎么要重新登录?"
> [!note] 适用场景
> 高敏感操作的保护,比如银行后台管理、密码修改页面。可以只在这些路由的 Cookie 上设 `Strict`。
### Lax — 平衡之选(浏览器默认)
```http
Set-Cookie: session_id=abc; SameSite=Lax; Secure; HttpOnly
```
**行为**:
| 请求方式 | 同站请求 | 跨站请求 |
|----------|----------|----------|
| `<a>` 链接跳转(GET) | ✅ 发送 | ✅ 发送 |
| `<form>` 提交(POST) | ✅ 发送 | ❌ 不发送 |
| `fetch` / `XMLHttpRequest` | ✅ 发送 | ❌ 不发送 |
| `<img>` / `<iframe>` 嵌入 | ✅ 发送 | ❌ 不发送 |
**核心逻辑**:只允许"顶级导航 + GET"的跨站请求携带 Cookie。这意味着用户点击链接跳转到你的网站是 OK 的,但恶意网站通过表单提交或 AJAX 发起的 POST/PUT/DELETE 请求不会带 Cookie。
> [!tip] 为什么 Lax 是默认值?
> `Strict` 太严影响正常体验,`None` 完全不防护。`Lax` 在安全和可用性之间取得了最佳平衡:用户从外部链接正常跳转不受影响,而 CSRF 攻击中常见的跨站 POST/PUT 请求被阻断。Chrome 从 2020 年起将 `Lax` 作为未显式设置 `SameSite` 时的默认值。
### None — 完全开放(必须配合 Secure)
```http
Set-Cookie: session_id=abc; SameSite=None; Secure
```
**行为**:所有请求(无论同站跨站)都发送 Cookie。
**注意**:使用 `None` **必须同时设置 `Secure`**,否则浏览器会拒绝该 Cookie。这是 Chrome 80+ 的强制要求。
> [!warning] 什么时候需要 None?
> 只有在你确实需要跨站发送 Cookie 的场景下才用 `None`,典型情况:
> - 第三方嵌入的支付组件(Stripe、PayPal)
> - 跨域单点登录(SSO)
> - 嵌入式 iframe 中需要认证的组件
>
> 使用 `None` 时**必须**配合其他 CSRF 防护手段(CSRF Token、Origin 检查等)。
## CSRF 攻防详解
### 攻击方式
```mermaid
sequenceDiagram
participant U as 用户
participant B as 浏览器
participant Bank as bank.com
participant Evil as evil.com
U->>Bank: 1. 登录成功,浏览器存储认证 Cookie
U->>Evil: 2. 访问恶意网站
Evil->>Evil: 3. 构造恶意请求(隐藏表单 / JS fetch)
Evil->>B: 4. 触发向 bank.com 的请求
B->>Bank: 5. 自动携带 bank.com 的 Cookie
Bank-->>B: 6. 以为是合法请求,执行操作
Note over U: 用户毫不知情,资产已被转移
```
### 防御方案
#### 方案一:SameSite Cookie(首选)
```http
Set-Cookie: session_id=abc; SameSite=Lax; Secure; HttpOnly
```
简单直接,无需服务端额外逻辑。`Lax` 已经阻断了绝大多数 CSRF 攻击向量。
#### 方案二:CSRF Token(传统方案)
服务端生成一个随机 Token,嵌入到表单中,提交时验证:
```go
// 生成 CSRF Token
func GenerateCSRFToken() string {
token := make([]byte, 32)
rand.Read(token)
return base64.URLEncoding.EncodeToString(token)
}
// 中间件:验证 CSRF Token
func CSRFProtection() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == "GET" {
c.Next() // GET 请求不校验
return
}
// 方式一:从请求头获取(前端 JS 注入)
token := c.GetHeader("X-CSRF-Token")
// 方式二:从表单字段获取
if token == "" {
token = c.PostForm("_csrf")
}
sessionToken, _ := c.Cookie("csrf_token")
if token != sessionToken {
c.AbortWithStatusJSON(403, gin.H{"error": "CSRF token mismatch"})
return
}
c.Next()
}
}
```
```tsx
// React:在请求头中注入 CSRF Token
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
axios.interceptors.request.use((config) => {
if (config.method !== 'get') {
config.headers['X-CSRF-Token'] = csrfToken;
}
return config;
});
```
> [!note] CSRF Token 的原理
> 攻击者可以构造请求,但**无法读取**目标网站的页面内容(受同源策略保护)。所以攻击者拿不到嵌入在页面中的 CSRF Token,也就无法通过验证。
#### 方案三:检查 Origin / Referer 头
```go
func OriginCheck() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin == "" {
origin = c.GetHeader("Referer")
}
// 验证来源是否为可信域
if !strings.HasSuffix(origin, "example.com") {
c.AbortWithStatusJSON(403, gin.H{"error": "invalid origin"})
return
}
c.Next()
}
}
```
> [!warning] Origin 头可以被伪造吗?
> 浏览器安全模型规定:通过代码(fetch/XHR)发起的跨域请求,`Origin` 头由浏览器自动设置,**JS 无法修改**。但某些老旧浏览器或非浏览器客户端(如 curl)可能不带 `Origin` 头,所以不能作为唯一防线。
### 防御方案对比
| 方案 | 防护强度 | 实现复杂度 | 兼容性 | 推荐度 |
|------|----------|------------|--------|--------|
| `SameSite=Lax` | ⭐⭐⭐ | 零成本 | 现代浏览器均支持 | ⭐⭐⭐⭐⭐ |
| CSRF Token | ⭐⭐⭐⭐⭐ | 中等 | 全部浏览器 | ⭐⭐⭐⭐ |
| Origin 检查 | ⭐⭐⭐ | 低 | 全部(需处理空值) | ⭐⭐⭐ |
| 双重 Cookie 验证 | ⭐⭐⭐⭐ | 低 | 全部浏览器 | ⭐⭐⭐ |
> [!info] 最佳实践
> **`SameSite=Lax` + CSRF Token** 双保险。`SameSite` 从浏览器层面阻断大部分攻击,CSRF Token 兜底处理 `SameSite=None` 或老旧浏览器的场景。
## 常见坑与调试技巧
### 1. Cookie 不生效排查清单
> [!warning] 遇到 Cookie "消失"了?按顺序检查
>
> 1. **Domain 匹配**:Cookie 的 `Domain` 必须是当前域或其父域。`localhost` 下设 `Domain=example.com` 会被忽略
> 2. **Path 匹配**:请求路径必须以 Cookie 的 `Path` 为前缀
> 3. **Secure + HTTPS**:`Secure` Cookie 在 HTTP 下不会被发送。本地开发用 `http://localhost` 时不要设 `Secure`
> 4. **SameSite + 跨域**:跨站请求在 `Lax`/`Strict` 下不带 Cookie,这是预期行为
> 5. **过期时间**:检查 `Expires`/`Max-Age` 是否已过期
> 6. **超过大小限制**:单个 Cookie 超过 4KB 会被静默丢弃
### 2. 跨域 Cookie 的正确配置
前后端分离开发中,前端 `localhost:3000` 调后端 `localhost:8080`,需要:
```go
// 后端 CORS 配置
cors.Config{
AllowOrigins: []string{"http://localhost:3000"},
AllowCredentials: true, // 允许携带 Cookie
// 不能同时用 AllowOrigins: ["*"] + AllowCredentials: true
}
```
```typescript
// 前端配置
axios.defaults.withCredentials = true; // 允许跨域携带 Cookie
```
> [!warning] `Access-Control-Allow-Origin` 不能用 `*`
> 当 `AllowCredentials: true` 时,`Access-Control-Allow-Origin` **不能**设为通配符 `*`,必须指定具体的源。这是浏览器安全策略的硬性要求。
## 关联笔记
- [[Cookie]] — Cookie 核心概念与使用方式
- [[hhs/DEV/鉴权策略/JWT/JWT]] — JWT 令牌格式与认证流程