AI에게 HTML을 맡겼더니 보안 구멍이 생겼습니다 — AI보안 관점의 출력 처리
AI보안을 생각 안 하고 만들었다가 웹보안 구멍을 냈습니다. AI가 만든 HTML을 화면에 그대로 넣고 있었습니다.
AI가 악의적이어서가 아닙니다. 원본 자료에 섞여 들어온 것이 그대로 통과할 수 있는 구조였습니다.
1. AI 출력을 어디에 넣는지 전부 찾습니다 2. 서식이 필요 없으면 이스케이프합니다 3. 서식이 필요하면 허용 목록 방식으로 다시 만듭니다 4. 지우는 방식(블랙리스트)은 쓰지 않습니다
AI보안 증상 — 출력이 그대로 실행됩니다
증상 — AI가 돌려준 문자열을 화면에 넣었는데, 그 안에 실행되는 코드가 섞여 있으면 브라우저가 그대로 실행합니다.

// 나쁨: 받은 문자열을 그대로 화면에 넣는다
element.innerHTML = aiResult; // 안에 스크립트가 있으면 실행된다
원인 — AI 출력을 신뢰할 수 있는 값으로 다뤘습니다. 제가 만든 프롬프트로 제가 부른 것이니 안전하다고 생각했습니다.
실제로는 그렇지 않습니다. AI에게 준 재료가 외부에서 온 것이면, 그 안에 무엇이 들어 있을지 모릅니다. 상품 설명이든 웹페이지 내용이든 남이 쓴 것입니다.
AI는 그 재료를 읽고 결과를 만듭니다. 재료에 있던 것이 결과에 섞여 나올 수 있습니다. 저는 이 경로를 생각 못 했습니다.
웹보안 해결 — 이스케이프냐 정리냐
해결 — 둘 중 하나를 골라야 하는데, 성격이 다릅니다.

| 방식 | 무엇을 하나 | 언제 쓰나 |
|---|---|---|
| 이스케이프 | 태그를 글자로 바꿉니다 | 서식이 필요 없을 때 |
| 정리(허용 목록) | 허용한 태그만 남깁니다 | 굵게·목록 같은 서식이 필요할 때 |
서식이 필요 없으면 이스케이프가 훨씬 안전합니다. 고민할 것이 없습니다.
// 좋음: 글자로만 넣는다. 태그가 있어도 글자로 보인다
element.textContent = aiResult;
파이썬 쪽이라면 이렇습니다.
import html
safe = html.escape(ai_result) # < 는 < 가 된다
저는 대부분을 이스케이프로 처리했습니다. 서식이 정말 필요한 자리가 생각보다 적었습니다.
서식이 필요하면 — 허용 목록
굵게·목록·문단 같은 서식이 필요하면 허용한 태그만 남기고 다시 만듭니다.
ALLOWED = {"p", "br", "strong", "em", "ul", "ol", "li", "h2", "h3"}
ALLOWED_ATTRS = {} # 속성은 아예 안 받는다
def sanitize(html_str: str) -> str:
"""허용 목록에 있는 태그만 남겨서 **다시 만든다**."""
out = []
for node in parse(html_str):
if node.tag in ALLOWED:
out.append(build(node.tag, sanitize(node.children)))
else:
out.append(escape(node.text)) # 나머지는 글자로
return "".join(out)
핵심은 "지우기" 가 아니라 "다시 만들기" 입니다. 허용한 것만 골라 새로 조립합니다.
속성은 아예 안 받는 편이 안전했습니다. href 나 style 을 받으면 그 안에서 또 검사할 것이 생깁니다.
함정 — 정리기 자체가 뚫립니다
여기서 한 번 더 막혔습니다. 위험한 것을 지우는 방식으로 만들었더니 뚫렸습니다.

입력 : <scr<script>ipt>
1회 제거 후 : <script> ← 가운데를 지우니 양쪽이 붙었다
원인 — 지우기 방식은 한 번만 지웁니다. 남은 조각이 다시 온전한 태그가 될 수 있습니다.
해결 — 두 가지입니다.
- 허용 목록 방식으로 바꿉니다. 다시 만드는 방식이라 조각이 남지 않습니다
- 굳이 지우기를 쓴다면 바뀌지 않을 때까지 반복합니다
prev = None
while prev != text:
prev = text
text = remove_dangerous(text) # 더 안 바뀔 때까지
첫 번째를 권합니다. 반복 제거는 언제 끝나는지 보장하기 어렵고, 새로운 우회 방법이 계속 나옵니다.
어디에 넣는지 전부 찾습니다
주의 — 한 군데 고쳤다고 끝이 아닙니다. AI 출력이 화면에 닿는 지점을 전부 찾아야 합니다.
grep -rn "innerHTML\|outerHTML\|insertAdjacentHTML\|dangerouslySetInnerHTML" .
저는 이 검색으로 세 군데를 더 찾았습니다. 하나는 미리보기 화면이었고, 하나는 관리 화면의 목록이었습니다. 화면에 보여주기만 하는 곳도 실행됩니다.
| 확인할 곳 | 왜 |
|---|---|
| 본문 렌더링 | 가장 먼저 떠오르는 곳 |
| 미리보기 화면 | 잊기 쉽습니다 |
| 관리 화면 목록 | 제목·요약도 출력입니다 |
| 오류 메시지 | 받은 값을 그대로 보여주는 경우 |
마지막 줄이 의외였습니다. 오류 메시지에 입력값을 그대로 넣으면 그것도 통로가 됩니다.
확인 못 한 것
허용 목록 방식이 완전히 안전한지는 확인하지 못했습니다. 알려진 우회 방법을 몇 개 넣어 봤을 때는 막혔는데, 제가 모르는 방법이 있을 수 있습니다.
검증된 라이브러리를 쓰는 편이 직접 만든 것보다 안전할 텐데, 저는 필요한 태그가 적어서 직접 만들었습니다. 규모가 커지면 검증된 것으로 옮길 생각입니다.
판정 — 계속 쓴다
이스케이프가 기본값이어야 합니다. 서식이 필요한 자리가 생각보다 적었고, 필요 없으면 고민할 것이 없습니다.
허용 목록 방식이 지우기 방식보다 확실합니다. 지우기는 조각이 다시 조합되는 문제가 있습니다.
AI 출력을 신뢰하지 않는 것이 이 글에서 제일 중요합니다. 재료가 외부에서 왔으면 결과도 외부 입력으로 다뤄야 합니다.
직접 하실 분께
하나. AI 출력을 화면에 넣는 곳을 검색으로 전부 찾으세요. 미리보기와 관리 화면을 빠뜨리기 쉽습니다.
둘. 서식이 필요 없으면 이스케이프합니다. 가장 단순하고 확실합니다.
셋. 정리기를 만든다면 허용 목록 방식으로, 지우지 말고 다시 만듭니다.
넷. 속성은 아예 안 받는 것부터 시작합니다. 필요해지면 그때 하나씩 엽니다.
다음에는 검증된 정리 라이브러리로 옮겨서, 직접 만든 것과 막는 범위를 비교해보려고 합니다. 되면 이어서 적겠습니다.