[마이 프로젝트] 블로그 HTML: 남의 글 그대로 붙이면 안 되는 이유

외부에서 받아온 글을 그대로 화면에 넣으면 생기는 문제를 나타낸 대표 이미지
남이 준 글자는 글자가 아닐 수 있습니다.

외부 API에서 받아온 제목을 블로그 화면에 그대로 넣으면, 그 안의 태그가 진짜 태그로 동작합니다. 남이 넣은 문장이 내 페이지에서 실행된다는 뜻입니다. 이 글은 짧은 함수 두 개로 이걸 막은 방법입니다.

글자가 글자로 안 들어간다

제가 만든 지원금 페이지는 공공데이터에서 받아온 제목과 설명을 화면에 뿌립니다. 이걸 innerHTML로 넣었습니다. 편하니까요.

문제는 innerHTML이 받은 문자열을 HTML로 해석한다는 점입니다. 제목에 <script>가 들어 있으면 그게 진짜 스크립트가 됩니다. 공공데이터에 그런 게 들어 있진 않겠지만, “들어 있지 않겠지”에 기대는 게 문제입니다.

다섯 글자만 바꾼다

막는 방법은 간단합니다. HTML에서 특별한 뜻을 가진 글자를 표시용 코드로 바꿔치기 하면 됩니다.

