지금까지 만든 에디터는 사용자가 쓴 HTML을 그대로 저장하고, 그걸 다시 화면에 뿌려요. 편하죠. 그런데 여기에 웹 보안에서 가장 흔하고 무서운 구멍이 숨어 있어요. 사용자가 글이 아니라 공격 코드를 심어 넣을 수 있거든요. 저는 이걸 가볍게 봤다가 테스트 중에 남의 글을 열자마자 경고창이 뜨는 걸 보고 등골이 서늘했어요. 오늘은 sanitize(위험 요소 정화)가 왜 선택이 아니라 필수인지, 그리고 어떻게 막는지 풀어볼게요.

21.1 sanitize가 왜 목숨 걸 일이죠?

핵심 위협의 이름은 XSS(Cross-Site Scripting, 크로스 사이트 스크립팅)예요. 공격자가 남의 브라우저에서 자기 자바스크립트를 실행시키는 공격이죠. 우리 에디터가 특히 위험한 이유는 저장형 XSS 때문이에요.


흐름은 이래요. 공격자가 글에 악성 코드를 심어 저장해요. 그 글을 다른 사용자가 열면, 그 사람의 브라우저에서 공격 코드가 그냥 실행돼요. 로그인 정보나 쿠키를 빼가거나, 그 사람 계정으로 글을 쓰고 돈을 보내는 짓까지 가능해요. 글 하나 저장했을 뿐인데 보는 사람마다 감염되는 거예요. 그래서 sanitize는 있으면 좋은 게 아니라 없으면 사고 나는 필수 방어예요.


여기서 오해 하나를 풀고 가요. "우리 사이트는 작아서 노릴 사람 없어요"라는 생각이요. XSS 공격은 사람이 손으로 노리는 게 아니라 자동으로 돌아다니는 봇이 찾아요. 입력창이 있으면 일단 공격 코드부터 넣어보는 식이라, 규모와 상관없이 구멍이 있으면 걸려요. 그러니 작든 크든 방어는 똑같이 해둬야 해요.

21.2 실제로 어떻게 뚫려요?

말로만 들으면 안 와닿으니 실제 공격을 볼게요. 가장 유명한 게 이미지 태그를 이용한 거예요. img는 평범한 태그 같지만 이렇게 쓰면 흉기가 돼요.


<img src="x">


src를 일부러 깨진 주소로 두면 그림 로딩이 실패해요. 그러면 onerror에 적힌 자바스크립트가 실행되죠. 여기선 쿠키를 경고창에 띄우지만, 진짜 공격은 그 쿠키를 몰래 공격자 서버로 전송해요. script 태그만 조심하면 된다고 생각했다면 오산이에요. 이렇게 이벤트 속성(onerror, onload, onclick 같은 on으로 시작하는 속성)만으로도 코드가 돌아요. 링크도 마찬가지예요.


<a href="javascript:steal()">눌러보세요</a>


href에 javascript:로 시작하는 주소를 넣으면, 사용자가 클릭하는 순간 그 코드가 실행돼요. 이렇게 공격 통로는 script 태그 하나가 아니라 이벤트 속성, 위험한 href 스킴 등 사방에 흩어져 있어요.

21.3 그럼 직접 필터링하면 안 되나요?

많은 사람이 처음엔 이렇게 생각해요. "script 글자를 찾아서 지우면 되잖아?" 위험한 걸 골라 지우는 방식, 이걸 블랙리스트(차단 목록)라고 해요. 그런데 이건 거의 반드시 뚫려요.


공격 방법이 끝이 없기 때문이에요. script를 지우게 만들면 scrscriptipt처럼 겹쳐 써서 지운 뒤에 다시 script가 되게 하거나, 대소문자를 섞거나, onerror 같은 다른 통로를 쓰거나, 인코딩으로 숨기죠. 공격자는 단 하나의 빈틈만 찾으면 되고, 우리는 모든 빈틈을 막아야 해요. 이 싸움은 애초에 기울어져 있어요. 그래서 위험한 걸 지우는 대신, 안전한 것만 남기는 반대 방향으로 가야 해요.

21.4 그럼 뭘 써야 해요?

