G-4: 프론트엔드 보안 ① — 우리 화면에서 남의 코드가 돈다
목차 40
안녕하세요, 홍순구 튜터입니다.
지난 시간에 코어 과정을 닫으면서 더 가고 싶은 분들을 위해 네 갈래를 말씀드렸는데, 그중 첫 번째가 이거였습니다.
남이 심은 스크립트가 우리 페이지에서 도는 문제, 남의 사이트가 우리 앱에 요청을 대신 보내는 문제, 그리고 브라우저에게 "이 출처의 스크립트만 실행해라"라고 미리 알려두는 방법이 있어요.
오늘부터 세 시간 동안 그 셋을 다 보는데, 오늘은 첫 번째인 남이 심은 스크립트가 우리 페이지에서 도는 문제부터예요.
시작하기 전에 하나 짚고 갈 게 있는데, 여러분은 45일 동안 이 앱을 만들면서 이 문제를 한 번도 안 만났어요. 오늘은 그 이유부터 확인해보는데, 우리가 잘해서였는지 아니면 운이 좋아서였는지요.
오늘의 여정
1. 댓글을 만든다 → 공격 문자열을 넣어본다 → 아무 일도 안 일어난다
2. 서식을 살리고 싶어진다 → 한 줄을 바꾼다 → 탭 제목이 바뀐다
3. 다른 문자열로 다시 재본다 → 아까 «안전» 이 조건부였다는 걸 안다
4. 남의 스크립트가 실제로 무엇을 할 수 있는지 끝까지 가본다
5. 걸러낸다 → 서식은 살리고 나머지는 지운다
💡 오늘 수업의 핵심 — "안전했던 게 아니라, 안전한 방법을 쓰고 있었습니다"
🎯 학습 목표
- 화면에 사용자 입력을 넣는 두 가지 방법이 어떻게 다른지 직접 재보고, 왜 한쪽 이름만 유난히 긴지 이해합니다.
- 공격 문자열 하나만으로 시험하면 왜 거짓 안심을 얻는지, 네 칸짜리 표를 채워 확인합니다.
- 남이 심은 스크립트가 무엇을 훔칠 수 있고 무엇을 훔치지 않고도 할 수 있는지 끝까지 확인한 뒤, 어디서 걸러낼지 정합니다.
Step 1: "우리는 이미 안전했습니다"
인스타그램에 댓글이 없으면 인스타그램이 아니지만, 우리 Next 앱에는 아직 댓글이 없어서 오늘 그것부터 만들고 그 위에서 이야기를 진행할게요.
연습용 서버에는 이미 댓글 API가 준비돼 있는데, 가져오는 함수부터 적습니다.
// apps/web-next/lib/api.ts
/** 게시물에 달린 댓글 한 줄 */
export type Comment = {
id: number;
postId: number;
username: string;
content: string;
createdAt: string;
};
/**
* 댓글은 굳히지 않는다. 방금 단 것이 안 보이면 안 단 것과 같다.
* 그래서 'use cache' 도 이름표도 없다 — 피드와 같은 결이다.
*/
export function fetchComments(postId: number): Promise<Comment[]> {
return get<Comment[]>(`/posts/${postId}/comments`);
}
댓글을 다는 일은 서버 액션이 맡는데, D-7·D-8에서 만든 팔로우·좋아요와 똑같이 동작합니다. 누가 썼는지는 폼이 보낸 값이 아니라 서버가 쿠키에서 직접 읽습니다.
// apps/web-next/app/actions/comment.ts
'use server';
import { currentUser } from '@/lib/session';
import { fetchAsUser } from '@/lib/backend-token';
export type CommentState = {
message: string | null;
};
export async function addComment(
postId: number,
_previous: CommentState,
formData: FormData,
): Promise<CommentState> {
const me = await currentUser();
if (me === null) {
return { message: '로그인이 필요해요' };
}
const content = String(formData.get('content') ?? '').trim();
if (content === '') {
return { message: '댓글을 입력해주세요' };
}
const response = await fetchAsUser(me, `/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (response === null) {
return { message: '백엔드에 신원을 확인받지 못했어요' };
}
const envelope = await response.json();
if (!response.ok || !envelope.success) {
return { message: envelope.message ?? '댓글을 저장하지 못했어요' };
}
return { message: null };
}
이제 화면 차례인데, 목록은 서버 컴포넌트가 그려요.
// apps/web-next/app/components/CommentSection.tsx
import { fetchComments } from '@/lib/api';
import { CommentForm } from './CommentForm';
// 지시어가 없다. 서버 컴포넌트라 여기서 그냥 기다렸다 받는다.
export async function CommentSection({ postId }: { postId: number }) {
const comments = await fetchComments(postId);
return (
<section className="mt-2 border-t border-black/5 pt-2">
<ul className="space-y-1">
{comments.map((comment) => (
<li key={comment.id} className="text-sm">
<span className="mr-1 font-semibold">@{comment.username}</span>
{/* 중괄호로 넣는다. 여기가 오늘의 출발점이다. */}
<span>{comment.content}</span>
</li>
))}
</ul>
<CommentForm postId={postId} />
</section>
);
}
폼은 useActionState로 묶는데, D-7에서 팔로우 버튼에 쓴 것과 같고, 안에 넣은 SubmitButton은 D-8에서 공용으로 뽑아둔 것이에요.
// apps/web-next/app/components/CommentForm.tsx
'use client';
import { useActionState } from 'react';
import { addComment, type CommentState } from '@/app/actions/comment';
import { SubmitButton } from './SubmitButton';
const initial: CommentState = { message: null };
export function CommentForm({ postId }: { postId: number }) {
// 어느 게시물인지는 미리 묶어둔다. 나머지 두 인자는 React 가 채운다.
const [state, formAction] = useActionState(addComment.bind(null, postId), initial);
return (
<form action={formAction} className="mt-2 flex gap-2">
<label htmlFor={`comment-${postId}`} className="sr-only">
댓글 달기
</label>
<input
id={`comment-${postId}`}
name="content"
placeholder="댓글 달기…"
className="flex-1 border-b border-black/15 py-1 text-sm outline-none"
/>
<SubmitButton pendingLabel="다는 중…" className="text-sm font-semibold disabled:opacity-40">
게시
</SubmitButton>
{state.message !== null && (
<p aria-live="polite" className="text-sm text-black/60">
{state.message}
</p>
)}
</form>
);
}
마지막으로 피드 카드에 붙입니다.
// apps/web-next/app/components/FeedList.tsx
<LikeButton postId={post.id} likeCount={post.likeCount} liked={post.liked} />
<CommentSection postId={post.id} />
로그인하고 댓글을 달아보세요. 잘 달립니다.
이제 이상한 걸 넣어봅시다
보안 이야기를 시작할 때 가장 먼저 하는 실험이 있는데, 입력칸에 이걸 넣는 거예요.
<script>alert(1)</script>
댓글창에 넣고 게시를 눌러보시면, 화면에 이렇게 나옵니다.
@minji <script>alert(1)</script>
경고창은 안 뜨고 태그가 글자 그대로 화면에 보이는데, 우리가 넣은 문자열이 그냥 문자열로 남았습니다.
정말 그런지 소스 보기로 확인해봅시다. 브라우저에서 페이지 소스 보기를 열고 alert을 찾으면 이렇게 되어 있어요.
<span><script>alert(1)</script></span>
<가 <로 바뀌어 있는데, 브라우저에게 "이건 태그가 아니라 글자야"라고 알려주는 표기라서 브라우저가 태그로 안 읽고 그냥 글자로 그립니다.
누가 이렇게 바꿔줬을까요
우리가 한 게 아닙니다. {comment.content}라고 쓴 것뿐이에요.
React가 중괄호 안에 들어온 값을 화면에 넣을 때는 언제나 글자로 취급하는데, 태그처럼 생겼든 아니든 상관없이 그게 기본 동작이에요. 그 덕분에 지난 45일 동안 여러분이 만든 모든 화면이 이 문제에서 자유로웠습니다. 회원가입 폼도, 검색칸도, 프로필 이름도요. 한 번도 신경 안 썼는데 안전했어요.
이건 좋은 소식입니다. 그런데 조심할 게 하나 있어요. "React를 쓰면 안전하다"가 아니라 "중괄호로 넣으면 안전하다"입니다. 둘은 다른 말이에요.
그 차이가 다음 Step에서 드러납니다.
💡 한 줄 정리
중괄호로 넣은 값은 태그처럼 생겼어도 글자로 그려집니다. 45일 동안 우리가 안전했던 건 이 기본 동작 덕분이지, 우리가 뭘 해서가 아닙니다.
🙋 학생 질문 — "튜터님, 그럼 프로필 이름 같은 주소에서 온 값도 안전한가요?"
네, 같은 이유로 안전해요. /[username]의 username은 주소에서 오니까 사람이 아무거나 적어 넣을 수 있지만, 그것도 @{username}으로 그리고 있어서 글자로 나갑니다.
D-2에서 만난 것도 같이 떠올려두면 좋은데, 그때는 없는 사람 주소로 들어가면 프로필 껍데기가 가짜 정보를 그린다는 문제가 있었고 notFound()로 막았죠. 그건 오늘 이야기와는 다른 문제였는데, 화면에 이상한 게 그려지는 것과 화면에서 남의 코드가 도는 것은 심각도가 다릅니다.
오늘은 뒤엣것을 봅니다.
Step 2: "그런데 줄바꿈을 넣고 싶어졌습니다"
댓글을 며칠 굴렸더니 요청이 들어옵니다.
댓글에 줄바꿈이 안 돼요. 엔터를 쳐도 한 줄로 붙어 나옵니다. 그리고 강조도 좀 하고 싶어요.
말이 되는 요청이니까, 실제 서비스에서도 흔히 받는 요구예요.
가장 빨리 해결하는 방법은 댓글을 글자가 아니라 HTML로 넣는 것인데, 그래야 <br>도 <b>도 그대로 살아나거든요.
React에는 그걸 하는 방법이 있는데, 이름이 좀 깁니다.
// apps/web-next/app/components/CommentSection.tsx
<li key={comment.id} className="text-sm">
<span className="mr-1 font-semibold">@{comment.username}</span>
{/* 줄바꿈과 링크를 살리려고 글자가 아니라 HTML 로 넣는다.
이름이 길고 사나운 이유가 있다 — 이 한 줄이 오늘의 문제다. */}
<span dangerouslySetInnerHTML={{ __html: comment.content }} />
</li>
dangerouslySetInnerHTML. 위험하게 HTML을 넣는다는 뜻입니다.
React 팀이 일부러 이렇게 지었어요. 다른 API는 전부 짧고 담백한데 이것만 유난히 깁니다. 심지어 값도 {{ __html: ... }}처럼 한 겹 더 감싸게 해뒀어요. 그냥 문자열을 받게 만들 수도 있었는데 안 그랬습니다.
타이핑이 귀찮아야 한 번 더 생각한다는 겁니다. 오늘 그 이유를 확인하게 될 거예요.
일단 요구는 해결됐습니다. 댓글에 첫 줄<br>둘째 줄 그리고 <b>강조</b>를 넣으면 이렇게 나옵니다.
@minji 첫 줄
둘째 줄 그리고 강조
줄이 바뀌고 강조도 굵게 나옵니다. 요청하신 분이 만족하시겠네요.
그런데 아까 그 댓글이 아직 거기 있습니다
Step 1에서 넣었던 댓글 기억하시죠. 그때는 글자로만 보였던 그것요.
이번엔 페이로드를 조금 바꿔서 다시 넣어봅시다. 경고창은 실습 흐름을 끊으니까, 눈에 보이면서 창을 안 띄우는 걸로 할게요.
<script>document.title = "털렸습니다"</script>사진 좋네요
게시를 누르고 화면을 보세요. 댓글 목록에는 이렇게 나옵니다.
@minji document.title = "털렸습니다"사진 좋네요
태그는 안 보이고 그 안에 있던 코드가 글자로 보이네요. 좀 이상하죠.
이번엔 브라우저 탭을 보세요.
탭 제목: 인스타그램 클론 → 털렸습니다
바뀌었습니다.
무슨 일이 일어난 걸까요
서버가 보낸 HTML을 열어보면 이렇게 되어 있어요.
<span><script>document.title = "털렸습니다"</script>사진 좋네요</span>
Step 1에서는 <script>로 나왔던 것이 이번엔 진짜 <script>입니다. 브라우저는 이걸 문서의 일부로 읽어서, 안에 있는 코드를 실행하죠.
댓글 목록에 이상한 글자가 보였던 것도 설명이 됩니다. <script> 태그 자체는 화면에 안 그려지지만 그 안의 글자는 남거든요. 실제 공격자라면 그것도 안 보이게 만들 겁니다.
지금 우리 앱의 상태를 정리하면 이렇습니다.
- 누구나 댓글을 달 수 있다
- 댓글은 HTML로 화면에 들어간다
- 그러니 누구나 우리 페이지에서 자기 코드를 돌릴 수 있다
댓글 하나로 탭 제목을 바꿨습니다. 제목만 바꿀 수 있는 건 아니겠죠. 그건 Step 4에서 끝까지 가봅니다.
💡 한 줄 정리
dangerouslySetInnerHTML은 받은 문자열을 문서의 일부로 만듭니다. 서식이 살아나는 것과 남의 코드가 도는 것은 같은 동작의 양면이에요.
🙋 학생 질문 — "튜터님, 그럼 그 API를 아예 안 쓰면 되는 것 아닌가요?"
대부분의 경우 맞고, 그게 첫 번째 답이어야 해요.
실무에서 이 API를 쓸 일은 생각보다 적은데, 줄바꿈만 필요하면 CSS로 되거든요. white-space: pre-wrap 한 줄이면 엔터가 살아나서 굳이 HTML로 안 넣어도 됩니다.
그럼에도 이 API가 필요한 경우가 있긴 한데, 마크다운으로 쓴 글을 HTML로 바꿔 보여주거나 관리자가 작성한 공지에 링크를 넣을 때예요. 여러분이 언젠가 이미 이 API를 쓰고 있는 코드베이스에 들어가게 될 수도 있고요.
오늘은 쓰지 말라로 끝내지 않고 써야 한다면 어떻게 해야 하는지까지 가는데, 순서는 기억해주세요. 첫 번째 답은 언제나 "안 쓴다"입니다.
Step 3: "공격 문자열 하나로 시험하면 속습니다"
여기서 잠깐 멈춥시다.
여러분이 회사에서 이 문제를 점검한다고 해봐요. 입력칸마다 <script>alert(1)</script>를 넣어보고, 경고창이 안 뜨면 "안전하다"라고 표시할 겁니다. 흔한 방법이에요.
그런데 그 방법에는 구멍이 있습니다. 직접 재봅시다.
클라이언트가 나중에 붙이면 어떻게 될까요
우리 댓글은 서버가 그린 HTML에 들어가서, 브라우저가 문서를 읽으면서 스크립트를 만나 실행했어요.
그런데 화면이 다 뜬 다음에 자바스크립트가 HTML을 밀어 넣는 경우도 있습니다. C 카테고리에서 만든 SPA가 그런 구조였는데, 그때는 어떻게 될까요.
개발자 도구 콘솔을 열고 이걸 붙여넣어 보세요.
const box = document.createElement('div');
document.body.append(box);
box.innerHTML = '<script>document.title = "이것도 돌까"<\/script><img src=x onerror="document.body.dataset.late = 1">';
// 이미지 로드 실패는 비동기다. 바로 읽으면 아직 안 돈다.
await new Promise((r) => setTimeout(r, 800));
console.log('script 태그가 DOM 에 있나 :', box.querySelectorAll('script').length);
console.log('script 가 돌았나 :', document.title === '이것도 돌까');
console.log('img 태그가 DOM 에 있나 :', box.querySelectorAll('img').length);
console.log('onerror 가 돌았나 :', document.body.dataset.late === '1');
결과가 이렇게 나옵니다.
script 태그가 DOM 에 있나 : 1
script 가 돌았나 : false
img 태그가 DOM 에 있나 : 1
onerror 가 돌았나 : true
읽어보면 이상하죠. <script> 태그는 DOM에 분명히 들어갔는데 안 돌았습니다. 반면 <img>의 onerror는 돌았어요.
이건 브라우저 규칙이에요. innerHTML로 밀어 넣은 <script>는 실행하지 않기로 정해져 있어서, 문서를 처음 읽을 때 만난 스크립트만 실행됩니다.
그런데 onerror 같은 이벤트 속성은 그 규칙에 안 걸립니다. 이미지 주소가 잘못돼 로드에 실패하고, 실패했으니 onerror가 불리고, 거기 적힌 코드가 돕니다.
네 칸을 채워봅시다
지금까지 잰 걸 모으면 이렇게 됩니다.
| 넣는 방법 | <script> |
<img onerror> |
|---|---|---|
중괄호 {content} |
글자로만 | 글자로만 |
| 서버가 그린 HTML (우리 앱) | 실행됨 | 실행됨 |
클라이언트가 innerHTML로 |
안 돌아감 | 실행됨 |
이 표에서 읽어야 할 게 두 가지입니다.
하나. 오른쪽 칸은 세 줄 중 두 줄이 실행되는데, 왼쪽 칸은 한 줄뿐이에요. 그러니 <script>로만 시험하면 실제로는 뚫려 있는데 안전하다고 판정할 수 있습니다. 클라이언트가 그리는 화면에서 특히 그래요.
둘. 같은 코드인데 서버에서 그리느냐 브라우저에서 그리느냐로 결과가 갈리는데, D-3에서 그은 그 선이 여기서 다시 나와요. 그때는 번들 크기와 데이터 유출이 갈렸는데, 오늘은 공격이 되느냐 안 되느냐가 갈립니다.
C 카테고리의 SPA를 D 카테고리에서 Next로 옮겼을 때, 우리는 속도와 검색 노출을 얻었습니다. 그런데 같은 이동으로 <script>가 도는 조건까지 함께 얻었어요. 아무도 안 알려줬죠.
재는 방법이 답을 바꿉니다
위 콘솔 코드에서 await new Promise(...) 줄을 빼고 다시 돌려보세요. onerror 가 돌았나가 false로 나옵니다.
이미지 로드가 실패하는 데 시간이 걸리기 때문이에요. 기다리지 않고 바로 읽으면 아직 안 돈 상태를 읽습니다.
이 과목에서 반복해서 만난 이야기죠. 이상한 결과가 나오면 코드보다 재는 쪽을 먼저 의심해야 하는데, 여기서는 안 기다린 것이 "안전하다"라는 정반대 결론을 만들 수 있었습니다.
💡 한 줄 정리
<script>는 문서를 처음 읽을 때만 실행되고, 이벤트 속성은 언제나 실행됩니다. 한 가지 문자열로만 시험하면 뚫린 것을 막혔다고 읽게 돼요.
🙋 학생 질문 — "튜터님, 그럼 시험할 문자열을 몇 개나 준비해야 하나요?"
이 질문을 하셨다면 방향을 잘못 잡으신 겁니다. 저도 처음엔 그렇게 생각했어요.
목록을 늘리는 접근은 공격자보다 먼저 다 떠올려야 이긴다는 게임입니다. 그건 못 이겨요. onerror 말고도 onload·onfocus·onanimationend가 있고, 태그도 <img> 말고 <svg>·<object>·<iframe>이 있고, 주소에 javascript:를 넣는 방법도 있는 데다, 브라우저가 새 버전을 내면 새로운 통로가 또 생겨요.
방향을 뒤집어야 합니다. 무엇을 막을지 목록으로 만들지 말고, 무엇을 허용할지 목록으로 만드세요. 허용목록에 없는 것은 이름을 몰라도 전부 지워집니다.
그게 Step 5에서 할 일입니다.
Step 4: "훔칠 수 있는 것과 없는 것"
탭 제목을 바꾸는 건 장난이죠. 실제로 무엇을 할 수 있는지 끝까지 가봅시다.
로그인한 상태에서 콘솔을 열고 재보는데, 지금부터 하는 일은 전부 댓글에 심어둔 스크립트가 할 수 있는 일이에요.
먼저 훔쳐보겠습니다
console.log('세션 쿠키를 읽을 수 있나 :', document.cookie.includes('session_token'));
console.log('document.cookie 전체 :', document.cookie);
결과는 이렇습니다.
세션 쿠키를 읽을 수 있나 : false
document.cookie 전체 : (세션 쿠키는 여기 없다)
못 읽습니다. G-1에서 세션 쿠키에 HttpOnly를 붙였기 때문이에요. 그때 이렇게 정리했었죠.
HttpOnly 페이지의 자바스크립트가 못 읽는다
그 약속이 지금 지켜지고 있습니다. 남이 심은 스크립트가 우리 페이지에서 돌고 있는데도 세션은 못 가져가요.
C-6에서 미뤄뒀던 이야기도 여기서 갚아집니다. 그때 SPA는 토큰을 localStorage에 뒀고, 그건 페이지의 자바스크립트가 다 읽을 수 있다고 했었죠. 지금 우리 Next 앱에는 그렇게 둔 토큰이 아예 없습니다. 백엔드 출입증은 G-3에서 서버가 들고 있게 바꿨으니까요.
그러니 훔쳐갈 게 없어서, 여기까지는 좋은 소식이에요.
그런데 훔치지 않고도 할 수 있습니다
같은 콘솔에서 이어서 해봅시다.
const 나 = await fetch('/api/auth/get-session', { credentials: 'same-origin' })
.then((r) => r.json());
console.log('서버는 나를 누구로 아나 :', 나?.user?.username);
서버는 나를 누구로 아나 : jaehoon
토큰은 못 읽었는데 서버는 재훈으로 알아봅니다.
당연한 일이에요. 쿠키를 못 읽는 것과 쿠키가 요청에 안 실리는 것은 다른 이야기니까요. HttpOnly는 읽기만 막는데, 브라우저는 여전히 우리 서버로 가는 모든 요청에 그 쿠키를 자동으로 실어 보내요.
즉 남이 심은 스크립트는 재훈의 신원을 손에 쥐지 않은 채로 재훈처럼 행동할 수 있습니다.
얼마나 할 수 있는지 봅시다
댓글 폼이 화면에 있으니까, 그걸 대신 제출시켜 보겠습니다.
const 입력 = document.querySelector('#comment-2');
const 폼 = 입력.closest('form');
입력.value = '제가 안 쓴 댓글입니다';
입력.dispatchEvent(new Event('input', { bubbles: true }));
폼.requestSubmit();
잠시 뒤 2번 게시물의 댓글을 확인하면 이렇습니다.
@jaehoon 3박 4일이면 넉넉했나요?
@jaehoon 제가 안 쓴 댓글입니다
재훈 이름으로 댓글이 달렸는데, 재훈은 아무것도 안 눌렀어요.
여기서 D-8에서 한 이야기를 떠올려보세요. 그때 우리는 서버 액션마다 currentUser()로 신원을 확인하도록 했습니다. 지금 그 확인이 정상적으로 통과했어요. 서버 입장에서는 재훈의 브라우저에서 온, 재훈의 세션이 실린, 정상적인 요청이거든요.
서버는 이걸 구분할 방법이 없습니다.
정리하면
G-3 마지막에 "뒷문이 한 칸 옮겨갔다"고 했던 것과 같은데, 막았다고 생각한 것이 실제로는 한 칸 옮겨간 것뿐이었죠.
훔쳐가기 HttpOnly 가 막는다 → 못 가져간다
그 신원으로 행동 HttpOnly 는 상관 없다 → 다 된다
그러니 HttpOnly를 붙였다고 XSS를 막은 게 아닙니다. 그건 털린 뒤의 피해를 줄이는 장치예요. 세션이 통째로 유출되면 공격자는 자기 컴퓨터에서 시간을 두고 재훈 행세를 할 수 있지만, 그게 막히면 재훈이 우리 페이지를 열고 있는 동안만 할 수 있습니다.
피해 범위는 줄었어요. 그런데 우리 페이지에서 남의 코드가 도는 것 자체는 그대로이니까, 그건 다른 방법으로 막아야 합니다.
💡 한 줄 정리
HttpOnly는 읽어가는 것을 막지 그 신원으로 행동하는 것을 못 막습니다. 쿠키가 안 보여도 요청에는 실려 나가니까요.
🙋 학생 질문 — "튜터님, 그럼 로그인한 사람만 댓글을 볼 수 있게 하면 안 되나요?"
이미 그렇게 되어 있는데, 그게 방어가 안 돼요.
지금 우리 앱은 G-2에서 문지기를 세워서 로그인 안 한 사람은 프로필 화면에 못 들어갑니다. 그런데 오늘 공격이 어디서 성공했는지 다시 보세요. 로그인한 재훈의 화면에서 일어났습니다.
공격자는 재훈의 화면에 들어갈 필요가 없어요. 댓글 하나만 달아두면 됩니다. 그다음은 재훈이 알아서 그 페이지를 열고, 재훈의 브라우저가 알아서 스크립트를 실행하죠.
이게 이 문제의 고약한 점입니다. 공격자와 피해자가 같은 시간에 마주칠 필요가 없어요. 공격자는 글을 남기고 떠나고, 피해는 나중에 다른 사람 화면에서 발생합니다.
Step 5: "그리기 직전에 걸러냅니다"
이제 고칩니다.
방향은 Step 3의 질문에서 이미 정했어요. 막을 것을 나열하지 않고 허용할 것을 나열합니다. 우리가 댓글에 필요한 건 줄바꿈과 굵게, 그리고 링크 정도죠. 그것만 남기고 나머지는 전부 지웁니다.
이 일을 하는 것을 살균(sanitization)이라고 부릅니다.
브라우저에 이 기능이 들어왔습니다
반가운 소식부터 말씀드릴게요. 2026년에 브라우저가 이 일을 직접 해주기 시작했는데, 그 이름이 Element.setHTML()이에요.
콘솔에서 재볼 수 있습니다.
const d = document.createElement('div');
d.setHTML('<b>굵게</b><img src=x onerror="1"><script>1<\/script><a href="javascript:alert(1)">링크</a>');
console.log(d.innerHTML);
<b>굵게</b><a>링크</a>
<script>도 사라지고 <img>도 사라지고, <a>는 남았는데 href만 떨어져 나갔습니다. 우리가 아무 설정도 안 줬는데요.
이 함수가 좋은 이유는 브라우저가 직접 하기 때문입니다. 브라우저는 자기가 HTML을 어떻게 읽는지 정확히 아는데, 라이브러리는 그걸 바깥에서 흉내 내야 하고, 흉내에는 늘 어긋나는 구석이 생깁니다.
그런데 우리 앱에서는 못 씁니다
우리 댓글은 서버 컴포넌트가 그립니다. 서버에서 확인해보면 이래요.
node -e "console.log('Element:', typeof globalThis.Element)"
Element: undefined
서버에는 Element가 없는데, document도 Sanitizer도 없어요. 당연합니다. 그건 브라우저가 가진 것이지 Node가 가진 게 아니니까요.
D-3에서 그은 선이 여기서 또 나옵니다. 브라우저에 새 기능이 생겼다는 소식은 브라우저에서 그리는 코드에만 도착하는데, 우리는 서버에서 그리기로 정했으니 그 소식이 우리에게 안 닿습니다.
그러니 갈라서 기억하세요.
화면을 브라우저가 그린다 → 브라우저 내장 setHTML 을 쓸 수 있다
(다만 사파리가 아직 없어서 대비가 필요하다)
화면을 서버가 그린다 → 서버에서 도는 도구가 필요하다 ← 우리
서버에서 걸러냅니다
서버에서 도는 살균 도구를 들입니다.
npm install --workspace apps/web-next isomorphic-dompurify
다음으로 우리 댓글에 맞는 허용목록을 정합니다.
// apps/web-next/lib/sanitize.ts
import DOMPurify from 'isomorphic-dompurify';
/**
* 댓글에 허용할 서식. 여기 없는 것은 전부 지운다.
*
* 목록을 넓게 잡을수록 나중에 후회한다. 지금 필요한 것만 적었다.
* 댓글에 이미지가 빠진 것은 우리가 필요 없다고 정한 것이지 img 가 위험해서가 아니다.
*/
const ALLOWED_TAGS = ['b', 'i', 'strong', 'em', 'br', 'a'];
const ALLOWED_ATTR = ['href'];
/**
* 저장된 댓글을 화면에 그리기 직전에 통과시킨다.
*
* 브라우저에도 같은 일을 하는 기능이 생겼지만(`Element.setHTML`) 그건 브라우저 API 다.
* 댓글은 서버 컴포넌트가 그리고 서버에는 그 함수가 없다 — 그래서 여기서 한다.
*
* 쓸 때가 아니라 그릴 때 하는 이유는, 우리가 이 함수를 만들기 전에 저장된 것도 있고
* 다른 경로로 들어온 것도 있기 때문이다. 화면으로 나가는 길목이 마지막 관문이다.
*/
export function sanitizeComment(raw: string): string {
return DOMPurify.sanitize(raw, { ALLOWED_TAGS, ALLOWED_ATTR });
}
화면에서는 한 번 거쳐서 넣습니다.
// apps/web-next/app/components/CommentSection.tsx
<li key={comment.id} className="text-sm">
<span className="mr-1 font-semibold">@{comment.username}</span>
{/* 여전히 HTML 로 넣는다. 달라진 것은 넣기 전에 한 번 거른다는 것뿐이다.
이 함수를 빼면 이 줄은 그대로 오늘 오전의 그 줄이 된다. */}
<span dangerouslySetInnerHTML={{ __html: sanitizeComment(comment.content) }} />
</li>
새로고침해보세요.
탭 제목: 인스타그램 클론 ← 안 바뀐다
@minji 첫 줄
둘째 줄 그리고 강조 ← 서식은 그대로 산다
@minji 사진 좋네요 ← 스크립트만 사라졌다
서식은 살아남고 공격은 사라졌는데, 우리가 얻고 싶었던 것만 남았어요.
두 도구의 기본값이 다릅니다
여기서 짚고 갈 게 있어요. 아까 브라우저 내장 함수로 재본 것과 방금 라이브러리로 한 것을 같은 입력으로 나란히 놓으면 이렇습니다.
| 넣은 것 | 브라우저 setHTML |
서버 DOMPurify |
|---|---|---|
<b>굵게</b> |
남음 | 남음 |
깨끗한 <img src="..."> |
사라짐 | 남음 |
<img src=x onerror="..."> |
통째로 사라짐 | <img src="x">로 남음 |
<script> |
사라짐 | 사라짐 |
<a href="https://..."> |
남음 | 남음 |
<a href="javascript:..."> |
href만 제거 |
href만 제거 |
<b style="color:red"> |
style 제거 |
style 남음 |
세 군데가 갈립니다. 둘 다 "살균"이라고 부르는데 결과가 달라요.
그러니 살균에는 하나의 정답이 없습니다. 도구가 정해둔 기본값이 있고, 그 기본값이 우리 서비스에 맞는지는 우리가 판단해야 해요. 브라우저 내장 함수를 아무 설정 없이 썼다면 사용자가 올린 이미지가 조용히 사라졌을 겁니다. 오류도 경고도 없이요.
우리가 위에서 허용목록을 직접 적은 이유가 이겁니다. 기본값에 맡기지 않고 우리 댓글에 필요한 것을 우리가 정했어요.
브라우저는 이 무게를 안 받습니다
마지막으로 하나 확인하고 갑시다. 방금 설치한 도구는 브라우저를 흉내 내는 코드까지 딸려와서 꽤 무거워서, 설치 폴더가 14MB 정도 늘어요.
그런데 빌드하고 브라우저가 받아가는 파일을 뒤져보면 이 도구의 흔적이 한 건도 없습니다. 서버 쪽 번들에만 있어요.
D-3에서 잰 것과 같은 이야기입니다. 경계 밖으로는 자바스크립트가 안 가요. 살균을 서버에서 하기로 한 덕분에 사용자는 이 무게를 한 바이트도 안 받습니다.
서버에서 그리기로 한 결정이 여기서 값을 합니다. 브라우저 내장 함수를 못 쓰게 만든 그 결정이요.
💡 한 줄 정리
막을 것을 나열하지 말고 허용할 것을 나열하세요. 그 목록의 기본값은 도구마다 다르니, 우리 서비스에 맞는지는 우리가 정해야 합니다.
🙋 학생 질문 — "튜터님, 저장할 때 걸러내면 더 낫지 않나요? 그리면 매번 안 해도 되잖아요."
성능만 보면 그렇습니다. 그런데 저장할 때만 하면 구멍이 남아요.
이유가 셋입니다.
하나. 이미 저장된 것이 있습니다. 우리 앱만 해도 오늘 오전에 넣은 공격 댓글이 데이터베이스에 그대로 있어요. 저장할 때만 거르면 그건 계속 살아 있습니다.
둘. 들어오는 길이 하나가 아닙니다. 우리 폼 말고도 백엔드가 직접 넣을 수도 있고, 관리자 도구가 넣을 수도 있고, 나중에 만들 다른 화면이 넣을 수도 있어요. 길목마다 거르는 코드를 붙여야 하는데, 하나만 빠뜨려도 뚫립니다.
셋. 저장할 때 거르면 원본이 사라집니다. 나중에 허용목록을 바꾸고 싶어도 원래 뭐였는지 알 수 없어요.
화면으로 나가는 길목은 하나입니다. 거기서 거는 게 확실해요. 저장할 때도 같이 거르면 더 좋고, 둘은 배타적인 선택이 아니에요. 그건 오늘 과제로 드립니다.
마무리
오늘 배운 핵심 세 가지
💡 하나. 안전했던 게 아니라 안전한 방법을 쓰고 있었습니다. 45일 동안 이 문제를 안 만난 건 중괄호로 값을 넣었기 때문입니다. React를 쓴 덕이 아니라 그 방법을 쓴 덕이에요. 방법을 바꾼 순간 문제가 나타났습니다.
💡 둘. 한 가지로 시험하면 거짓 안심을 얻습니다. <script>는 서버가 그릴 때만 돌고, 이벤트 속성은 언제나 도는데, 막을 목록을 늘리는 방향으로는 못 이겨요. 허용할 목록을 좁게 잡는 쪽이 이깁니다.
💡 셋. 막았다는 말은 대개 이 경로를 막았다는 뜻입니다. HttpOnly는 훔쳐가는 것을 막았지 그 신원으로 행동하는 것은 못 막았어요. 지난 시간의 뒷문 이야기와 같은 구조입니다. 한 겹을 막았으면 그다음 겹을 물어봐야 합니다.
다음 시간 예고
오늘 우리는 구멍 하나를 막았는데, 댓글이라는 구멍이었어요.
여기서 이렇게 물어보실 수 있습니다. 우리 앱에 구멍이 그것 하나뿐이라고 어떻게 확신하죠?
못 해요. 화면은 계속 늘어나고 입력칸도 늘어나고, 나중에 들어온 사람이 dangerouslySetInnerHTML을 한 줄 더 쓸 수도 있어서, 구멍을 하나씩 막는 방식으로는 마지막 구멍을 찾았다고 말할 수가 없습니다.
그래서 다음 시간에는 방향을 뒤집습니다. 구멍을 찾아다니는 대신, 브라우저에게 미리 알려두는 방법을 봐요. "이 페이지에서는 우리가 허락한 곳의 코드만 실행해라"라고요. 그러면 우리가 못 찾은 구멍으로 뭔가 들어와도 실행되지는 않습니다.
그리고 그 뒤에 하나 더 봅니다. 오늘은 남이 우리 페이지 안에서 코드를 돌렸죠. 그다음 시간에는 남의 사이트가 자기 쪽에서 우리 앱으로 요청을 보내는 경우를 봐요. 그중 하나는 이미 막혀 있고 하나는 활짝 열려 있는데, 열려 있는 쪽은 D-7에서 우리가 직접 만든 것이에요.
과제
[구현] 저장할 때도 걸러보기
Step 5의 🙋 에서 저장할 때 거르는 것과 그릴 때 거르는 것을 이야기했습니다. 이번엔 둘 다 해봅시다.
addComment 액션에서 서버로 보내기 전에 sanitizeComment를 한 번 통과시키고, 다음 두 가지를 확인해보세요.
- 새로 단 댓글이 연습용 서버에 어떤 모양으로 저장되는지 직접 확인합니다. 저장된 값 자체가 달라져야 해요.
- 그 상태에서
CommentSection의sanitizeComment를 잠깐 빼보세요. 오늘 오전에 넣어둔 공격 댓글은 어떻게 되나요? 새로 단 댓글은요?
2번의 결과가 이 과제의 핵심입니다. 왜 그릴 때 거르는 것을 뺄 수 없는지가 눈에 보일 거예요. 확인한 뒤에는 뺐던 것을 다시 넣어두세요.
[구현] 링크를 허용했더니
우리 허용목록에는 a 태그와 href 속성이 들어 있습니다. 그러니 댓글에 링크를 넣을 수 있어요.
그런데 링크의 주소에는 https://만 들어가는 게 아닙니다. 다음 댓글을 넣어보세요.
<a href="javascript:document.title='링크로도 되네'">눌러보세요</a>
화면에 링크가 보이나요? 눌러보면 어떻게 되나요? 소스 보기에서 그 링크의 href가 어떻게 되어 있는지도 확인해보세요.
결과를 봤으면 이제 도구를 시험해봅시다. 아래 넷을 차례로 넣고, 각각 href가 살아남는지 적으세요.
<a href="JaVaScRiPt:alert(1)">대소문자를 섞으면</a>
<a href=" javascript:alert(1)">앞에 공백을 넣으면</a>
<a href="data:text/html,<b>hi</b>">다른 종류의 주소면</a>
<a href="https://example.com">평범한 주소면</a>
넷 중 몇 개가 통과하나요? 통과 여부를 가르는 기준이 무엇인지도 말로 적어보세요.
마지막으로 답해보세요. 우리가 ALLOWED_ATTR에 href를 넣은 것은 위험한 결정이었나요? 그 판단의 근거는 우리가 잘 정한 것인가요, 아니면 다른 무언가가 한 겹 더 해주고 있는 것인가요?
[탐구] 두 도구를 같은 입력으로 재보기
Step 5의 표를 직접 만들어봅시다. 아래 입력들을 브라우저 콘솔의 setHTML과 서버의 DOMPurify.sanitize(설정 없이 기본값으로) 양쪽에 넣고 결과를 적으세요.
<b style="color:red">빨강</b>
<img src="https://picsum.photos/seed/a/64">
<div onclick="1">눌러보세요</div>
<iframe src="https://example.com"></iframe>
첫 줄<br>둘째 줄<p>문단</p>
서버 쪽은 이렇게 재면 됩니다.
node --input-type=module -e "
import DOMPurify from 'isomorphic-dompurify';
console.log(DOMPurify.sanitize('여기에 넣으세요'));
"
표를 채운 뒤 몇 군데가 갈리는지 세어보고, 그중에서 우리 인스타그램 클론에 실제로 문제가 될 만한 것이 있는지 판단해보세요.
[탐구] 우리 앱을 전수 점검하기
오늘은 댓글 하나를 고쳤습니다. 그런데 우리 앱에는 사용자가 값을 넣을 수 있는 통로가 더 있어요.
apps/web-next 전체에서 dangerouslySetInnerHTML을 찾아보세요. 몇 건이 나오나요?
그다음, 사용자 입력이 화면 어디에 그려지는지 직접 세어보세요. 회원가입 폼의 오류 메시지, 프로필 주소의 사용자 이름, 검색칸에 친 글자 같은 것들요. 각각이 어떤 방법으로 화면에 들어가는지 확인하고, 오늘 배운 기준으로 안전한지 판단해보세요.
이 과제의 목적은 목록을 만드는 게 아닙니다. 찾는 방법을 익히는 것이에요. 새 화면을 만들 때마다 이걸 할 수 있어야 합니다.
생각해볼 주제
1. 마크다운이면 안전할까요
댓글에 서식을 넣는 방법으로 마크다운을 쓰는 서비스가 많습니다. 사용자는 **굵게**라고 쓰고, 서버나 브라우저가 그걸 <b>굵게</b>로 바꿔서 화면에 넣죠.
이렇게 하면 사용자가 태그를 직접 못 쓰니까 오늘 문제가 안 생길 것처럼 보입니다. 정말 그럴까요?
마크다운을 HTML로 바꾸는 과정에서 무엇이 일어나는지 생각해보고, 마크다운 문법 중에 주소를 받는 것이 있는지도 떠올려보세요.
2. 우리가 만든 게 아닌 구멍
오늘 우리가 고친 곳은 우리가 직접 쓴 코드였지만, 실무에서는 우리가 안 쓴 코드가 화면에 들어오는 경우가 있어요.
E-5에서 디자인 시스템 컴포넌트를 복사해 들여왔던 것을 떠올려보세요. 그때 우리는 그 파일들을 읽어봤습니다. 만약 그중 하나가 받은 값을 HTML로 넣고 있었다면, 우리는 그걸 알았을까요?
그리고 복사가 아니라 설치해서 쓰는 라이브러리라면요. 광고 스크립트나 분석 도구처럼 남의 서버에서 받아오는 코드는요?
이 질문들에 대해 우리가 할 수 있는 일과 할 수 없는 일을 갈라보세요.
3. 왜 사용자가 서식을 원할까요
오늘 이 모든 문제의 출발점은 "댓글에 줄바꿈이 안 돼요"라는 요청 하나였습니다.
그 요청을 받았을 때 우리에게는 선택지가 여럿 있었어요. HTML로 넣기, CSS로 줄바꿈만 살리기, 마크다운 쓰기, 그냥 안 해주기.
우리는 첫 번째를 골랐고 그 대가로 오늘 하루를 썼습니다. 만약 두 번째를 골랐다면 어땠을까요?
요구사항을 그대로 받는 것과 요구사항 뒤에 있는 목적을 보는 것이 어떻게 다른지 생각해보고, 여러분이 실제로 이 요청을 받았다면 무엇을 되물어봤을지도 생각해보세요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 저장할 때도 걸러보기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 액션에서 거른다 | 5 | addComment가 서버로 보내기 전에 sanitizeComment를 통과시킨다 |
| 저장된 값을 직접 확인했다 | 6 | 화면이 아니라 서버가 들고 있는 값을 봤다 |
| 옛 댓글과 새 댓글을 갈라서 봤다 | 8 | 살균을 뺐을 때 둘의 운명이 다르다는 것을 확인했다 |
| 왜 둘 다 필요한지 말로 정리했다 | 6 | "이미 저장된 것" 이라는 근거가 나온다 |
풀이 예시
액션에 한 줄을 더합니다.
// apps/web-next/app/actions/comment.ts
import { sanitizeComment } from '@/lib/sanitize';
// 저장하기 전에도 한 번 거른다.
const content = sanitizeComment(String(formData.get('content') ?? '')).trim();
이 상태로 3번 게시물에 이런 댓글을 답니다.
<script>document.title="저장될까"</script><b>굵게</b> 좋아요
다음으로 화면이 아니라 서버가 들고 있는 값을 확인합니다.
curl -s "http://localhost:8090/api/posts/3/comments"
10 @jaehoon: '<b>굵게</b> 좋아요'
<script>가 저장 단계에서 이미 사라졌는데, 서버에 남은 것 자체가 깨끗해요.
이제 그릴 때의 살균을 잠깐 빼봅니다
CommentSection의 sanitizeComment(...)를 comment.content로 되돌리고 새로고침하면 이렇게 갈립니다.
| 대상 | 결과 |
|---|---|
| 3번 게시물의 새 댓글 (저장이 이미 깨끗) | 안전하다 |
| 1번 게시물의 옛 댓글 (오늘 오전에 저장) | <script>와 onerror가 HTML에 되살아난다 |
소스 보기로 확인하면 실행 가능한 <script>도, onerror 속성도 그대로 들어 있습니다. 오전에 저장된 값은 우리가 액션을 고쳐도 바뀌지 않으니까요.
자주 나오는 실수
저장할 때 걸렀으니 그릴 때는 빼도 된다고 판단하는 것. 위 표의 두 번째 줄이 그 판단의 대가입니다. 이미 저장된 것은 액션을 고쳐도 안 바뀌고, 우리 폼 말고 다른 경로로 들어오는 값도 액션을 안 거칩니다.
화면만 보고 판단하는 것. 살균을 켠 채로 화면만 보면 옛 댓글도 멀쩡해 보입니다. 저장된 값이 더러운지 깨끗한지는 화면으로 알 수 없는데, 서버에 직접 물어봐야 합니다.
💡 튜터의 한마디 — 저장할 때 거르는 것은 좋은 습관이지만 방어선이 아닙니다. 방어선은 화면으로 나가는 길목 하나예요. 들어오는 길은 여러 개고 앞으로 더 늘어나지만, 나가는 길은 우리가 세어볼 수 있습니다.
🎯 [과제 2 예시답안] 링크를 허용했더니
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 화면과 소스를 둘 다 확인했다 | 6 | 링크는 보이는데 href가 없다는 것을 봤다 |
| 변형 넷을 전부 재봤다 | 8 | 짐작으로 답하지 않았다 |
| 가르는 기준을 말로 적었다 | 6 | 주소의 종류를 본다는 것이 나온다 |
| 판단의 출처를 갈랐다 | 5 | 우리가 정한 것과 도구가 해준 것을 구분했다 |
풀이 예시
먼저 넣어본 댓글입니다.
<a href="javascript:document.title='링크로도 되네'">눌러보세요</a>
화면에는 「눌러보세요」라는 글자가 보입니다. 그런데 눌러도 아무 일이 안 일어나요. 소스 보기로 확인하면 이유가 나옵니다.
<a>눌러보세요</a>
href가 통째로 떨어져 나갔는데, 주소가 없으니 링크가 아니라 그냥 글자예요.
변형 넷의 결과
| 넣은 것 | href가 남는가 |
|---|---|
JaVaScRiPt:alert(1) |
안 남는다 |
javascript:alert(1) (앞 공백) |
안 남는다 |
data:text/html,<b>hi</b> |
안 남는다 |
https://example.com |
남는다 |
넷 중 하나만 통과합니다.
가르는 기준은 주소의 종류입니다. 도구가 href 값을 그냥 문자열로 넘기지 않고, 앞에 붙은 주소 종류를 보고 안전한 것만 통과시켜서, 대소문자를 섞어도 앞에 공백을 넣어도 같은 판정이 나옵니다.
그래서 위험한 결정이었나
아니었습니다. 그런데 우리가 잘 정해서가 아닙니다.
우리가 한 것은 ALLOWED_ATTR에 href를 적은 것뿐이에요. "어떤 주소는 되고 어떤 주소는 안 된다"는 판단은 우리가 한 적이 없습니다. 도구가 한 겹 더 해준 겁니다.
이 구분이 중요한 이유가 있어요. 우리가 직접 허용목록을 짰다면 십중팔구 이걸 빠뜨렸을 겁니다. <a>는 안전한 태그라고 생각하고 넣었을 테니까요.
자주 나오는 실수
넷 중 하나만 재보고 나머지를 짐작하는 것. 특히 대소문자 섞기는 "당연히 막히겠지"라고 넘어가기 쉬운데, 직접 짠 검사는 여기서 가장 자주 뚫립니다. 재보는 것과 짐작하는 것은 다릅니다.
「안 위험했다」로 끝내는 것. 결과가 안전했다는 것과 우리가 안전하게 만들었다는 것은 다른 이야기예요. 다음에 다른 도구를 쓰거나 직접 짜게 되면 이 한 겹이 없을 수 있습니다.
💡 튜터의 한마디 — 직접 짜지 말라는 조언이 왜 나오는지가 여기서 보입니다. 허용목록은 태그 이름만의 문제가 아니에요. 속성마다 값의 규칙이 다르고, 그 규칙을 다 아는 것이 도구가 하는 일입니다.
🎯 [과제 3 예시답안] 두 도구를 같은 입력으로 재보기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 다섯 입력을 양쪽에서 재봤다 | 8 | 표가 채워져 있다 |
| 몇 군데가 갈리는지 셌다 | 5 | 두 군데라는 답이 나온다 |
| 우리 서비스 기준으로 판단했다 | 7 | 일반론이 아니라 인스타그램 클론 기준으로 말한다 |
| 기본값을 안 믿어야 하는 이유를 적었다 | 5 | 조용히 사라진다는 점이 나온다 |
풀이 예시
브라우저 콘솔에서는 이렇게 잽니다.
const d = document.createElement('div');
d.setHTML('<b style="color:red">빨강</b>');
console.log(d.innerHTML);
서버는 이렇게 잽니다.
node --input-type=module -e "
import DOMPurify from 'isomorphic-dompurify';
console.log(DOMPurify.sanitize('<b style=\"color:red\">빨강</b>'));
"
채운 표
| 넣은 것 | 브라우저 setHTML |
서버 DOMPurify |
|---|---|---|
<b style="color:red">빨강</b> |
<b>빨강</b> |
<b style="color:red">빨강</b> |
<img src="https://picsum.photos/..."> |
(빈 문자열) | <img src="https://picsum.photos/..."> |
<div onclick="1">눌러보세요</div> |
<div>눌러보세요</div> |
<div>눌러보세요</div> |
<iframe src="https://example.com"> |
(빈 문자열) | (빈 문자열) |
첫 줄<br>둘째 줄<p>문단</p> |
그대로 | 그대로 |
두 군데가 갈립니다. style 속성과 이미지요.
우리 서비스 기준으로는
두 군데 다 실제로 문제가 될 수 있는데, 방향이 반대예요.
이미지. 브라우저 기본값을 그대로 썼다면 사용자가 올린 이미지가 사라집니다. 인스타그램 클론에서 이미지가 사라지는 것은 가벼운 문제가 아니죠. 그런데 오류도 경고도 안 납니다. 누가 문의를 넣기 전까지 아무도 모릅니다.
style 속성. 서버 도구 기본값을 그대로 썼다면 사용자가 색과 크기를 마음대로 정할 수 있습니다. 댓글 하나가 화면을 덮게 만들 수도 있어요. 코드가 도는 것만큼 심각하진 않지만 우리가 의도한 것도 아닙니다.
우리가 Step 5에서 허용목록을 직접 적은 것이 두 문제를 다 피했습니다. img를 안 넣었으니 사라질 이미지가 없고, style을 안 넣었으니 남을 스타일도 없어요.
자주 나오는 실수
「둘 다 살균이니까 결과가 같겠지」 하고 한쪽만 재는 것. 표를 채우는 것이 이 과제의 전부입니다. 재보면 다르고, 안 재보면 같아 보여요.
빈 문자열을 「오류」로 읽는 것. 이미지가 사라진 것은 도구가 실패한 게 아니라 정상 동작이라서 더 위험해요. 실패하면 알아채는데 정상 동작이면 못 알아챕니다.
💡 튜터의 한마디 — 도구의 기본값은 그 도구를 만든 사람이 「대체로 이 정도면 되겠지」 하고 정한 값입니다. 우리 서비스를 보고 정한 게 아니에요. 기본값으로 시작하되 우리 화면에서 무엇이 사라지고 무엇이 남는지는 한 번 재보셔야 합니다.
🎯 [과제 4 예시답안] 우리 앱을 전수 점검하기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 위험한 API를 전수 검색했다 | 5 | 건수가 나온다 |
| 입력이 어디에 그려지는지 직접 셌다 | 8 | 파일과 줄이 적혀 있다 |
| 각각의 방법을 확인했다 | 7 | 중괄호인지 아닌지를 구분했다 |
| 속성도 봤다 | 5 | 태그 사이만 본 게 아니다 |
풀이 예시
먼저 위험한 API부터 셉니다.
grep -rn "dangerouslySetInnerHTML" app lib
app/components/CommentSection.tsx:18
한 건입니다. 오늘 우리가 만든 그 줄이고, 이미 살균을 거치고 있습니다.
다음으로 사용자 입력이 화면 어디에 그려지는지 셉니다.
| 위치 | 어떤 값 | 넣는 방법 | 안전한가 |
|---|---|---|---|
[username]/layout.tsx:16 |
주소의 사용자 이름 | @{username} |
안전 |
FeedList.tsx:28 |
게시물 내용 | {post.content} |
안전 |
FeedList.tsx:21 |
게시물 내용 (이미지 설명) | alt={post.content} |
안전 |
SignInFields.tsx:43 |
서버가 보낸 메시지 | {state.message} |
안전 |
SignUpFields.tsx:40 |
서버가 보낸 메시지 | {state.message} |
안전 |
CommentForm.tsx:30 |
서버가 보낸 메시지 | {state.message} |
안전 |
FollowButton.tsx:24 |
서버가 보낸 메시지 | {state.message} |
안전 |
CommentSection.tsx:18 |
댓글 내용 | dangerouslySetInnerHTML |
살균을 거침 |
여덟 군데 중 일곱이 중괄호이고, 나머지 하나가 오늘 고친 것입니다.
속성도 봐야 합니다
위 표에서 alt={post.content} 한 줄만 성격이 다릅니다. 태그 사이가 아니라 속성 값으로 들어가거든요.
이것도 중괄호라 안전합니다. React는 속성 값에 들어가는 것도 똑같이 글자로 취급하는데, 따옴표를 닫고 나가려는 시도가 안 통합니다.
다만 속성 중에 예외가 있다는 것은 기억해두세요. 과제 2에서 본 href처럼 값 자체가 코드가 될 수 있는 속성이 있습니다. 그런 것은 글자로 넣는 것만으로 부족해요.
자주 나오는 실수
grep 결과가 한 건이라 안심하고 끝내는 것. 지금 한 건인 것이지 앞으로도 한 건이라는 보장은 없는데, 이 과제의 목적은 숫자가 아니라 세는 방법을 익히는 거예요.
태그 사이만 세는 것. alt·title·placeholder처럼 속성으로 들어가는 것을 빠뜨리기 쉬우니까, 목록을 만들 때 속성도 같이 훑으세요.
💡 튜터의 한마디 — 오늘 우리 앱은 여덟 군데 중 일곱이 저절로 안전했는데, 그 일곱이 안전한 이유와 나머지 하나가 위험했던 이유가 같아요. 어떻게 넣었느냐입니다. 새 화면을 만들 때마다 이 질문 하나만 하시면 됩니다.
🤔 [생각해볼 주제 1] 마크다운이면 안전할까요
문제 상황 요약
댓글에 서식을 넣는 방법으로 마크다운을 도입하려고 합니다. 사용자는 **굵게**라고 쓰고, 변환기가 그것을 <b>굵게</b>로 바꿔 화면에 넣는데, 사용자가 태그를 직접 못 쓰니 오늘 문제가 안 생길 것처럼 보입니다.
튜터의 가이드 및 해설
먼저 변환기가 만든 결과가 어디로 가는지 보세요. 마크다운은 HTML을 만들고, 그 HTML은 결국 화면에 들어가야 하죠. 어떻게 들어갈까요? 오늘 배운 그 방법으로 들어갑니다. 마크다운을 쓴다고 dangerouslySetInnerHTML이 사라지는 게 아니에요. 오히려 그것을 쓰는 이유가 됩니다.
그다음 질문은 변환기가 태그를 통과시키느냐입니다. 많은 마크다운 변환기가 원문에 섞인 HTML 태그를 그대로 통과시키는 것을 기본 동작으로 삼습니다. 마크다운 문법 자체가 "HTML을 섞어 쓸 수 있다"를 전제로 만들어졌기 때문이에요. 그러니 사용자가 <script>를 그냥 적으면 변환기를 그대로 통과해 나올 수 있습니다.
세 번째로, 태그를 다 막는 변환기를 썼다고 해도 빠져나가는 길이 있습니다. 마크다운에서 주소를 받는 문법이 있죠. 링크와 이미지입니다.
[눌러보세요](javascript:여기에_코드)
이건 마크다운 문법 그대로예요. 변환기는 이걸 <a href="javascript:...">로 바꿉니다. 태그를 하나도 안 썼는데 결과는 과제 2에서 본 것과 같아집니다.
결론은 이렇습니다. 마크다운은 입력 문법을 제한하는 것이지 출력을 안전하게 만드는 게 아닙니다. 변환 뒤에 나온 HTML을 살균하는 단계는 그대로 필요해요. 순서는 마크다운 변환 → 살균 → 화면입니다.
🎯 면접관을 홀리는 핵심 멘트
"마크다운을 쓰면 안전하다는 말은 절반만 맞습니다. 입력 문법을 좁힌 것이지 출력이 안전해진 게 아니거든요. 변환기가 만들어낸 HTML도 결국 같은 자리로 들어가고, 링크 문법은 주소를 그대로 받습니다. 저는 변환 다음에 살균을 한 단계 더 두고, 그 순서를 팀에 문서로 남깁니다."
🤔 [생각해볼 주제 2] 우리가 만든 게 아닌 구멍
문제 상황 요약
오늘 고친 곳은 우리가 직접 쓴 코드였지만, 실무에서는 우리가 안 쓴 코드가 화면에 들어옵니다. 복사해 들여온 컴포넌트, 설치해서 쓰는 라이브러리, 남의 서버에서 받아오는 스크립트 같은 것들이요.
튜터의 가이드 및 해설
세 종류를 갈라서 봐야 합니다. 우리가 할 수 있는 일이 각각 다르거든요.
복사해 들여온 것. E-5에서 디자인 시스템 컴포넌트를 들여왔을 때, 우리는 그 파일들이 우리 저장소에 들어온다는 점을 계속 이야기했습니다. 그때는 소유의 장점으로 말했지만 오늘 기준으로 보면 읽을 수 있다는 뜻이에요. 실제로 E-6에서 우리는 들여온 파일을 열어 <div>를 <article>로 되돌리고 낭독기가 영어로 읽던 낱말을 우리말로 바꿨습니다. 같은 방식으로 위험한 줄이 있는지도 볼 수 있어요. 과제 4에서 한 검색이 그 파일들까지 훑습니다.
설치해서 쓰는 것. 이건 우리 저장소에 소스가 안 들어오는데, 그래도 할 수 있는 일이 있어요. 무엇을 설치했는지 목록이 있고, 그 목록이 늘어날 때 검토할 수 있고, 오늘 확인한 것처럼 서버에서만 도는 것과 브라우저로 가는 것을 갈라볼 수 있어요. 브라우저로 가는 목록이 짧을수록 걱정거리가 적습니다.
남의 서버에서 받아오는 것. 광고나 분석 도구가 여기 해당하는데, 이게 가장 어려워요. 우리가 검토한 코드와 사용자가 실제로 받는 코드가 다를 수 있거든요. 우리가 잘못한 게 없어도 그쪽이 바뀌면 우리 페이지에서 남의 코드가 돕니다.
여기서 오늘 배운 방식의 한계가 드러나는데, 살균은 우리가 데이터를 넣는 길목을 지키는 방법이에요. 남의 코드가 아예 다른 경로로 들어오는 것은 못 막습니다.
그래서 다음 시간에 방향을 바꿔봅니다. 데이터를 지키는 대신 브라우저에게 어떤 출처의 코드를 실행해도 되는지 미리 알려두는 방법이요. 그건 우리가 안 만든 구멍에도 걸립니다.
🎯 면접관을 홀리는 핵심 멘트
"제가 짠 코드를 지키는 것과 제 페이지를 지키는 것은 다른 일이라고 봅니다. 입력을 살균하는 건 앞엣것이고, 그것만으로는 제가 안 쓴 코드가 도는 걸 못 막습니다. 그래서 저는 두 겹으로 봅니다. 데이터가 들어오는 길목을 좁히는 것과, 브라우저에게 실행해도 되는 출처를 미리 알려두는 것이요."
🤔 [생각해볼 주제 3] 왜 사용자가 서식을 원할까요
문제 상황 요약
오늘 하루의 출발점은 "댓글에 줄바꿈이 안 돼요"라는 요청 하나였습니다. 우리는 HTML로 넣는 방법을 골랐고, 그 대가로 공격 가능성을 열었다가 다시 막았습니다.
튜터의 가이드 및 해설
먼저 다른 선택지를 살펴봅시다. 줄바꿈만 필요했다면 CSS 한 줄로 됩니다.
white-space: pre-wrap;
사용자가 친 엔터가 그대로 살아나는데, 댓글은 여전히 중괄호로 들어가니 오늘 문제가 아예 안 생겨요. 라이브러리도 안 들이고 허용목록도 안 짭니다.
그럼 우리가 잘못 고른 걸까요? 그건 요청을 정확히 들었느냐에 달렸는데, 요청에는 두 가지가 섞여 있었어요. 줄바꿈과 강조요. 줄바꿈만이면 CSS로 끝나고, 강조까지면 어떤 형태로든 서식을 표현할 방법이 필요합니다.
여기서 더 중요한 질문이 나옵니다. 강조가 왜 필요했을까요?
댓글에서 강조를 쓰고 싶다는 것은 대개 긴 글을 쓰고 있다는 뜻이라서, 진짜 요구는 "댓글에 굵게 기능"이 아니라 "댓글이 너무 길어지는데 읽기가 힘들다"일 수 있어요. 그 경우 답은 서식이 아니라 다른 것일 수도 있습니다. 답글 기능이라든지, 글자 수 제한이라든지요.
이 과목에서 반복해서 만난 태도가 여기서도 나옵니다. 먼저 재고 나서 정합니다. 요구사항도 마찬가지예요. 요청 문장을 그대로 구현하기 전에 그 뒤에 무엇이 있는지 한 번 물어보는 것이 비용을 크게 바꿉니다.
실제로 오늘 우리가 쓴 비용을 세어보세요. 라이브러리 하나, 파일 하나, 허용목록 결정 하나, 그리고 그 결정이 맞는지 계속 확인할 책임이요. CSS 한 줄과 비교하면 차이가 큽니다.
다만 오해하지 마세요. HTML로 넣는 선택이 틀렸다는 게 아닙니다. 그 선택에 따라오는 비용을 알고 고르는 것과 모르고 고르는 것이 다르다는 이야기예요. 오늘 여러분은 그 비용을 전부 봤습니다.
🎯 면접관을 홀리는 핵심 멘트
"저는 요구사항을 받으면 그대로 만들기 전에 무엇 때문에 필요한지를 먼저 묻습니다. 줄바꿈이 안 된다는 요청에 HTML 렌더링을 붙이면 라이브러리와 허용목록 관리가 따라오지만, CSS 한 줄로 끝나는 경우도 많거든요. 기능의 비용은 만드는 시간이 아니라 그 뒤로 계속 지켜야 하는 것에서 나온다고 봅니다."