function escapeHTML(value){  return String(value == null ? '' : value)    .replace(/[&<>"']/g, function(c){      return {        '&':'&amp;', '<':'&lt;', '>':'&gt;',        '"':'&quot;', "'":'&#39;'      }[c];    });}

바꾸는 순서에 이유가 있습니다. &를 가장 먼저 처리해야 합니다. 나중에 하면 앞에서 만든 &lt;의 &까지 다시 바꿔서 &amp;lt;가 됩니다. 정규식에 &를 맨 앞에 둔 게 그래서입니다.

null 처리도 넣었습니다. 데이터가 비어 있을 때 화면에 “null”이라는 글자가 그대로 찍히는 걸 막습니다.

받아온 값을 화면에 넣기 전에 거르는 두 단계
글자와 주소는 거르는 방법이 다릅니다.

주소는 따로 봐야 한다

여기서 놓치기 쉬운 게 있습니다. 링크 주소는 escapeHTML로 막을 수 없습니다. javascript:로 시작하는 주소는 특수문자가 없어도 클릭하면 실행됩니다.

function safeURL(url){  var value = String(url||'');  return /^(https?:\/\/|\/)/i.test(value) ? value : '#';}

방식이 반대입니다. escapeHTML은 나쁜 것을 바꾸는 쪽이고, safeURL은 좋은 것만 통과시키는 쪽입니다. 나쁜 것 목록을 만들면 빠뜨리기 쉬우니, 주소처럼 형태가 정해진 값은 허용 목록이 낫습니다.

http://, https://, 그리고 /로 시작하는 내부 주소만 통과시키고 나머지는 #으로 바꿉니다. 링크가 죽는 게 실행되는 것보다 낫습니다.

외부 데이터를 화면에 넣기 전 확인할 세 가지를 정리한 설명 이미지
공공기관 데이터라도 예외로 두지 않았습니다.

공공데이터인데도 거르는 이유

“정부 API인데 그런 게 들어 있겠나”라고 생각했습니다. 실제로 안 들어 있을 겁니다. 그래도 거르기로 한 이유는 데이터가 어디서 오는지가 나중에 바뀔 수 있기 때문입니다.

지금은 공공데이터만 씁니다. 나중에 다른 출처를 붙이거나, 이 코드를 다른 곳에 복사해 쓸 수 있습니다. 그때 거르는 함수가 이미 들어 있는 것과 없는 것은 차이가 큽니다.

한계

이 두 함수로 모든 경우를 막지는 못합니다. 값을 태그 속성 안에 넣거나 스타일에 넣는 경우는 규칙이 다릅니다. 제 화면은 글자와 링크만 다뤄서 이 정도로 충분했지만, 더 복잡한 화면이라면 부족합니다.

그리고 제가 보안 전문가는 아닙니다. 실제 공격을 시도해 검증하지는 않았고, 알려진 기본 방식을 그대로 적용한 수준입니다.

공식 문서는 이 문제를 어떻게 설명하나

위에서 한 방식이 개인 경험에만 기대지 않도록, 브라우저 문서(MDN)와 웹 보안 지침(OWASP)이 같은 문제를 어떻게 다루는지 옮겨 둡니다.

MDN의 innerHTML 문서는 이 속성을 사용자가 준 안전하지 않은 문자열이 정리되지 않은 채 화면에 들어가는 대표적인 통로로 설명합니다. 문자열에 악성 내용이 들어 있을 수 있다면 직접 대입하는 것이 보안 위험이라고도 적혀 있습니다. 또 innerHTML로 넣은 <script> 요소는 실행되지 않지만, 그렇다고 안전한 것은 아니라고 합니다. 문서에 실린 예가 <img src='x' onerror='alert(1)'>입니다. 스크립트 태그가 아니어도 이미지 태그의 오류 처리 속성으로 코드가 실행될 수 있습니다. 위에서 <script>만 막는 것으로는 부족한 이유가 이것입니다.

그냥 글자라면 textContent가 더 간단하다

받은 값이 처음부터 순수한 글자여야 한다면 변환 함수를 쓰는 대신 textContent에 넣는 방법이 있습니다. MDN은 사용자가 준 내용이 일반 텍스트여야 한다는 것을 알고 있을 때 textContent를 쓰라고 하며, 이러면 HTML로 해석되지 않는다고 설명합니다. 같은 문서에서 innerHTML로 글자를 읽거나 쓰는 것은 권하지 않는다고 하고, 그 이유로 원시 HTML을 다루기 때문에 XSS 공격에 노출될 수 있다는 점과, 글자만 넣는 경우에도 HTML 파서를 거치므로 더 느리다는 점을 듭니다. OWASP 지침도 같은 방향입니다. 위험한 출력 지점인 innerHTML 대신 textContent나 value를 쓰도록 코드를 고치라고 안내합니다.

그렇다면 왜 이 글에서는 변환 함수를 만들었을까요. 목록 카드처럼 제목에 <strong> 같은 내 서식을 섞은 문자열을 한 번에 조립해서 넣는 화면에서는, 조각마다 textContent를 쓰기보다 문자열을 만들면서 값만 변환하는 쪽이 코드가 짧습니다. 화면 구조가 단순하고 요소를 하나씩 만들어 붙일 수 있다면 textContent가 더 안전하고 간단합니다. 둘은 경쟁 관계가 아니라 화면을 만드는 방식에 따라 고르는 것입니다.

글자를 바꿀 때 공식 지침의 대응표

OWASP 지침은 HTML 본문에 신뢰할 수 없는 값을 넣을 때 HTML 엔티티 인코딩을 쓰라고 합니다. 아래는 이 글의 escapeHTML이 바꾸는 다섯 글자와 지침의 대응입니다.

원래 글자바꾼 표시 코드왜 바꾸나
&&amp;다른 코드의 시작 글자로 읽히지 않게 합니다. 가장 먼저 바꿉니다.
<&lt;태그의 시작으로 읽히지 않게 합니다.
>&gt;태그의 끝으로 읽히지 않게 합니다.
"&quot;속성 값을 닫아 버리는 데 쓰이지 않게 합니다.
'&#x27; 또는 &#39;작은따옴표로 속성 값을 닫는 데 쓰이지 않게 합니다. OWASP는 &#x27; 표기를 예로 듭니다.

이 글의 함수는 작은따옴표를 &#39;로 바꿉니다. 표기만 다를 뿐 같은 글자를 가리키는 숫자 코드입니다.

링크 주소에 대한 공식 권고

OWASP는 신뢰할 수 없는 주소를 href나 src에 넣을 때의 방어로 http와 https 주소만 허용하는 방식을 안내합니다. 자바스크립트 프로토콜은 피하라는 말도 함께 있습니다. 이 글의 safeURL이 http://, https://, /로 시작하는 값만 통과시키는 것과 같은 방향입니다. 같은 문서는 프레임워크가 javascript: 주소를 막아 주더라도 그것이 서비스별 주소 검사를 대신하지는 않는다는 점도 짚습니다.

한눈에 보는 선택표

넣을 값권장하는 방법근거
순수한 글자(제목, 설명)textContent에 넣기MDN, OWASP
서식 문자열 안에 끼워 넣는 글자다섯 글자를 변환한 뒤 조립OWASP 출력 인코딩
링크 주소http·https·내부 경로만 허용OWASP 주소 항목
HTML 자체를 받아야 하는 경우신뢰할 수 있는 정리 단계를 거치기MDN Trusted Types 안내
사용자가 준 스크립트·스타일 내용textContent로도 해결되지 않음OWASP 경고

마지막 두 줄은 이 글의 두 함수 범위를 벗어납니다. MDN은 HTML을 받아야 할 때 입력이 변환 함수를 거치게 해서 위험한 마크업을 제거하는 방식(Trusted Types와 정리 라이브러리를 함께 쓰는 예)을 소개합니다. 이 글처럼 글자와 링크만 다루는 화면이 아니라면 그쪽 문서를 먼저 읽는 편이 안전합니다.

정리하면 방어는 두 겹으로 생각하면 쉽습니다. 첫째는 값이 들어가는 자리가 글자인지 링크인지 구분하는 것이고, 둘째는 자리에 맞는 방법을 쓰는 것입니다. 글자 자리에는 변환이나 textContent, 링크 자리에는 허용 목록을 씁니다. 두 자리를 같은 함수 하나로 처리하려 하면 빈틈이 생깁니다. 이 글에서 함수를 둘로 나눈 이유도 같습니다. 또 공식 문서들이 공통으로 강조하는 것은 출처를 믿느냐가 아니라 화면에 들어가는 지점에서 거른다는 원칙입니다. 공공기관 데이터처럼 믿을 만한 출처도 예외가 아닙니다.

적용 전 확인 순서

  1. 외부에서 오는 값이 어디에 들어가는지 글자, 링크, 속성, 스타일로 나눠 적습니다.
  2. 글자라면 textContent로 충분한지 먼저 봅니다.
  3. 문자열을 조립해 넣어야 한다면 값마다 escapeHTML을 거칩니다.
  4. 링크라면 safeURL을 거칩니다.
  5. 속성이나 스타일에 넣는 값이 있으면 이 글의 범위 밖이므로 공식 지침을 따로 읽습니다.

확인 자료

위 문서는 2026년 10월 기준 내용입니다. 정책과 권고는 바뀔 수 있으니 적용 전에 최신 문서를 확인하세요.