Making Strings Safe
한국어 원문으로 표시합니다.
목표
서버 렌더링을 문자열을 만드는 일로 직접 해 보면서, 거기서 나오는 세 가지 — XSS · 하이드레이션 불일치 · 캐시 유출 — 를 막습니다.
만들 것
/root/work/ssr/render.mjs 하나입니다. 프레임워크도 npm 도 쓰지 않습니다.
| export | 계약 |
|---|---|
renderShell() |
데이터 없이도 유효한 문서 셸 |
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']})))"
단계
renderShell()render(state)— 내용이 HTML 안에escapeHtml—&를 먼저renderState—</script>탈출 막기- 순수성 —
Date.now/Math.random금지 renderStream— 셸 먼저cacheControl- 정리 →
08-notes.md
참고
5단계 채점기는 Date.now 와 Math.random 을 호출할 때마다 다른 값을
주도록 바꿔치기한 뒤 두 번 렌더해 비교합니다. 렌더 안에서 그것들을
부르고 있으면 바로 드러납니다.
셸부터
/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 안에서 쓰세요. 다섯 글자(& < > " ')를 바꿔야 하고, & 를 가장 먼저 바꿔야 합니다.
채점기가 <img src=x onerror=alert(1)> 같은 값을 넣어 봅니다. & 를 나중에 바꾸면 이미 만든 < 가 &lt; 로 망가집니다 — 순서가 규칙의 일부입니다.
초기 상태를 안전하게 심는다
renderState(state) 를 export 해 <script type="application/json" id="state"> 태그를 만드세요. 데이터에 </script> 가 들어 있어도 태그가 끊기면 안 됩니다.
JSON.stringify(state).replace(/</g, '\u003C'). < 를 유니코드 이스케이프로 바꾸면 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> 로 끝나야 합니다.
개인화된 페이지를 캐시하지 않는다
cacheControl(state) 를 export 하세요. state.user 가 있으면 private, no-store 를, 없으면 public 과 s-maxage 가 든 값을 돌려줍니다.
public 이 붙은 개인화 응답은 CDN 이 A 의 페이지를 B 에게 주는 사고로 이어집니다. 기본값을 private, no-store 로 두고 공개해도 되는 것만 여는 편이 안전합니다.
셋을 정리한다
08-notes.md 에 세 줄 이상. 이스케이프에서 & 를 먼저 바꿔야 하는 이유, 하이드레이션 불일치의 원인 두 가지, 그리고 개인화 페이지에 public 을 붙이면 생기는 일.
본문에 이스케이프, 하이드레이션, 캐시 가 들어가야 합니다. 세 번째가 가장 비싼 사고입니다 — 발견될 때는 이미 남의 정보가 나간 뒤입니다.