245 lines
8.7 KiB
Markdown
245 lines
8.7 KiB
Markdown
|
|
---
|
|||
|
|
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 令牌格式与认证流程
|