扫描报告翻到最后一页,那一屏“低危”里躺着一条:会话cookie中缺少HttpOnly属性。多数人扫一眼就点了“忽略”,或者丢给开发加个属性了事。但这条告警的位置很尴尬——它单独看确实危害有限,可一旦和前端的一处 XSS 或者一个第三方脚本连起来,就直接变成“攻击者不用破解密码,复制一串字符就能登进你账号”的完整链条。我处理过几次真实的会话被盗事件,复盘下来,HttpOnly缺失从来不是根因,但它往往是把“小问题”放大成“事故”的那块短板。
这篇内容想聊的不是“加个属性”这么简单。我会从这条告警怎么被判出来、HttpOnly 到底挡住了什么、各个技术栈具体怎么改、改完怎么验证、上线后业务为什么会挂,一路讲到怎么把 Cookie 安全基线固化成评审清单和 CI 拦截规则。适合写业务的后端同学、做安全测试的同学,也适合负责网关和发布流程的运维同学对照着看。内容里有大量实操细节和我自己踩过的坑,能直接抄作业,也能帮你在评审会上把“为什么要改”讲明白。
1. 这条告警到底在说什么:HttpOnly的作用边界
1.1 从一份扫描报告说起:判定逻辑其实很朴素
大部分扫描器对这条的判定逻辑非常直接:抓取响应里的Set-Cookie头,逐个解析属性列表,只要发现某个 cookie 的名字看起来像会话标识(常见关键词如session、sess、jsessionid、phpsessid、token、sid、auth等),且属性列表里没有出现HttpOnly,就抛一条。有些工具还会做二次判断:如果这个 cookie 在后续请求里一直带着,且值在登录前后发生变化,就把它升级成“确认的会话 cookie”。
这个逻辑有个明显的问题——它只看名字,不看语义。我见过一个项目,前端用document.cookie存了一个sessionTheme用来记主题色,名字里带 session,结果年年被扫出来。反过来,有些系统把会话标识塞进一个叫_uid或者t的短名字里,扫描器反而漏了。所以拿到这条告警的第一件事,不是急着改代码,而是先确认:被点名的这个 cookie,到底是不是承载登录态的那个。
我通常的做法是把登录前后的完整请求响应抓下来对比。登录请求返回的Set-Cookie里,哪个值在登录后变了、在后续所有请求里都带、且服务端拿它去查会话存储,那才是真正的会话 cookie。剩下的营销追踪、灰度标记、地区偏好,全都不在这个讨论范围内。这一步花十分钟,能省掉后面大量无效沟通。
1.2 HttpOnly能挡什么、不能挡什么
先把边界划清楚,这决定了你后面怎么跟人解释风险。HttpOnly是 2002 年前后由浏览器厂商引入、后来写进 RFC 6265 的一个 cookie 属性。它的作用只有一个:禁止非 HTTP 的接口访问这个 cookie。翻译成人话就是,document.cookie读不到它,前端脚本拿不到它的值,但浏览器发请求时照样会带上它。
它挡不住的东西比它能挡的多得多:
- 挡不住 CSRF。跨站请求伪造靠的是浏览器自动携带 cookie,而 HttpOnly 完全不干涉 cookie 的发送,所以加了它一点用都没有,得靠
SameSite和 CSRF Token。 - 挡不住 XSS 发起操作。攻击者注入的脚本虽然读不到 cookie 的值,但它可以在受害者浏览器里以受害者身份发起任意同源请求,该删的数据照样删。所以 HttpOnly 降低的是“会话被完整带走”的风险,不是“XSS 被利用”的风险。
- 挡不住会话固定。攻击者事先拿到一个会话 ID,想办法让受害者用这个 ID 登录,之后攻击者直接用自己的那份 ID 就进了。这个场景里 cookie 从头到尾没被脚本读过,HttpOnly 无从发力。
- 挡不住服务端泄露。日志里打印了完整 cookie、错误上报把请求头带上去了、会话存储被人拖走,这些都跟浏览器属性没关系。
那它到底挡什么?挡的是最经典也最常见的那条链:XSS 注入 →document.cookie读取 → 把字符串外带到攻击者服务器 → 攻击者直接拿着这串值访问业务系统,完全绕过密码、验证码、二次验证。加了 HttpOnly 之后,这条链在“读取”这一步断了,攻击者只能退而求其次,在受害者浏览器里做操作,成本高、时效短、还容易被风控捕捉。所以它是一道降低单次攻击收益的护栏,不是万能锁。
提示:别把 HttpOnly 当成 XSS 的解决方案。真正的修法是输出编码、CSP、富文本过滤这一整套。HttpOnly 只是假设“万一还是被注入了”,把损失控制在一个可接受的量级。
1.3 为什么它经常被标成低危却又经常被要求整改
这是很多人困惑的点。扫描器给低危,是因为它单独不可利用——你没法只凭“cookie 没有 HttpOnly”这一个事实攻进系统,必须配合一个 XSS 或者一个中间人场景。但合规检查和甲方验收经常把它列成必改项,原因是它属于成本极低、收益确定的加固措施。改一行配置就能把一个完整的攻击链掐断,性价比太高了,没有理由不改。
我的判断标准比较实用:承载登录态的会话 cookie,必须加 HttpOnly,没有例外。其余 cookie 按需处理——前端需要读的(比如 CSRF Token、埋点用的匿名 ID、A/B 实验分组)不加;前端完全不需要读的一律加上。这个标准写进团队规范,后面就不用每次都开会讨论了。
还有一层现实原因:很多事故复盘里,攻击者拿到的第一手证据就是一段明文的会话 ID。如果这个 ID 当初加了 HttpOnly,攻击者就得改用更麻烦的手法,往往就放弃了。安全防护的价值很多时候不体现在“挡住了多少次”,而体现在“让攻击者觉得不划算”。
2. 原理拆解:浏览器、Cookie与脚本的三方关系
2.1 Set-Cookie头里那一串属性各自管什么
很多人改 cookie 属性是靠复制粘贴,不理解每个字段的含义,改错了也不知道。这里把常用的几个属性捋一遍,都是实际配置时会碰到的:
| 属性 | 作用 | 常见误用 |
|---|---|---|
Expires/Max-Age | 控制持久化时长,不设则为会话 cookie | 设了超长有效期,等于长期有效凭证外泄 |
Domain | 指定生效的域名范围 | 设成主域导致所有子域共享,一个子域被攻破全线沦陷 |
Path | 限定生效路径 | 设成/导致同域下所有服务都能收到 |
Secure | 只在加密通道上发送 | 测试环境是明文,加了直接导致 cookie 被丢弃,登录不上 |
HttpOnly | 禁止脚本读取 | 前端有脚本读它做判断,加了静默失效 |
SameSite | 控制跨站请求是否携带 | 设成None却没配Secure,浏览器直接拒收 |
这里面最容易出问题的是Domain和Path。Domain一旦设成主域,所有子域名的服务都会收到这个 cookie,内网里一个不太安全的子服务被攻破,主站的会话就跟着遭殃。Path设得太宽,同域下不同端口的服务会互相串味——cookie 的作用域跟端口无关,同一台机器上跑三个服务共用域名,cookie 名字撞了就是一场灾难。这两个属性跟 HttpOnly 一起配合,才构成一个完整的会话隔离方案。
2.2 document.cookie的读写规则与拦截点
document.cookie这个 API 的设计很反直觉:它是个字符串,但赋值不是替换,而是“追加一条”。你写document.cookie = "a=1",再写document.cookie = "b=2",读出来是"a=1; b=2"。要删一条得把过期时间设到过去。这个奇怪的设计导致了大量前端 bug。
HttpOnly 的拦截点就在这个 API 上:带该属性的 cookie 不会出现在document.cookie的读取结果里,同时脚本也不该能用它覆盖。规范上不允许非 HTTP 接口修改 HttpOnly cookie,但历史上各家浏览器的实现并不完全一致,有的会忽略写入,有的会产生一条同名的新 cookie,表现相当混乱。所以我从来不让团队依赖“加了 HttpOnly 脚本就绝对覆盖不了”这个假设去做安全设计,它只是一道读取屏障。
这里有个特别隐蔽的坑:如果原来的前端代码里有document.cookie = "sid=" + newValue这种刷新会话的写法(早期单点登录方案里很常见),加上 HttpOnly 之后这行代码不报错、不抛异常,只是默默失效了。浏览器控制台干干净净,但业务逻辑已经走偏。这类问题比抛异常难查十倍,因为它连日志都不给你留。所以改造前必须先全局搜一遍把 cookie 名字当字符串用的地方。
2.3 会话固定、CSRF、XSS三者与HttpOnly的交叉关系
这三个概念经常被混着讲,但它们的防护手段几乎不重叠,理清楚了才能对症下药。
XSS 是注入问题,攻击者把脚本塞进你的页面。它跟 HttpOnly 的关系是:HttpOnly 限制了 XSS 的“变现方式”,从“偷走会话”降级为“在受害者浏览器里操作”。防护主力是输出编码和内容安全策略。
CSRF 是请求伪造问题,攻击者诱导受害者浏览器向你的站点发请求。它跟 HttpOnly 完全无关——cookie 该带还是会带。防护主力是 CSRF Token 和SameSite。
会话固定是会话管理问题,攻击者预先准备一个会话 ID 并让受害者使用。它跟 HttpOnly 也无关。防护主力是登录成功后重新生成会话 ID。
我在评审里见过最常见的错误是:出了会话相关的问题,就统一回复“加上 HttpOnly 就好了”。实际上如果问题是会话固定,改完 HttpOnly 漏洞依然存在,只是扫描器不报了。这种“扫描器绿了但风险还在”的状态是最危险的,因为它给了团队虚假的安全感。
3. 各技术栈的落地改法
3.1 Java系:Servlet容器与Spring生态的配置入口
Java 这边的情况比较碎,因为会话 cookie 可能由容器下发,也可能由框架接管,还可能被网关重写。
如果用的是原生 Servlet 容器,配置在web.xml里:
<session-config> <cookie-config> <http-only>true</http-only> <secure>true</secure> </cookie-config> <tracking-mode>COOKIE</tracking-mode> </session-config>tracking-mode这一项建议显式写上COOKIE,把 URL 重写这条路关掉——有些老容器在 cookie 不可用时会退化成把jsessionid拼在 URL 后面,这个行为会直接把会话 ID 暴露在日志、Referer 和浏览器历史里,比缺少 HttpOnly 严重得多。
Spring Boot 的话,配置文件里一行就够:
server.servlet.session.cookie.http-only=true server.servlet.session.cookie.secure=true server.servlet.session.cookie.same-site=lax注意这些配置项在不同大版本里名字有变化,same-site是 2.6 之后才有的。用 Spring Session 把会话存到外部存储的项目,光改上面这几行不够,因为 cookie 是DefaultCookieSerializer下发的,得单独注入一个 Bean 去设置:
@Bean public DefaultCookieSerializer cookieSerializer() { DefaultCookieSerializer serializer = new DefaultCookieSerializer(); serializer.setUseHttpOnlyCookie(true); // 具体方法名随版本有差异,以所用版本文档为准 serializer.setUseSecureCookie(true); serializer.setSameSite("Lax"); serializer.setCookiePath("/"); return serializer; }这里的坑是:容器配置和 Spring Session 的配置是两套独立逻辑,只改一套另一半照样生效。特别是 Spring Session Redis 这种方案,容器级配置完全不起作用,只认DefaultCookieSerializer。
3.2 Node.js:Express与Koa的中间件配置
Express 里用express-session的话,默认其实已经打开了 HttpOnly,但显式写出来更稳妥,也方便代码评审时看到:
app.use(session({ name: 'sid', secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: true, // 本地开发是 http 要临时关掉 sameSite: 'lax', maxAge: 1000 * 60 * 30, path: '/' } }));手工res.cookie()下发的地方最容易漏,尤其是登录接口和刷新 token 接口,经常是一处改了另一处忘了:
res.cookie('sid', sessionId, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 30 * 60 * 1000 });Koa 的ctx.cookies.set参数结构类似,但它有个额外参数overwrite,默认值是false。这意味着如果你在同一次请求里对同名 cookie 调两次 set,第二次不生效。我见过一个项目在中间件里统一加了属性,业务代码里又自己 set 了一次,结果业务那次覆盖了中间的配置,属性全丢了。排查的时候对着日志看了半天才发现。
3.3 PHP、Python、Go的服务端配置差异
PHP 的坑在于时机。session.cookie_httponly必须在session_start()之前设置,之后再改完全无效:
ini_set('session.cookie_httponly', '1'); ini_set('session.cookie_secure', '1'); ini_set('session.cookie_samesite', 'Lax'); session_start();更稳的做法是写进php.ini,避免某个入口文件忘了设置。用setcookie()手工下发的,记得用数组形式传选项,老的位置参数形式不支持 HttpOnly 之外的一些新属性。
Python 这边 Flask 和 Django 的默认值都比较友好,Flask 的SESSION_COOKIE_HTTPONLY默认是 True,Django 的SESSION_COOKIE_HTTPONLY也是 True。所以被扫出来往往说明有人显式关掉了,或者用的是别的下发方式。这里有个值得注意的细节:Django 的CSRF_COOKIE_HTTPONLY默认是 False,这是有意为之的——前端的 AJAX 请求需要从 cookie 里读出 CSRF Token 放到请求头里。这个 cookie 不加 HttpOnly 是正确的,不要为了“扫描器全绿”把它也加上,加了之后所有 AJAX 提交都会 403。
# settings.py SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SECURE = True SESSION_COOKIE_SAMESITE = 'Lax' CSRF_COOKIE_HTTPONLY = False # 保持默认,前端需要读Go 的标准库写法:
http.SetCookie(w, &http.Cookie{ Name: "sid", Value: sessionID, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 1800, })Go 这边有个容易忽略的点:SameSite的零值SameSiteDefaultMode在部分版本里会被序列化成不输出任何内容,看起来像没设置。要么显式指定Lax,要么在网关层统一兜底。
3.4 网关层统一兜底的做法与代价
应用有几十个服务的时候,一个个改代码不现实,很多团队选择在网关或者反向代理层统一加属性。Nginx 从 1.19.3 开始提供了proxy_cookie_flags,比老的proxy_cookie_path干净得多:
# 对匹配到的 cookie 追加 httponly/secure,同时设置 samesite proxy_cookie_flags ~* httponly secure samesite=lax; # 只对特定名字的 cookie 处理 proxy_cookie_flags ~*^(sid|jsessionid|session)$ httponly secure samesite=lax;老方法proxy_cookie_path是字符串拼接,容易把属性加重复:
proxy_cookie_path / "/; HttpOnly; Secure; SameSite=Lax";这个写法的隐患是它会把路径部分替换掉,如果原来的Path不是/,会被强行改掉。而且如果后端已经加了 HttpOnly,这里会再加一遍变成HttpOnly; HttpOnly,虽然多数浏览器容忍,但严格解析的客户端可能会出问题。
Apache 这边可以用Header edit:
Header always edit Set-Cookie ^(.*)$ "$1; HttpOnly; Secure"这个正则同样会重复追加,建议配合Header always edit Set-Cookie "^(.*?)(;? *HttpOnly.*)$" "$1$2"这类写法先做去重,或者干脆用Header always edit的条件匹配版本。
网关层兜底的最大问题是它只能加不能减。如果某个 cookie 明确不能被加上 HttpOnly(比如前端要读的 CSRF Token),你就得在规则里做精确的名字匹配排除,规则会越来越长,最后变成没人敢动的黑盒。我的建议是:网关做“兜底 + 告警”,发现漏配的就报警,推动应用侧改,而不是长期依赖网关改写。
4. 改造后的验证方法与回归测试
4.1 用开发者工具和命令行快速确认
改完之后最直接的验证方式是看响应头。命令行一行就够:
curl -i -s -X POST https://your-service/login \ -d 'user=test&pass=test' | grep -i 'set-cookie'正确的输出应该是类似:
Set-Cookie: sid=abc123; Path=/; Max-Age=1800; HttpOnly; Secure; SameSite=Lax浏览器里更直观:打开开发者工具的 Application 面板,左侧 Cookies 里找到对应域名,看那一列 HttpOnly 是不是打了勾。然后在 Console 里敲document.cookie,确认返回的字符串里不包含这个会话 cookie 的值。这两步都通过,才算真的生效。
我习惯再加一步“反向验证”:在 Console 里尝试document.cookie = "sid=xxx",然后看请求里的实际值有没有变。如果变了,说明服务端没有正确设置或者存在多次下发覆盖,得回去查。这一步能抓出不少“看起来加了实际上被覆盖了”的情况。
4.2 写个小脚本做批量巡检
服务多了之后,人工点太慢,我一般会写个几十行的脚本跑一遍所有核心域名。思路是发一个登录请求,收集所有Set-Cookie,然后对每个 cookie 检查是否包含httponly、secure、samesite三个关键字,输出一份差异报告。
import requests, re def audit(url, payload): s = requests.Session() r = s.post(url, data=payload, timeout=10, verify=True) issues = [] for raw in r.raw.headers.getlist('Set-Cookie'): name = raw.split('=', 1)[0].strip() low = raw.lower() if 'httponly' not in low: issues.append(f'{name}: 缺少 HttpOnly') if 'secure' not in low: issues.append(f'{name}: 缺少 Secure') if 'samesite' not in low: issues.append(f'{name}: 缺少 SameSite') return issues这个脚本的价值在于它能跑在 CI 里。把它挂到发布流水线的冒烟测试阶段,新版本一上线就自动检查,谁改坏了一目了然。比起每季度做一次渗透测试再回头补,成本低太多了。脚本里要注意脱敏,别把真实的 cookie 值打进日志或者构建产物里。
4.3 灰度上线与兼容性风险
HttpOnly 本身是“只减不增”的改动——它拿掉了脚本读取的能力,所以风险几乎全在依赖脚本读取的地方。上线前一定要盘清楚这几类:
- 前端读 cookie 做登录态判断。很多老项目靠
document.cookie里有没有某个值来决定跳不跳登录页。加了之后读不到,页面逻辑走到“未登录”分支,用户被反复踢回登录页。这种故障表现是“登录成功但立刻又回登录页”,非常容易被投诉但不容易定位。 - 混合 App 的 WebView 桥接。原生端通过注入的 JS 读 cookie 传给壳层,或者壳层通过
CookieManager读出来做请求签名。这部分代码往往不在前端仓库里,评审时最容易被漏掉。 - 第三方脚本。埋点、客服、AB 测试的脚本有时会读 cookie 做用户标识。加了之后它们的统计口径会变,虽然不影响核心功能,但数据团队会来找你。
- 单点登录的回跳流程。某些 SSO 方案依赖前端读取 cookie 拼参数,改造后流程断裂。
我一般的灰度策略是先改测试和预发,用真实业务链路跑一遍核心流程(登录、支付、下单、退出、切账号),再按 5% 流量灰度一天,重点看登录成功率和“异常退出登录”的监控指标。这类指标在灰度期间会有明显抖动,是最早的预警信号。
注意:
Secure属性和 HttpOnly 经常一起改,但Secure的风险大得多——它会导致明文通道下的 cookie 直接被浏览器丢弃。测试环境如果是 http,改完就是登录不上,而且报错信息非常隐晦。建议这两个属性分两次发布,先 HttpOnly,观察一天,再上 Secure。
5. 常见问题与排查技巧实录
5.1 明明改了却没生效的六种原因
这是我被问得最多的一类问题。大部分情况不是配置写错了,而是有别的环节在覆盖。按出现频率排一下:
| 现象 | 大概率原因 | 排查动作 |
|---|---|---|
| 响应头里还是没有 HttpOnly | 应用有多处下发同名 cookie,改的是不生效的那处 | 在响应头里数一下同名Set-Cookie的条数 |
| 改了代码线上没变化 | 发布的包是旧的,或者容器里有多个实例只重启了一部分 | 看实例列表和镜像 tag,逐个确认 |
| 响应头里有但浏览器里看不到属性 | 中间代理重写了Set-Cookie,属性被覆盖 | 跳过代理直连后端对比响应头 |
| 预发正常线上异常 | 网关/CDN 的规则只配了一套环境 | 对比两个环境的代理配置 |
| 属性时有时无 | 不同接口走的代码路径不一样 | 抓多个接口的响应做横向对比 |
| 加了之后 cookie 直接消失 | 同时加了Secure而当前是明文通道 | 检查协议,去掉 Secure 复测 |
这里面最阴的是第一条。我遇到过一个系统,登录接口返回的 cookie 是加了 HttpOnly 的,但每次请求经过一个鉴权过滤器,过滤器发现会话快过期就会“续期”,用new Cookie()重新下发一遍,那个新 cookie 只设了 name、value、path,属性全丢。结果是第一次登录正常,几秒后的第二个请求就把 cookie 换成了没有 HttpOnly 的版本。这种情况单看登录接口永远查不出来,必须抓完整会话的多个请求响应做对比。
5.2 加完之后业务挂了的排查路径
顺序很重要,按这个走基本不会跑偏:
第一步,确认是读不到还是登录态失效。在 Console 里看document.cookie有没有那个名字,有就是属性没生效,没有就是生效了但业务依赖它。
第二步,全局搜代码。搜 cookie 的名字字符串,搜document.cookie,搜js-cookie、Cookies.get、$.cookie这类封装。注意要搜编译后的产物和 CDN 上的老版本文件,有些祖传 JS 只在生产环境才加载。
第三步,看混合端。如果是 App 内嵌页面,让客户端同学确认有没有从 WebView 读 cookie 的逻辑。
第四步,临时回滚验证。把 HttpOnly 去掉重新发一版,如果业务立刻恢复,说明判断没错,接着按第二步精确定位是哪段代码。
第五步,改造而不是回退。找到依赖点之后,把“读 cookie”改成“服务端渲染时注入到页面里的初始状态”或者“单独的接口返回”。前者最省事,后端在返回 HTML 的时候把登录态作为一段 JSON 塞进去,前端从全局变量取,完全不需要碰 cookie。
5.3 几个容易被忽略的细节
同名不同 Path 的 cookie 会共存。你在/下有一条,在/app下又有一条同名的,浏览器两个都会发,服务端读哪个取决于解析顺序。改造的时候如果只改了一条,表现就是“有时候生效有时候不生效”,取决于请求路径。排查时一定要在 DevTools 的 Cookies 面板里按 Path 分组看全。
cookie 不区分端口。同一域名的 8080 和 9090 两个服务共享 cookie 作用域。内网测试环境经常这样,一个服务的改动影响到另一个服务,排查半天发现是两个应用撞了 cookie 名。解决办法是给每个应用用不同的 cookie 名字,别都叫sid。
大小写敏感度。HttpOnly这个属性名在规范里是大小写不敏感的,主流浏览器都认httponly、HTTPONLY。但一些自研的网关插件、老版本的代理工具在解析时是严格匹配的,会导致规则不生效。排查时如果怀疑这块,统一写成规范的大小写形式。
会话 ID 的随机性比属性更重要。改属性的同时顺手看一眼会话 ID 的生成方式。如果是自研的、用了时间戳加随机数的拼法,长度不够或者熵值太低,那 HttpOnly 加得再全也没用——攻击者根本不需要读你的 cookie,直接猜就行了。这个检查项经常被漏掉,但它的优先级比 HttpOnly 更高。
6. 从单点修复到体系化:Cookie安全基线怎么定
6.1 属性组合策略而非单点加固
HttpOnly 单独用价值有限,真正有效的是几个属性组合起来的策略。我一般给团队定这样一套基线:
会话 cookie:HttpOnly+Secure+SameSite=Lax+Path=/,不设Domain(让它默认绑定当前主机名),Max-Age控制在 30 分钟到 2 小时之间,配合服务端滑动续期。不设Domain这一点很多人不注意,设了主域就等于把所有子域都拉进信任范围,一个边缘子服务出问题全站遭殃。
需要跨站携带的 cookie(比如嵌入第三方页面的场景):SameSite=None+Secure。这两个必须成对出现,只写None不写Secure,浏览器会直接拒绝这个 cookie,表现就是“登录了但状态没保存”。
前端需要读的 cookie(CSRF Token、实验分组):不加 HttpOnly,但Secure和SameSite照加,并且严格控制Path,别让它出现在不必要的路径上。
敏感度极高的会话(比如管理后台):可以额外用__Host-前缀。这个前缀有一串硬性要求——必须带Secure、必须Path=/、不能设Domain。满足条件后浏览器会拒绝任何不合规的同名 cookie 覆盖,能有效防止子域写入伪造 cookie 的攻击。代价是老代码里如果有设置Domain的地方会全部失效,改造前要评估。
6.2 把检查固化进代码评审和流水线
人工检查靠不住,一定会漏。我推的做法是三层拦截:
第一层,框架默认值统一。项目脚手架里把安全配置写成默认开启,新项目创建出来就是对的。这一层能覆盖 80% 的新增代码。
第二层,代码评审清单。评审时看到Set-Cookie、res.cookie、setcookie、http.SetCookie这类调用,就必须确认属性齐全。清单上写死这一条,比口头强调有效得多。
第三层,流水线自动检查。把前面那个巡检脚本挂到发布流程里,对核心接口做一次真实的登录请求,属性缺失就卡住发布。这一层能抓住配置被意外改掉、被其他中间件覆盖这类问题。实现成本不高,但效果立竿见影——上线前拦住总比上线后被通报强。
顺带提一句,扫描器的告警要做分类处理而不是一刀切忽略。会话 cookie 缺 HttpOnly 归为必改,非会话 cookie 缺属性归为“记录并评估”,同名重复下发归为“需要人工确认”。分类之后告警量会降一个数量级,团队才不会对报告脱敏。
6.3 与前端存储方案的取舍
现在很多项目不用 cookie 存会话,改用localStorage或sessionStorage存 token,走Authorization头。这个方案跟 HttpOnly 是互斥的——存储 API 本来就只对脚本开放,没有 HttpOnly 这个概念。所以选型的时候要清楚代价:localStorage 里的 token 一旦页面被注入,攻击者一行代码就能读走,而且没有任何浏览器级别的防护能拦住。
我的看法比较务实:内部系统、低敏感业务,用 localStorage 图方便没什么问题;涉及资金、身份、隐私的核心业务,还是走 HttpOnly 的 cookie 更稳妥。如果因为跨域、多端统一这些原因必须用 token 方案,那就要在别的维度补——短有效期、刷新令牌轮换、绑定设备指纹、异常登录检测,用工程手段把风险摊薄。
还有一种折中方案我见过效果不错的:会话标识放 HttpOnly cookie,同时下发一个短期的、权限受限的“操作令牌”给前端放在内存里(不落 localStorage),这个令牌只能调几个特定接口,且有效期只有几分钟。这样即使 XSS 读走了操作令牌,能做的事情也很有限,而核心会话始终在 HttpOnly 的保护下。
最后分享我在实际改造中的一点体会。这类“低危”项最容易陷入的困境是:改了没人感知,不改也没出事,于是永远排在需求列表的最后。真正推动它落地的往往不是安全团队发多少通报,而是把它变成默认行为——脚手架里默认开启、评审清单里默认检查、流水线里默认拦截。人是有惰性的,靠自觉不如靠机制,把正确的事情变成最省事的那条路径,问题自然就消失了。