saip.learning notes

웹 보안 기본기: CSRF, XSS, 토큰과 비밀값

보안 · 마지막 수정 2026-10-24

이 블로그를 만들면서 실제로 막아야 했던 공격과 실수들을 정리한다. 로그인 방식은 Google 로그인 노트에 따로 있다.

한눈에 보기

위협 어떻게 당하나 막는 방법
CSRF 다른 사이트가 내 로그인 쿠키를 이용해 요청을 보냄 폼마다 CSRF 토큰
XSS 남이 쓴 글 속 스크립트가 내 브라우저에서 실행됨 출력 이스케이프, 위험한 URL 차단
CDN 오염 외부 스크립트 서버가 털려서 악성 코드가 섞임 SRI, CSP
토큰 유출 http 평문, 리다이렉트, 로그로 토큰이 샘 https 강제, 리다이렉트 거부
토큰 추측 비교에 걸리는 시간 차이로 한 글자씩 맞춤 상수 시간 비교
비밀값 공개 노트에 키나 비밀번호가 그대로 올라감 치환 + 정규식 검사 2중
관리자 사칭 확인 안 된 이메일로 관리자 행세 email_verified 확인

인증과 인가

  • 인증(Authentication): 당신은 누구인가. 로그인이 해결한다.
  • 인가(Authorization): 당신은 무엇을 할 수 있는가. 역할(ROLE_USER, ROLE_ADMIN)로 코드가 정한다.

Note

둘을 섞으면 "로그인만 하면 관리 화면이 열리는" 사고가 난다. 로그인 성공과 권한 부여는 별개의 단계로 생각한다.

세션 쿠키

로그인이 끝나면 서버가 세션을 만들고 브라우저에 쿠키(입장 팔찌)를 준다. 브라우저는 그 사이트로 가는 모든 요청에 쿠키를 자동으로 붙인다. 편리하지만 이 자동 첨부가 CSRF의 원인이다.

CSRF (사이트 간 요청 위조)

관리자가 로그인한 채로 악성 사이트에 들어갔다고 하자.

<!-- 악성 사이트에 숨겨진 폼 -->
<form action="https://내블로그/admin/notes/docker/delete" method="post"></form>
<script>document.forms[0].submit()</script>

브라우저는 내 블로그 쿠키를 자동으로 붙여 보내고, 서버는 관리자의 정상 요청으로 착각해 노트를 지운다.

sequenceDiagram
  participant A as 관리자 브라우저
  participant E as 악성 사이트
  participant S as 내 블로그
  A->>E: 페이지 방문
  E->>A: 숨겨진 폼 자동 전송
  A->>S: POST /delete + 세션 쿠키(자동)
  S-->>A: CSRF 토큰 없음 → 403

방어: 내 화면의 폼에만 매번 다른 비밀 번호표(CSRF 토큰)를 숨겨 두고, POST가 오면 번호표를 확인한다. 다른 사이트는 내 페이지 내용을 읽을 수 없어서 번호표를 알 수 없다.

  • Spring Security는 기본으로 CSRF 검사를 켠다. Thymeleaf의 th:action 폼에는 토큰이 자동으로 들어간다.
  • JS의 fetch로 보낼 때는 <meta> 태그에 넣어 둔 토큰을 꺼내 헤더에 붙인다.
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">

이미지 업로드처럼 fetch로 FormData를 보낼 때도 같은 헤더를 붙인다. 빠뜨리면 403이다.

테스트로 확인하기

spring-security-test의 csrf()를 빼고 보내면 403이 나와야 한다. "막혀야 할 것이 막히는지"를 테스트로 고정해 둔다.

mvc.perform(post("/admin/notes/docker/delete").with(oidcLogin().authorities(admin)))
    .andExpect(status().isForbidden());              // CSRF 토큰 없음
mvc.perform(post("/admin/notes/docker/delete").with(oidcLogin().authorities(admin)).with(csrf()))
    .andExpect(status().is3xxRedirection());        // 정상

Tip

