Velog
굿바이 innerHTML, 반가워 setHTML: Firefox 148에서 한층 강화된 XSS 방어
Firefox 148이 표준 Sanitizer API와 Element.setHTML()을 최초 탑재했다. innerHTML 대입을 setHTML()로 바꾸는 최소한의 수정만으로 XSS를 기본 안전으로 끌어올릴 수 있다.
핵심 요약
Sanitizer API 는 신뢰할 수 없는 HTML 을 DOM 에 넣기 전에 정제하는 표준이고, Element.setHTML() 은 이 정제 단계를 삽입 API 내부에 엮어 safety by default 를 보장한다. 기본 설정이 지나치게 엄격할 때는 SanitizerConfig 로 허용 태그/속성을 직접 정의하는 커스텀 설정을 쓸 수 있다. Trusted Types 와 결합하면 require-trusted-types-for 'script' 지시어로 innerHTML 같은 raw sink 자체를 차단할 수 있어, 복잡한 CSP 설계 없이도 XSS 재발을 구조적으로 막는다. 핵심은 '라이브러리 호출 기반' → 'API 내장 강제' 로의 패러다임 전환이다.
이 글은 **'XSS는 CSP로 막는다'**는 2010년대의 방어 모델에서, **'innerHTML 대입 지점(sink)을 setHTML()로 대체한다'**는 2020년대 모델로 갈아타는 전환점으로 읽는다. 정책 레이어(CSP) → DOM sink 레이어(Sanitizer) → 강제 레이어(Trusted Types) 의 3단 방어를 각각 어떤 문제를 푸는 도구인지 구분하는 프레임이 핵심이다.
XSS는 10년 넘게 CWE 상위 3개에 고정된 취약점이지만, 대응 수단은 실무 도입 비용이 컸다.
Content-Security-Policy: 전사 재설계 필요, 인라인 스크립트/핸들러와 충돌DOMPurify: 라이브러리 호출을 잊으면 그 순간 뚫림Sanitizer+setHTML(): 대입 라인만 바꾸면 되는 점진적 접근
Firefox 148이 표준 Sanitizer 를 최초 탑재하면서 Chromium/Safari 도 따라올 것이 거의 확실하고, 면접에서 '왜 지금 setHTML 인가' 를 설명할 수 있어야 최신 보안 감각을 보여줄 수 있다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
기본 안전 vs 사후 정제
innerHTML 은 정제와 삽입이 분리되어 있어 개발자가 sanitize 호출을 한 번만 빠뜨려도 뚫린다. setHTML() 은 정제를 삽입 API 내부로 끌어들여 '잊을 수 있는 단계'를 구조적으로 제거한다.
// 위험: sanitize 호출을 잊으면 바로 XSS
el.innerHTML = userInput;
el.innerHTML = DOMPurify.sanitize(userInput); // 매번 기억해야 함
// 안전: 정제가 API 에 내장
el.setHTML(userInput);
보안은 '누가 실수하지 않는가' 가 아니라 '실수해도 안전한가' 로 설계해야 한다.
'DOMPurify 쓰면 되는 거 아니냐'로 끝내는 답변. 라이브러리는 호출을 잊으면 그만이지만, setHTML 은 API 자체가 정제를 강제하므로 본질이 다르다.
CSP와의 역할 분담
세 레이어는 푸는 문제가 다르고, 함께 쓸 때 효과가 극대화된다.
| 레이어 | 대상 | 역할 |
|---|---|---|
CSP | 리소스 로드/실행 | 어떤 스크립트/도메인을 허용할지 정책 |
Sanitizer | HTML 콘텐츠 | DOM 에 들어가기 전 태그/속성 정제 |
Trusted Types | DOM sink | innerHTML 등 raw sink 강제 차단 |
setHTML 도입 후 Trusted Types 를 켜면 raw innerHTML 경로까지 전면 차단되므로, 복잡한 CSP 튜닝 없이도 XSS 재발을 막을 수 있다.
CSP 와 Sanitizer 를 같은 레이어로 묶어 '중복 아니냐' 고 생각하는 것. 둘은 방어 레이어가 다르다.
점진적 마이그레이션 경로
레거시 전체 재작성 없이 innerHTML = x 라인을 element.setHTML(x) 로 기계적으로 치환하는 것만으로 대부분의 XSS 벡터가 닫힌다. 브라우저 지원이 부족한 환경은 feature detection 으로 fallback 을 둔다.
if ('setHTML' in Element.prototype) {
el.setHTML(html);
} else {
el.innerHTML = DOMPurify.sanitize(html);
}
리치텍스트 에디터처럼 기본 설정이 깨는 경우는 SanitizerConfig 로 허용 목록을 정의한다.
브라우저 지원이 부족하다며 도입 자체를 전부 미루는 것. 지원 체크 후 fallback 을 두는 단계적 도입이 현실적이다.
읽는 순서
- 1이론
MDN 의
Sanitizer명세에서 '기본 설정' 과 '커스텀 설정(SanitizerConfig)' 섹션, 그리고Trusted Types의require-trusted-types-for지시어를 읽고 세 레이어(CSP/Sanitizer/Trusted Types) 의 역할을 한 줄씩 정리하세요. - 2구현
기존 프로젝트에서
innerHTML을grep으로 찾아내고, 그중 하나를element.setHTML()로 교체해 Firefox 148 이상에서 동작을 확인하세요. feature detection +DOMPurifyfallback 도 같이 붙여보세요. - 3실무
가장 위험한 사용자 입력 경로(리뷰, 댓글, 리치텍스트) 를 목록화하고,
Trusted TypesReport-Only 정책을 켠 뒤 남는 raw sink 를 리포트로 수집해 마이그레이션 PR 순서를 설계해 보세요. - 4설명
팀에 '
SanitizervsDOMPurifyvsCSPvsTrusted Types' 비교표를 한 장으로 공유하고, 왜setHTML마이그레이션을 지금 시작해야 하는지(브라우저 표준화 흐름 + 도입 비용) 설득 논리를 만들어 보세요.
면접 연결 질문
[감점 답변] '다 안전하게 처리한다' 식으로 뭉뚱그리면 감점. [좋은 답변] 레이어를 구분한다.
- 이스케이프: 문자열 레벨 방어 (
<치환) CSP: 실행 정책 (리소스 허용 목록)DOMPurify: 라이브러리 호출 기반 정제 — 호출 누락 시 뚫림setHTML: API 내장 정제 — 잊어도 안전
조합: setHTML + Trusted Types 로 sink 를 강제하고, CSP 로 실행을 제한하는 defense in depth.
[감점 답변] '그냥 일괄 치환하면 된다' 는 현실성 감점. [좋은 답변] 우선순위를 정한다.
- 가장 위험한 입력 경로 부터: 사용자 작성 HTML, 외부 피드, 댓글/리뷰
- feature detection +
DOMPurifyfallback 으로 안전망 - 리치텍스트/에디터는
SanitizerConfig로 허용 목록 커스텀 Trusted Types정책을 Report-Only 로 먼저 켜서 잔여innerHTML을 감지- 감지 끝나면 Enforce 모드로 승격
[감점 답변] 둘을 동의어처럼 말하면 감점. [좋은 답변] 레이어 차이를 명확히:
Sanitizer= 콘텐츠 정제 (무엇이 들어갈 수 있는가)Trusted Types= 정제된 값만 sink 에 들어가도록 강제하는 정책 (어디에 들어갈 수 있는가)
Sanitizer 만 쓰면 여전히 누군가 raw innerHTML 을 쓸 여지가 남는다. Trusted Types 를 켜면 require-trusted-types-for 'script' 로 그 경로 자체를 CSP 수준에서 차단한다. 둘을 결합해야 '실수로 raw innerHTML' 까지 막힌다.
자기 점검
'API 가 더 짧아졌다' 는 식의 표면 설명. 핵심은 '정제를 호출자에게 맡기지 않는다' 는 원칙 전환이다.
sanitizer 를 꺼버리고 innerHTML 로 회귀하는 것. 필요한 태그/속성만 허용 목록 에 추가하는 것이 정답이다.
CSP 만 완벽히 걸면 충분하다는 생각. CSP 는 실행 정책이라 DOM 에 삽입된 HTML 자체의 위험 요소(이벤트 핸들러, javascript: URL)를 1차 차단해주지 못하는 경우가 많다.