정답은 검증된 라이브러리에 맡기는 거예요. 브라우저 환경에서 가장 널리 쓰이는 게 DOMPurify예요. 수많은 공격 사례로 단련된 도구라, 우리가 밤새 고민할 구멍을 이미 다 막아 뒀어요. 쓰는 법은 놀랄 만큼 간단해요.


const dirty = editor.innerHTML;
const clean = DOMPurify.sanitize(dirty);
save(clean);


DOMPurify.sanitize에 지저분한 HTML을 넣으면, 위험 요소를 다 걷어낸 안전한 HTML이 나와요. 앞에서 본 onerror가 붙은 img는 속성이 제거되고, javascript: 링크는 무력화되며, script 태그는 통째로 사라지죠. 이게 화이트리스트(허용 목록) 방식이에요. 아는 안전한 것만 통과시키고 나머지는 기본적으로 다 버리니까, 처음 보는 새로운 공격도 자동으로 막혀요.

21.5 태그랑 속성은 어떻게 허용해요?

기본값도 안전하지만, 우리 에디터에 맞게 허용 범위를 좁혀 두면 더 든든해요. 어떤 태그와 속성을 남길지 직접 정할 수 있어요.


const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ["p", "b", "i", "a", "ul", "li", "br", "img"],
ALLOWED_ATTR: ["href", "src", "alt"],
});


ALLOWED_TAGS에 적은 태그만 살아남고, 나머지는 벗겨져요. ALLOWED_ATTR에 적은 속성만 남으니, onerror나 onclick 같은 이벤트 속성은 목록에 없어서 자동으로 제거돼요. 허용 목록을 좁게 잡을수록 공격 표면이 줄어요. 에디터가 굵게, 링크, 이미지, 목록만 쓴다면 딱 그만큼만 열어두면 되는 거예요. 우리가 안 쓰는 태그는 애초에 문을 닫아 두는 게 이득이에요.


한 가지 더. 링크를 허용할 때 href는 열어두되 위험한 스킴은 걸러야 해요. DOMPurify는 기본으로 javascript: 같은 위험한 href를 막아주지만, 우리가 옵션을 잘못 열면 그 방어가 풀릴 수 있어요. 그래서 설정을 바꿀 땐 왜 여는지 분명할 때만 열고, 애매하면 기본값을 믿는 게 안전해요. 기본값은 수많은 사고를 거쳐 다듬어진 값이거든요.

21.6 화면에 넣기 전에 어디서 걸러요?

여기서 자주 하는 실수가 있어요. sanitize를 저장할 때만 하고 안심하는 거예요. 하지만 방어는 두 곳에서 해야 해요. 저장할 때 한 번, 그리고 화면에 뿌리기 직전 한 번이요.


const clean = DOMPurify.sanitize(post.body);
container.innerHTML = clean;


innerHTML로 HTML을 화면에 꽂는 순간이 가장 위험한 지점이에요. 여기에 정화 안 된 내용이 들어가면 그 자리에서 공격 코드가 실행되거든요. 그래서 innerHTML에 넣기 직전에도 반드시 통과시켜요. 저장 때 걸렀더라도, 옛날에 저장된 글이나 다른 경로로 들어온 데이터가 있을 수 있으니까요. 그리고 이 검사는 서버에서도 한 번 더 해야 완벽해요. 화면 코드는 우회당할 수 있지만, 서버를 반드시 거치는 저장 경로에서 거르면 어떤 통로로 들어와도 못 빠져나가요.

21.7 오늘 정리

사용자 HTML을 그대로 뿌리는 에디터는 저장형 XSS의 온상이었어요. 글 하나로 보는 사람마다 감염되니 sanitize는 필수였죠. 공격은 script 태그만이 아니라 onerror 같은 이벤트 속성, javascript: 링크 등 사방에 있어서, 위험한 걸 지우는 블랙리스트는 반드시 뚫렸어요. 그래서 안전한 것만 남기는 화이트리스트로 가야 했고, 브라우저에선 DOMPurify가 정답이었어요. ALLOWED_TAGSALLOWED_ATTR로 허용 범위를 좁히고, 저장할 때와 innerHTML에 넣기 직전 그리고 서버에서 걸러야 빈틈이 없었죠. 직접 막지 말고 검증된 도구에 맡기라는 게 오늘의 핵심이에요.