웹 보안
브라우저와 서버 사이에서 자주 생기는 취약점과, 브라우저가 주는 방어 장치다.
목차 (8)
동일 출처 정책과 CORS: 브라우저가 그어 둔 기본 경계
브라우저는 한 번에 여러 사이트를 연다. 은행 탭과 아무 블로그 탭이 같은 브라우저에서 돌고, 두 탭 모두 같은 쿠키 저장소를 쓴다. 블로그의 스크립트가 은행 페이지를 마음대로 읽을 수 있다면 로그인은 아무 의미가 없다. 이를 막는 기본 규칙이 동일 출처 정책(same-origin policy)이다. 한 출처에서 온 스크립트는 다른 출처의 문서나 응답을 읽지 못한다.
MDN의 동일 출처 정책 문서가 정리하듯 출처(origin)는 스킴, 호스트, 포트 세 가지로 정한다. 셋이 모두 같아야 같은 출처이고, 경로나 쿼리는 상관없다. 주소를 비교하는 일은 사람이 눈으로 하면 틀리기 쉬워서, 서버 코드에서도 문자열을 직접 자르지 말고 URL 파서가 주는 origin 값을 비교하는 편이 안전하다.
막는 것은 주로 읽기다. 다른 출처로 이미지를 불러오거나 폼을 제출하는 일, 곧 요청을 보내는 일은 웹이 처음부터 허용해 왔다. 이 틈이 뒤에 나올 CSRF의 바탕이 된다. 동일 출처 정책은 요청을 막는 장치가 아니라 응답을 읽지 못하게 하는 장치라는 점을 기억해 두면 여러 방어가 왜 따로 필요한지 이해가 쉽다.
앱과 API를 다른 도메인에 두면 이 경계가 걸림돌이 된다. CORS(Cross-Origin Resource Sharing)는 서버가 "이 출처에는 내 응답을 읽게 해도 된다"고 브라우저에 알리는 방법이다. 브라우저는 요청에 Origin 헤더를 붙이고, 서버가 Access-Control-Allow-Origin 헤더로 그 출처를 허락해야 응답을 스크립트에 넘긴다. JSON을 보내는 PUT처럼 단순하지 않은 요청은 실제 요청 전에 OPTIONS 메서드로 먼저 묻는 사전 요청(preflight)을 보낸다. 자세한 규칙은 MDN의 CORS 안내에 있다.
- CORS는 경계를 여는 장치다. 보안 기능을 켜는 것이 아니라 동일 출처 정책에 예외를 만드는 것이므로, 허용 출처는 필요한 곳만 적는다.
- 요청에 담긴 Origin 값을 검사 없이 그대로 허용 출처로 되돌려 주면서 쿠키까지 허락하면, 어느 사이트든 로그인한 사용자의 응답을 읽을 수 있게 된다. 허용 출처는 정해 둔 목록과 비교한다.
- CORS는 브라우저가 지키는 약속이라 서버 간 통신이나 curl 같은 도구에는 걸리지 않는다. 서버의 인증과 권한 검사를 대신하지 못한다.
XSS: 남의 입력이 내 페이지의 코드가 될 때
XSS(Cross-Site Scripting, 사이트 간 스크립팅)는 사용자가 넣은 글이 화면에 글자로 찍히지 않고 HTML이나 스크립트로 해석되는 취약점이다. 끼어든 스크립트는 그 사이트의 출처로 실행되므로 동일 출처 정책이 지켜 주지 못한다. 사용자의 화면을 바꾸고, 그 사용자 이름으로 요청을 보내고, 스크립트가 읽을 수 있는 쿠키나 저장소 값을 가져갈 수 있다. 경로에 따라 셋으로 나눈다.
근본 대책은 출력 인코딩(output encoding)이다. 데이터를 HTML에 넣을 때 <를 <로 바꾸는 식으로, 그 자리의 문법에서 특별한 뜻을 갖는 글자를 무해한 표기로 바꾼다. 중요한 점은 넣는 자리(context)마다 규칙이 다르다는 것이다. HTML 본문, 속성 값, URL, 자바스크립트 문자열은 각각 위험한 글자가 다르다. OWASP XSS 방지 치트 시트는 자리별 인코딩을 기본으로 두고, 꼭 HTML을 받아야 할 때만 검증된 정화(sanitization) 라이브러리를 쓰라고 권한다.
// 위험: 문자열을 HTML로 해석해 문서에 넣는다
message.innerHTML = comment;
// 안전: 문자열을 글자로만 넣는다
message.textContent = comment;DOM 기반 XSS는 서버를 거치지 않으므로 서버 쪽 인코딩으로는 잡히지 않는다. 주소, postMessage, 저장소처럼 바깥에서 오는 값을 innerHTML, document.write, eval 같은 위험한 입구(sink)에 넘기지 않는 것이 핵심이다. OWASP DOM 기반 XSS 방지 치트 시트가 이런 입구를 정리해 두었다.
| 방어 | 하는 일 | 한계 |
|---|---|---|
| 자리별 출력 인코딩 | 데이터를 넣는 자리의 문법에 맞게 특수 글자를 바꾼다 | 자리를 잘못 고르면 소용없다. 손으로 하지 말고 템플릿에 맡긴다 |
| 자동 이스케이프 템플릿 | React·Vue·Astro 같은 틀은 기본으로 값을 글자로 넣는다 | HTML을 그대로 넣는 탈출구를 쓰면 그 자리는 다시 무방비다 |
| HTML 정화 | 꼭 서식을 받아야 할 때 허용한 태그·속성만 남긴다 | 직접 만든 정규식은 쉽게 뚫린다. 검증된 라이브러리를 쓴다 |
| CSP | 끼어든 스크립트가 실행되지 못하게 한다 | 두 번째 방어선이다. 인코딩 실수를 덮어 줄 뿐 대신하지 않는다 |
| HttpOnly 쿠키 | 세션 쿠키를 스크립트가 읽지 못하게 한다 | 쿠키 도난만 막는다. 스크립트는 여전히 사용자 이름으로 요청을 보낼 수 있다 |
CSP: 실행해도 되는 스크립트를 미리 적어 두기
CSP(Content Security Policy, 콘텐츠 보안 정책)는 서버가 응답 헤더로 "이 페이지는 이런 곳의 스크립트·스타일·이미지만 쓴다"고 브라우저에 알리는 장치다. 브라우저는 목록 밖의 자원을 불러오지 않고, 특히 HTML 속에 끼워 넣은 인라인 스크립트와 eval을 기본으로 막는다. XSS 취약점이 남아 있어도 끼어든 코드가 실행되지 못하게 하는 두 번째 방어선이다.
주소 목록 방식은 목록에 넣은 CDN에 누구나 올릴 수 있는 파일이 있으면 무너지기 쉽다. 그래서 MDN의 CSP 안내와 OWASP CSP 치트 시트는 요청마다 새로 만든 임의 값(nonce)이나 스크립트 내용의 해시를 붙인 스크립트만 실행하게 하는 엄격한 CSP(strict CSP)를 권한다. 공격자는 그 응답의 nonce를 미리 알 수 없으므로 끼워 넣은 스크립트가 실행되지 않는다.
- 처음에는
Content-Security-Policy-Report-Only로 막지 않고 위반만 보고받아, 정상 기능이 깨지지 않는지 본 뒤 적용한다. frame-ancestors로 내 페이지를 다른 사이트가 iframe으로 감싸지 못하게 해, 투명한 틀을 덮어 클릭을 가로채는 클릭재킹(clickjacking)도 함께 막는다.'unsafe-inline'을 넣으면 인라인 스크립트 차단이라는 가장 큰 이점이 사라진다.
CSRF: 브라우저가 쿠키를 알아서 붙이는 성질을 빌린다
CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조)는 사용자가 로그인해 둔 사이트로, 다른 사이트가 사용자 모르게 요청을 보내게 만드는 공격이다. 앞에서 본 대로 동일 출처 정책은 다른 출처로 폼을 보내는 일은 막지 않고, 브라우저는 목적지 사이트의 쿠키를 요청에 자동으로 붙여 왔다. 서버 입장에서는 사용자가 직접 누른 송금 버튼과 구별할 수 없다. 공격자는 응답을 읽지 못하지만, 송금이나 비밀번호 변경처럼 상태를 바꾸는 요청이라면 보내는 것만으로 충분하다.
사용자가 bank.example에 로그인하자, 은행이 세션 쿠키를 브라우저에 넣어 둔다.
OWASP CSRF 방지 치트 시트는 여러 겹을 함께 쓰라고 권한다.
| 방어 | 원리 |
|---|---|
| CSRF 토큰 | 서버가 폼마다 예측할 수 없는 값을 넣어 두고, 제출될 때 세션의 값과 맞는지 본다. 다른 사이트는 그 값을 읽을 수 없으니 맞출 수 없다 |
| SameSite 쿠키 | 다른 사이트에서 시작된 요청에는 쿠키를 붙이지 않게 한다. 아래 쿠키 절에서 자세히 본다 |
| 출처 확인 | Origin이나 Sec-Fetch-Site 요청 헤더로 요청이 어느 사이트에서 왔는지 보고 다른 사이트면 거부한다 |
| GET은 읽기만 | 상태를 바꾸는 일을 GET으로 받지 않는다. 링크 하나, 이미지 태그 하나로 일이 벌어지지 않게 한다 |
| 중요한 일은 다시 확인 | 송금·비밀번호 변경은 비밀번호나 두 번째 인증을 다시 받는다 |
쿠키 대신 Authorization 헤더에 토큰을 실어 보내는 API는 브라우저가 그 헤더를 자동으로 붙이지 않으므로 CSRF에 덜 노출된다. 대신 토큰을 스크립트가 읽는 곳에 두면 XSS 한 번에 토큰이 새어 나간다. 세션 방식의 장단점은 인증과 인가 노트의 세션과 토큰 절에서 다룬다.
쿠키 속성: 세션을 지키는 작은 깃발들
로그인 상태는 대개 세션 쿠키 하나에 달려 있다. 서버는 Set-Cookie 헤더에 속성을 붙여 이 쿠키가 언제, 어디로, 누구에게 보이는지 정한다. MDN의 Set-Cookie 문서와 쿠키 표준인 RFC 6265를 바탕으로 정리하면 다음과 같다.
| 속성 | 뜻 | 막는 것 |
|---|---|---|
Secure | HTTPS 연결에만 쿠키를 보낸다 | 암호화되지 않은 연결에서 쿠키를 엿보는 일. 네트워크 보안 노트의 HTTPS·HSTS와 짝을 이룬다 |
HttpOnly | 자바스크립트(document.cookie)가 읽지 못한다 | XSS가 일어났을 때 세션 쿠키를 빼 가는 일 |
SameSite | 다른 사이트에서 시작된 요청에 쿠키를 붙일지 정한다(Strict·Lax·None) | CSRF의 바탕인 쿠키 자동 첨부 |
Domain·Path | 쿠키를 보낼 호스트와 경로 범위 | Domain을 넓게 잡으면 하위 도메인 전부가 쿠키를 받는다. 필요 없으면 생략해 지금 호스트로 좁힌다 |
Max-Age·Expires | 쿠키의 수명 | 세션 쿠키는 짧게 둔다. 서버 쪽 세션 만료와 함께 써야 한다 |
__Host- 접두사 | 이름이 이렇게 시작하면 Secure 필수, Domain 금지, Path=/ 여야만 받아들인다 | 하위 도메인이 같은 이름의 쿠키를 덮어쓰는 일 |
여기서 사이트(site)는 출처보다 넓은 단위다. 대략 스킴과 등록 가능한 도메인(example.com 같은)이 같으면 같은 사이트로 보므로, app.example.com과 api.example.com은 다른 출처지만 같은 사이트다. SameSite는 이 사이트 단위로 판단한다.
SameSite를 적지 않은 쿠키를 어떻게 다룰지는 브라우저마다 다르고 바뀌어 왔다. 일부 브라우저는 생략을 Lax로 취급하지만, 모든 사용자의 브라우저가 그렇다고 기댈 수는 없다. 값은 늘 명시하고, SameSite는 CSRF 토큰과 출처 확인을 대신하지 않는 한 겹으로 본다.
SQL 인젝션: 데이터가 명령의 문법을 바꿀 때
SQL 인젝션(SQL injection)은 사용자 입력을 SQL 문장 문자열에 그대로 이어 붙일 때 생긴다. 입력 속 따옴표나 기호가 문자열을 닫고 문장의 구조를 바꾸면, 데이터여야 할 글자가 명령으로 읽힌다. 악의가 없어도 드러난다. 이름이 O'Brien인 사용자 한 명이 검색을 망가뜨린다면, 같은 자리에 누군가 의도적으로 문법을 넣을 수도 있다는 뜻이다. 결과는 남의 데이터 열람부터 수정, 삭제까지 이어질 수 있다.
해결책은 따옴표를 걸러 내는 것이 아니라 매개변수 쿼리(parameterized query, prepared statement)다. 문장의 틀과 값을 따로 보내면 DB는 값을 문법으로 해석할 기회가 없다. OWASP SQL 인젝션 방지 치트 시트도 이것을 첫째 방어로 둔다.
// 위험: 입력을 쿼리 문자열에 이어 붙인다
const rows = await db.query("SELECT * FROM users WHERE name = '" + name + "'");
// 안전: 틀과 값을 따로 보낸다($1은 node-postgres의 자리 표시자)
const rows = await db.query('SELECT * FROM users WHERE name = $1', [name]);- ORM과 쿼리 빌더는 대개 내부에서 매개변수를 쓰지만, 문자열을 그대로 넣는 탈출구(raw query)를 쓰는 순간 같은 문제가 돌아온다.
- 테이블 이름이나 정렬 열처럼 자리 표시자로 넣을 수 없는 부분은 정해 둔 목록에서 고르게 한다(허용 목록).
- 앱이 DB에 접속하는 계정에는 필요한 권한만 준다. 뚫려도 피해가 그 권한까지로 줄어든다(보안의 기초 노트의 최소 권한).
- 같은 원리가 셸 명령, LDAP 질의, 템플릿 엔진 등 문자열로 명령을 만드는 모든 곳에 해당한다. 묶어서 인젝션(injection)이라 부른다.
SSRF: 서버에게 대신 심부름을 시키는 공격
링크 미리보기, 주소로 이미지 가져오기, 웹훅처럼 서버가 사용자가 준 URL로 직접 요청을 보내는 기능이 있다. SSRF(Server-Side Request Forgery, 서버 측 요청 위조)는 이 기능에 내부 주소를 넣어, 바깥에서는 닿지 않는 곳으로 서버가 대신 요청하게 만드는 취약점이다. 서버는 방화벽 안쪽에 있으므로 내부 관리 페이지나, 클라우드 서버가 자기 설정과 임시 자격 증명을 받아 가는 메타데이터 주소에 닿을 수 있다.
OWASP SSRF 방지 치트 시트와 MDN의 SSRF 설명이 공통으로 권하는 방어는 다음과 같다.
- 허용 목록(allowlist): 가져올 수 있는 도메인과 스킴(https만)을 정해 두고 나머지는 거부한다. 막을 주소를 나열하는 차단 목록은 표기를 바꾼 주소에 쉽게 빠져나간다.
- 이름이 아니라 실제로 연결할 IP를 확인한다. 도메인이 내부 IP로 풀리거나, 리다이렉트로 내부 주소로 넘어가는 경우도 같은 검사를 거친다.
- 네트워크 단에서도 막는다. URL을 가져오는 일을 따로 떼어 내부망에 닿지 않는 곳에서 돌리고, 클라우드 메타데이터는 토큰을 요구하는 방식을 켠다.
- 가져온 응답을 그대로 사용자에게 돌려주지 않는다. 필요한 것(제목, 이미지 크기)만 골라 돌려준다.
OWASP Top 10: 웹 취약점을 한눈에
OWASP Top 10은 실제 앱에서 모은 데이터와 업계 설문으로 가장 중요한 웹 앱 보안 위험 열 가지를 꼽은 문서다. 순위표라기보다 무엇부터 점검할지 정하는 공통 어휘로 쓰인다. 2025년판은 다음과 같고, 이 노트에서 다룬 XSS와 SQL 인젝션은 인젝션(A05)에, CSRF와 SSRF는 접근 통제 실패(A01)에 들어 있다.
| 번호 | 이름 | 뜻과 예 |
|---|---|---|
| A01 | 접근 통제 실패(Broken Access Control) | 권한 없는 데이터·기능에 닿는다. 주소의 번호만 바꿔 남의 주문을 보는 IDOR, CSRF, SSRF. 인증과 인가 |
| A02 | 보안 설정 오류(Security Misconfiguration) | 기본 계정·비밀번호, 너무 자세한 오류 화면, 공개로 열린 저장소, 빠진 보안 헤더 |
| A03 | 소프트웨어 공급망 실패(Software Supply Chain Failures) | 오래되거나 오염된 의존성, 빌드·배포 과정의 침해. 시스템·소프트웨어 보안 |
| A04 | 암호 실패(Cryptographic Failures) | 평문 전송, 약하거나 직접 만든 알고리즘, 잘못 관리한 키. 암호 |
| A05 | 인젝션(Injection) | 입력이 명령·쿼리·스크립트의 문법이 된다. SQL 인젝션, XSS |
| A06 | 안전하지 않은 설계(Insecure Design) | 구현을 고쳐도 남는 설계의 빈틈. 위협 모델링이 없던 기능. 보안의 기초 |
| A07 | 인증 실패(Authentication Failures) | 무차별 대입을 막지 않는 로그인, 약한 비밀번호 저장, 허술한 세션 관리 |
| A08 | 소프트웨어·데이터 무결성 실패(Software or Data Integrity Failures) | 서명을 확인하지 않는 업데이트, 믿을 수 없는 데이터를 그대로 객체로 되살리기 |
| A09 | 보안 로깅·경보 실패(Security Logging and Alerting Failures) | 공격이 있어도 기록이 없거나 아무도 알림을 받지 못한다. 사람과 운영 |
| A10 | 예외 상황 처리 실패(Mishandling of Exceptional Conditions) | 오류가 나면 검사를 건너뛰고 통과시키는 식의 '열린 채 실패'(fail open), 오류 메시지로 내부 정보 노출 |
순위표 밖에서도 자주 묻는 취약점이 있다. 대부분 위 표의 인젝션이나 접근 통제 실패에 들어가지만, 이름이 따로 불리므로 대표 방어와 함께 적어 둔다.
| 이름 | 무엇이 문제인가 | 대표 방어 |
|---|---|---|
| 경로 조작(path traversal) | 파일 이름 입력에 ../ 같은 상위 경로를 넣어 정해진 폴더 밖의 파일을 읽거나 쓴다 | 사용자가 준 이름을 경로로 쓰지 않고 번호로 찾는다. 써야 하면 정규화한 뒤 허용 폴더 안인지 확인한다 |
| 파일 업로드 | 올린 파일이 서버에서 스크립트로 실행되거나, 다른 사용자에게 악성 파일로 내려간다 | OWASP 권고대로 확장자 허용 목록, Content-Type 불신, 서버가 지은 이름으로 바꾸기, 웹 루트 밖이나 다른 서버에 저장, 크기 제한, 악성코드 검사 |
| 열린 리다이렉트 | ?next= 같은 값으로 아무 주소로나 보내 주면, 진짜 도메인으로 시작하는 피싱 링크가 된다 | 돌아갈 주소를 허용 목록이나 사이트 안 상대 경로로만 받는다 |
| XXE(XML 외부 개체) | XML 파서가 문서 안의 외부 개체 선언을 따라가 서버 파일을 읽거나 내부 주소로 요청한다 | 파서 설정에서 DTD와 외부 개체를 끈다 |
화면 없이 API만 내놓는 서비스라면 OWASP API Security Top 10(2023년판)이 따로 있다. 1위가 객체 단위 인가 실패(Broken Object Level Authorization), 곧 인증과 인가 노트의 IDOR다. 흔한 오해 하나는 "Top 10을 다 막았으니 안전하다"는 생각이다. Top 10은 인식을 위한 문서이고, 개발할 때 점검 기준으로 쓰라고 만든 것은 OWASP ASVS(애플리케이션 보안 검증 표준, 2025년 5.0판)다.
표의 위험 대부분은 하나의 습관으로 모인다. 바깥에서 온 값은 데이터로만 다루고, 권한은 서버에서 매번 확인하고, 한 겹이 뚫려도 다음 겹이 버티게 한다. 이 사이트의 정적 페이지들도 같은 원칙에 따라, 파일 처리 도구는 입력을 서버로 보내지 않고 브라우저 안에서만 다룬다.
- OWASP Top 10:2025 — OWASP
- Same-origin policy — MDN
- Cross-Origin Resource Sharing (CORS) — MDN
- Cross Site Scripting Prevention Cheat Sheet — OWASP
- DOM based XSS Prevention Cheat Sheet — OWASP
- Content Security Policy (CSP) — MDN
- Content Security Policy Cheat Sheet — OWASP
- Cross-Site Request Forgery Prevention Cheat Sheet — OWASP
- Set-Cookie header — MDN
- RFC 6265: HTTP State Management Mechanism — IETF
- SQL Injection Prevention Cheat Sheet — OWASP
- Server Side Request Forgery Prevention Cheat Sheet — OWASP
- Server-side request forgery (SSRF) — MDN
- A01:2025 Broken Access Control — OWASP
- Path Traversal — OWASP
- File Upload Cheat Sheet — OWASP
- Unvalidated Redirects and Forwards Cheat Sheet — OWASP
- XML External Entity Prevention Cheat Sheet — OWASP
- API Security Top 10 — OWASP
- Application Security Verification Standard (ASVS) — OWASP