TT Lab
はじめる
学ぶ 学習パス コース

SSR — サーバが先に描く

文字列を安全にする

TT Labで続きを見る

目標

サーバーレンダリングを文字列を組み立てる作業として自分で行いながら、そこから生じる3つの問題、XSS・ハイドレーションの不一致・キャッシュ漏えいを防ぎます。

作るもの

/root/work/ssr/render.mjs1つです。フレームワークもnpmも使いません。

export 契約
renderShell() データがなくても有効なドキュメントシェル
escapeHtml(s) 5文字の置換(&を先に)
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を呼び出すたびに違う値を返すように差し替えてから2回レンダリングして比較します。レンダリングの中でそれらを呼び出していると、すぐに発覚します。

シェルから始める

/root/work/ssr/render.mjsでrenderShell()をexportしてください。<!doctype html>・<html lang>・<head>・<body>を備えた、データがなくても有効なドキュメントを文字列で返します。

mkdir -p /root/work/ssr。ESMなのでexport function renderShell() { ... }です。このシェルがストリーミングで最初に出ていく断片になります。ブラウザーはこれを受け取るとすぐにCSSを取りに行きます。

最初のHTMLに内容を入れておく

render(state)をexportしてください。state.titleとstate.items(文字列の配列)が、HTML文字列の中に実際に入っている必要があります。

CSRの最初のレスポンスは<div id="root"></div>だけです。クローラーやリンクプレビューにとって、それは空のページです。各項目を<li>にして入れてください。

ユーザーデータをエスケープする

escapeHtml(s)をexportして、renderの中で使ってください。5文字(& < > " ')を置換する必要があり、置換するときは、&を最初にしなければなりません。

採点ツールが<img src=x onerror=alert(1)>のような値を入れて試します。&を後で置換すると、すでに作った&lt;が&amp;lt;に壊れます。順序が規則の一部です。

初期状態を安全に埋め込む

renderState(state)をexportして、<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を呼び出すたびに違う値を返すように差し替えてから2回レンダリングして比較します。時刻やidが必要なら、サーバーで作ってstateに入れて渡してください。それがハイドレーションの不一致をなくす唯一の方法です。

シェルを先に流す

renderStream(state)をasync generatorとしてexportしてください。最初の断片はシェルで、データが入った断片はその後に来ます。断片は2つ以上でなければなりません。

export async function* renderStream(state) { yield renderShell부분; ...; yield 본문; }のような形です(プレースホルダーはシェルの断片と本文の断片です)。最初の断片に<headがあり、state.titleは含まれていてはいけません。シェルがデータを待たないことが要点です。最後の断片は</html>で終わる必要があります。

パーソナライズされたページをキャッシュしない

cacheControl(state)をexportしてください。state.userがあればprivate, no-storeを、なければpublicとs-maxageを含む値を返します。

publicが付いたパーソナライズ済みレスポンスは、CDNがAのページをBに渡す事故につながります。デフォルトをprivate, no-storeにして、公開してよいものだけを開く方が安全です。

3つを整理する

08-notes.mdに3行以上で、エスケープで&を先に置換しなければならない理由、ハイドレーションの不一致の原因2つ、そしてパーソナライズされたページにpublicを付けると起きることを書いてください。

本文に이스케이프、하이드레이션、캐시が入っている必要があります(それぞれ韓国語で「エスケープ」「ハイドレーション」「キャッシュ」を意味する語です)。3つ目が最も高くつく事故です。見つかったときには、すでに他人の情報が出たあとです。