LabHub
배우기 러닝패스 코스

SSR — サーバが先に描く

文字列を安全にする

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

서버 렌더링을 문자열을 만드는 일로 직접 해 보면서, 거기서 나오는 세 가지 — 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']})))"

단계

  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.nowMath.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.titlestate.items(문자열 배열)가 HTML 문자열 안에 실제로 들어 있어야 합니다.

CSR 의 첫 응답은 <div id="root"></div> 뿐입니다 — 크롤러와 링크 미리보기에는 그게 빈 페이지입니다. 각 항목을 <li> 로 만들어 넣으세요.

사용자 데이터를 이스케이프한다

escapeHtml(s) 를 export 하고 render 안에서 쓰세요. 다섯 글자(& < > " ')를 바꿔야 하고, & 를 가장 먼저 바꿔야 합니다.

채점기가 <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'). < 를 유니코드 이스케이프로 바꾸면 JSON 값은 그대로이면서 태그가 안 끊깁니다. type="application/json" 이면 브라우저가 실행하지 않습니다 — 그래도 이스케이프는 여전히 필요합니다.

렌더 함수를 순수하게

render같은 입력이면 항상 같은 출력을 내게 하세요. 안에서 Date.now()·Math.random()·new Date()·toLocaleString() 을 부르면 안 됩니다.

채점기가 Date.nowMath.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 를, 없으면 publics-maxage 가 든 값을 돌려줍니다.

public 이 붙은 개인화 응답은 CDN 이 A 의 페이지를 B 에게 주는 사고로 이어집니다. 기본값을 private, no-store 로 두고 공개해도 되는 것만 여는 편이 안전합니다.

셋을 정리한다

08-notes.md 에 세 줄 이상. 이스케이프에서 & 를 먼저 바꿔야 하는 이유, 하이드레이션 불일치의 원인 두 가지, 그리고 개인화 페이지에 public 을 붙이면 생기는 일.

본문에 이스케이프, 하이드레이션, 캐시 가 들어가야 합니다. 세 번째가 가장 비싼 사고입니다 — 발견될 때는 이미 남의 정보가 나간 뒤입니다.