06|安全边界
第八章 / 06。目标:把“服务端能访问”与“可以发送给浏览器”分开审查。
四条不能混淆的边界
- 模板边界:模板会编译成 JavaScript,绝不能把不可信字符串当 Vue template。
- HTML 边界:插值默认转义文本;
v-html明确绕过此保护,只能接收经过可信策略净化的内容。 - 请求边界:服务端根据会话鉴权,每个请求独立状态;客户端隐藏按钮不是授权。
- 序列化边界:SSR payload 对用户可见,只发送页面所需的最小公开投影。
type UserRecord = {
id: string
displayName: string
email: string
passwordHash: string
sessionToken: string
}
function toPublicUser(user: UserRecord) {
return { id: user.id, displayName: user.displayName }
}允许列表优于“复制全部再删几个字段”:新增敏感字段时不会自动进入 payload。Nuxt 自带序列化流程应优先使用;手工把 JSON.stringify(data) 拼进 <script> 还要处理 < 等能结束脚本上下文的字符,极易出错。
JSON 正确,不代表放进 HTML 仍安全
假设服务端生成下面的 HTML:
const state = { displayName: '</script><img src=x onerror=alert(1)>' }
const html = `<script>window.__STATE__=${JSON.stringify(state)}</script>`JSON.stringify 得到的字符串在 JSON 语法里完全合法,但浏览器先按 HTML 解析整份响应。HTML 解析器看到第一个字面量 </script> 就结束脚本元素;后面的 <img> 已经回到 HTML 上下文,会成为真实节点。这里的错误不是“JSON 解析失败”,而是把只满足 JSON 规则的数据直接放进另一种语法上下文。
在这个受限实验里,可先把 JSON 源码中的 < 改写为 Unicode 转义:
function escapeInlineJSON(value: unknown) {
return JSON.stringify(value).replaceAll('<', '\\u003c')
}
const source = escapeInlineJSON(state)
console.assert(!source.includes('</script>'))
console.assert(JSON.parse(source).displayName === state.displayName)响应源码不再含能结束脚本元素的 <,而 JSON.parse 仍会恢复原字符串。这个函数只解释一条上下文规则,不是 Nuxt 序列化器的替代品:框架还要处理支持的数据类型、hydration 恢复协议、CSP 和版本兼容。生产代码应使用 Nuxt 的 payload 通道,并验证最终 HTML,而不是复制这段简化函数。
Cookie、XSS 与 CSRF
- 会话 Cookie 通常设置
HttpOnly,让客户端 JS 不能读取;Secure限制 HTTPS;SameSite是 CSRF 防线之一,但策略要按跨站流程设计。 - XSS 与 CSRF 不是同一种攻击。XSS 能在受信任 origin 执行脚本;CSRF 利用浏览器自动携带凭据发请求。
- 有副作用的服务端操作应校验方法、来源/CSRF token、会话与权限;GET 不应改变业务状态。
- CSP 可降低部分注入后果,但不能让不可信模板或
v-html变安全。
用一次转账请求跟踪 CSRF 的过程。用户已经登录 bank.example,浏览器保存会话 Cookie;随后用户访问攻击者页面,页面提交表单到 https://bank.example/transfers:
攻击者页面发起跨站 POST
↓
浏览器按 Cookie 属性决定是否自动附带 bank.example 的 Cookie
↓
服务端只看到“有效会话”,若未验证请求来源与 CSRF 证明,就执行转账HttpOnly 只阻止页面 JavaScript 读取 Cookie,不阻止浏览器随请求发送它,所以它不能单独防 CSRF。修复发生在服务端决策处:副作用接口只接受正确方法,核对 Origin/CSRF token,再按当前会话校验这名用户是否有权执行具体操作;SameSite 用来减少跨站自动携带凭据的机会。每一层解决不同条件,不能互相代替。
如果攻击者已经通过 XSS 在 bank.example 的受信任 origin 执行脚本,脚本可能直接读取页面数据并发同源请求;CSRF token 往往也能被它取得。此时应回到注入源,使用插值的文本转义、禁止不可信 template、约束 v-html,并以 CSP 降低剩余风险。由此可以推导:隐藏按钮没有建立授权边界,Cookie 属性也没有净化页面内容。
服务端错误对象也可能包含 SQL、路径、上游 token 或内部 stack。返回客户端的是稳定错误码和安全文案,详细上下文只进入受控日志,并用 request id 关联。
跨请求验证
安全测试要并发交错两个用户请求,检查响应 payload 互不包含对方 id;还要扫描 HTML/__NUXT_DATA__ 是否出现 passwordHash、sessionToken 等禁止键。只测试单用户成功页面无法证明隔离。测试序列化时同时验证两件事:源码中没有字面量 </script>,解析后的公开数据仍与原输入相等;只检查“危险字符串消失”可能把正常姓名也错误删掉。
练习:序列化公开状态
实现 serializePublicState(user),只接收完整用户记录并返回可嵌入 HTML 的 JSON 字符串,内容只能有 id/displayName。断言结果不含 email/passwordHash/sessionToken,输入 displayName 为 </script><script>alert(1)</script> 时输出不能出现字面量 </script>。说明生产 Nuxt 为什么仍应优先使用框架 payload 序列化而非此练习函数。
参考答案:第 8 章第 06 题。