쿠키가 아니라 Authorization: Bearer [TOKEN] 헤더로 인증하는 API는 CSRF와 무관하다. 브라우저가 이 헤더를 자동으로 붙이지 않기 때문이다. 그래서 /api/** 체인만 CSRF를 껐다.

XSS (크로스 사이트 스크립팅)

남이 쓴 글(댓글, 노트 본문)에 <script>가 들어 있고 그게 그대로 HTML로 출력되면, 그 페이지를 연 다른 사람의 브라우저에서 실행된다. 쿠키 탈취, 관리자 권한으로 요청 보내기 등이 가능해진다. DB에 저장됐다가 터지는 걸 저장형 XSS라고 한다.

방어: 출력할 때 이스케이프

상황 방법
댓글 같은 평문 Thymeleaf th:text: <를 &lt;로 바꿔 글자로 보여줌
마크다운 본문 렌더러에서 원본 HTML 이스케이프(escapeHtml(true)) 후 th:utext
링크 javascript:alert(1) 같은 URL 차단(sanitizeUrls(true))
Mermaid securityLevel: 'strict'

Warning

<script> 태그만 막으면 끝이 아니다. [클릭](javascript:...) 같은 링크 주소로도 스크립트가 실행된다. 실제로 이 블로그도 처음엔 이걸 놓쳤다가 테스트를 추가하고 막았다.

th:utext(이스케이프 안 함)는 이미 안전하게 만든 HTML에만 쓰고, 사람이 입력한 원문에는 절대 쓰지 않는다.

편집기 미리보기도 같은 렌더러로

관리자 편집기의 미리보기는 브라우저용 마크다운 라이브러리를 따로 쓰지 않고, 본문을 서버(POST /admin/preview)로 보내 공개 화면과 같은 렌더러가 만든 HTML을 받아 innerHTML에 넣는다.

  • 렌더러가 둘이면 "미리보기에서는 안전했는데 공개 화면에서는 아니다" 같은 차이가 생긴다. 하나면 이스케이프 규칙도 하나다.
  • innerHTML에는 서버가 이스케이프한 HTML만 들어간다. 사용자 입력을 직접 innerHTML에 넣지 않는다.
  • 입력이 0.4초 멈출 때만 요청하고(디바운스), 늦게 도착한 예전 응답은 버린다.

Note

이스케이프는 내가 쓴 HTML 주석에도 적용된다. 이미지 업로드 실패 사유를 <!-- -->로 본문에 남겼더니 공개 화면에 글자로 그대로 보였다(이미지 처리 노트 참고).

외부 스크립트: SRI와 CSP

CDN에서 받는 라이브러리(highlight.js 등)는 CDN 서버가 털리면 악성 코드로 바뀔 수 있다.

  • SRI(Subresource Integrity): 파일의 해시를 integrity 속성에 적어 두면, 내용이 한 글자라도 바뀐 파일은 브라우저가 실행하지 않는다.
<script src="https://cdnjs.cloudflare.com/.../highlight.min.js"
        integrity="sha384-..." crossorigin="anonymous"></script>
  • CSP(Content Security Policy): "이 페이지는 이 출처의 스크립트만 실행한다"는 응답 헤더. SRI를 걸 수 없는 경우(동적 ESM import)까지 범위를 제한한다. 공개 배포 전에 넣을 예정.

API 토큰 다루기

상수 시간 비교

a.equals(b)는 첫 글자가 다르면 바로 끝난다. 응답 시간 차이를 재면 한 글자씩 맞춰 나가는 공격(타이밍 공격)이 이론상 가능하다. 끝까지 비교하는 함수를 쓴다.

MessageDigest.isEqual(expected, given.getBytes(StandardCharsets.UTF_8));

토큰은 어디로 새나

경로 대응
http://로 보냄 네트워크에서 평문으로 보인다 → localhost가 아니면 https만 허용
리다이렉트 Python urllib은 리다이렉트를 따라갈 때 Authorization 헤더를 다른 호스트로도 넘긴다 → 리다이렉트 거부
대화, 로그, 파일 생성 명령에서 바로 환경변수에 넣고 화면에 출력하지 않는다
예측 가능한 난수 PowerShell Get-Random은 보안용이 아니다 → RandomNumberGenerator
$b = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b)
$t = [Convert]::ToBase64String($b)   # 출력하지 않고 바로 환경변수로

토큰이 비어 있으면?

설정값이 비어 있을 때 "빈 토큰 == 빈 헤더"로 통과되면 안 된다. 설정이 없으면 아무도 인증하지 않는다(fail closed)가 기본값이어야 한다.

비밀값이 공개 글에 올라가지 않게

노트는 검토 없이 바로 공개되므로 여러 겹으로 막는다.

  1. 글을 쓰는 Claude Code가 키, 비밀번호, 회사명을 [API_KEY], [PASSWORD], [COMPANY] 같은 자리표시자로 치환
  2. 보내기 직전 내 PC에서 정규식 + 회사 금지어 목록으로 검사
  3. 서버가 같은 정규식 파일로 다시 검사, 걸리면 저장하지 않고 422 + 줄 번호만 응답(값은 돌려주지 않는다)

Caution

정규식은 최신 키 형식을 따라가야 한다. 예전 OpenAI 키 패턴은 지금 기본 형식인 sk-proj-...를 못 잡았고, 공개되는 카테고리 이름은 검사 대상에서 빠져 있었다. 둘 다 코드 리뷰에서 발견해 고쳤다.

반대로 너무 넓게 잡으면 password: ${DB_PASSWORD} 같은 정상 교육용 설정 예시까지 막혀서 노트가 망가진다. ${...}, <...>, 함수 호출은 실제 값이 아니므로 통과시킨다.

점검 체크리스트

  • 상태를 바꾸는 요청은 모두 POST이고 CSRF 토큰을 검사하는가
  • 사람이 입력한 글을 th:utext로 출력하는 곳이 없는가
  • 외부 스크립트에 SRI가 있는가
  • 비밀값이 저장소, 로그, 응답 본문에 나가지 않는가
  • 설정이 비었을 때 기본 동작이 "거부"인가
  • 관리자 판정에 확인된 이메일만 쓰는가
  • 업로드 파일을 확장자가 아닌 내용(헤더)으로 검사하는가
  • CSRF 없는 POST가 403인지 테스트가 있는가

댓글 0

로그인하고 댓글 달기