文字列を安全にする
目標
サーバーレンダリングを文字列を組み立てる作業として自分で行いながら、そこから生じる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']})))"
ステップ
renderShell()render(state): 内容をHTMLの中に入れるescapeHtml:&を先にrenderState:</script>による脱出を防ぐ- 純粋性:
Date.now/Math.randomの禁止 renderStream: シェルを先にcacheControl- 整理 →
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;に壊れます。順序が規則の一部です。
初期状態を安全に埋め込む
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つ目が最も高くつく事故です。見つかったときには、すでに他人の情報が出たあとです。