TT Lab
开始
学习 学习路径 课程

SSR — 服务端先画

把字符串弄安全

在 TT Lab 中继续学习

目标

亲手把服务端渲染当作拼接字符串的工作来做,并堵住由此产生的三个问题:XSS、水合(hydration)不一致、缓存泄露。

要创建的内容

只有一个文件:/root/work/ssr/render.mjs。不使用框架,也不使用 npm。

export 契约
renderShell() 没有数据也合法的文档外壳(shell)
escapeHtml(s) 替换五个字符(先替换 &)
render(state) 包含内容的完整 HTML。纯函数
renderState(state) <script type="application/json"> 状态标签
renderStream(state) async generator,从外壳开始逐段输出
cacheControl(state) 根据是否登录返回对应的 Cache-Control 值

state 的形状如下。

{ title: "제목", items: ["가", "나"], user: { name: "홍길동" } | null }

确认

cd /root/work/ssr
node -e "import('./render.mjs').then(m => console.log(m.render({title:'t',items:['a']})))"

步骤

  1. renderShell()
  2. render(state):内容要在 HTML 里
  3. escapeHtml:先替换 &
  4. renderState:防止 </script> 被提前闭合
  5. 纯度:禁止 Date.now 和 Math.random
  6. renderStream:外壳先行
  7. cacheControl
  8. 总结 → 08-notes.md

参考

第 5 步的评分器会先把 Date.now 和 Math.random 替换成每次调用都返回不同值的版本,再渲染两次并比较结果。如果渲染函数内部调用了它们,立刻就会暴露。

先写外壳

在 /root/work/ssr/render.mjs 中 export renderShell()。它以字符串形式返回一份没有数据也合法的文档,包含 <!doctype html>、<html lang>、<head> 和 <body>。

mkdir -p /root/work/ssr。由于是 ESM,写成 export function renderShell() { ... }。这个外壳就是流式传输中最先发出的片段:浏览器一收到它,就会去下载 CSS。

第一份 HTML 里必须已有内容

export render(state)。state.title 和 state.items(字符串数组)必须真实地出现在 HTML 字符串中。

CSR 的第一次响应只有 <div id="root"></div>,对爬虫和链接预览来说这就是一个空页面。把每一项都生成 <li> 放进去。

对用户数据做转义

export escapeHtml(s),并在 render 中使用它。要替换五个字符(& < > " '),并且必须最先替换 &。

评分器会传入 <img src=x onerror=alert(1)> 这样的值来测试。如果后替换 &,已经生成的 &lt; 会被破坏成 &amp;lt;。顺序本身就是规则的一部分。

安全地植入初始状态

export renderState(state),生成 <script type="application/json" id="state"> 标签。即使数据中含有 </script>,标签也不能被截断。

JSON.stringify(state).replace(/</g, '\u003C')。把 < 换成 Unicode 转义后,JSON 的值保持不变,标签也不会被截断。设为 type="application/json" 时浏览器不会执行其中的内容,但转义依然是必需的。

让渲染函数保持纯净

让 render 做到相同输入始终得到相同输出。函数内部不能调用 Date.now()、Math.random()、new Date() 和 toLocaleString()。

评分器会先把 Date.now 和 Math.random 替换成每次调用都返回不同值的版本,再渲染两次并比较。如果需要时间或 id,就在服务端生成,放进 state 里传下去。这是消除水合不一致的唯一办法。

外壳先流出

把 renderStream(state) 作为 async generator 来 export。第一个片段是外壳,带数据的片段排在它后面。片段必须至少有两个。

export async function* renderStream(state) { yield renderShell부분; ...; yield 본문; }(占位符依次为外壳部分与正文)。第一个片段里要有 <head,并且不能有 state.title:要点在于外壳不等待数据。最后一个片段必须以 </html> 结尾。

不缓存个性化页面

export cacheControl(state)。存在 state.user 时返回 private, no-store,不存在时返回同时含有 public 和 s-maxage 的值。

带有 public 的个性化响应,会引发 CDN 把 A 的页面交给 B 的事故。默认值设为 private, no-store,只开放可以公开的内容,这样更安全。

总结这三点

在 08-notes.md 中写至少三行:转义时为什么必须先替换 &、水合不一致的两个原因,以及给个性化页面加上 public 会发生什么。

正文里必须出现 이스케이프、하이드레이션、캐시(韩文,依次意为“转义”“水合”“缓存”)。第三点是代价最高的事故:被发现时,别人的信息早已泄露出去。