본문으로 건너뛰기
CodeAuditAgent
전체 글

보안 헤더 완전 정리: CSP, HSTS 그리고 나머지

nonce 기반 CSP, HSTS와 preload, frame-ancestors, Referrer-Policy, COOP 실전 가이드.

· 7분 분량 · Lina Source LLC

보안 헤더는 서버가 브라우저에 주는 지시입니다. 이 스크립트만 실행하라, 나와는 HTTPS로만 통신하라, 다른 사이트가 이 페이지를 프레임에 넣지 못하게 하라 같은 것들이죠. 코드의 버그를 고쳐 주지는 않지만, 공격자가 그 버그로 할 수 있는 일을 제한합니다. 엄격한 콘텐츠 보안 정책 뒤에 있는 크로스 사이트 스크립팅 구멍은 같은 구멍이 정책 없이 노출된 경우보다 훨씬 작은 문제입니다.

이 헤더 대부분은 설정 한 줄이면 됩니다. 예외는 계획이 필요한 CSP입니다. 이 가이드는 각 헤더가 무엇을 하는지, 일반적인 웹 앱에 적절한 값은 무엇인지, 프로덕션을 망가뜨리지 않고 적용하는 방법을 다룹니다.

Content-Security-Policy

CSP는 스크립트와 스타일, 이미지, 프레임, 연결의 출처 중 무엇이 허용되는지 브라우저에 알려 줍니다. 주된 역할은 주입된 스크립트가 실행되지 못하게 막는 것입니다. 도메인 허용 목록이 당연한 접근처럼 보이지만, 우회 사례의 역사가 깁니다. 사용자 콘텐츠나 오래된 라이브러리 버전을 호스팅하는 허용된 CDN은 공격자가 제어하는 스크립트를 불러오는 데 쓰일 수 있습니다.

nonce와 strict-dynamic

실제로 통하는 방식은 nonce 기반 정책입니다. 서버는 응답마다 난수 값을 생성해 CSP 헤더에 넣고, 렌더링하는 각 script 태그에 nonce 속성으로 추가합니다. 주입된 script 태그는 nonce를 모르므로 차단됩니다. 여기에 'strict-dynamic'을 더하면 신뢰된 스크립트가 불러온 스크립트가 다시 다른 스크립트를 불러올 수 있어, 모든 도메인을 나열하지 않고도 번들러와 태그 매니저가 정상 동작합니다.

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic';
  style-src 'self' 'nonce-4AEemGb0xJptoIGFP3Nd';
  img-src 'self' data: https:;
  connect-src 'self';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self';
  upgrade-insecure-requests

이 헤더는 실제로는 한 줄로 전송되며, 여기서는 읽기 쉽도록 줄을 나눴습니다. 위 정책의 몇몇 지시어는 보이는 것보다 많은 일을 합니다. object-src 'none'은 레거시 플러그인 콘텐츠를 차단합니다. base-uri 'none'은 주입된 base 태그가 상대 경로 스크립트 URL을 다른 곳으로 돌리는 것을 막습니다. form-action 'self'는 주입된 폼이 자격 증명을 다른 곳으로 전송하지 못하게 합니다. nonce는 예측할 수 없어야 하고 응답마다 새로워야 하므로, 이를 사용하는 페이지는 정적 캐시에서 제공될 수 없습니다.

Report-Only로 적용하기

엄격한 CSP를 무턱대고 배포하면 무언가는 반드시 깨집니다. 분석 스니펫이나 인라인 이벤트 핸들러, 서드파티 위젯 같은 것들이죠. 정책을 먼저 Content-Security-Policy-Report-Only로 내보내세요. 브라우저는 아무것도 적용하지 않고 report-to나 report-uri 지시어에 지정된 엔드포인트로 모든 위반을 보고합니다. 한동안 보고를 모아 정당한 것은 고치거나 허용한 뒤, 헤더 이름을 바꿔 적용 모드로 전환하세요. 적용한 뒤에도 보고는 계속 켜 두세요. 새로운 위반은 회귀이거나 공격이기 때문입니다. 브라우저 확장 프로그램이 페이지에 자체 스크립트를 주입하므로 보고에 잡음이 섞일 것을 예상하고, 무엇을 허용할지 결정하기 전에 출처로 걸러 내세요.

Strict-Transport-Security

HSTS는 사용자가 http://를 입력하거나 오래된 링크를 클릭하더라도 정해진 기간 동안 해당 도메인에 HTTPS를 쓰라고 브라우저에 알려 줍니다. 네트워크 공격자가 첫 평문 HTTP 요청을 가로채 HTTPS 리디렉션을 벗겨 낼 수 있는 틈이 이로써 닫힙니다. 브라우저는 이 헤더가 HTTPS로 도착할 때만 인정하며, HSTS가 적용된 도메인에서는 인증서 오류를 무시하고 진행하는 것도 허용하지 않습니다. 그것이 바로 목적이지만, 인증서가 만료되면 위험 요소이기도 합니다.

하루 정도의 짧은 max-age로 시작해 아무것도 깨지지 않는지 확인한 뒤 1~2년으로 올리세요. includeSubDomains는 모든 서브도메인에 규칙을 확장하므로, 오래된 마케팅 사이트나 같은 도메인의 내부 도구를 포함해 전부가 유효한 HTTPS를 제공하는지 먼저 확인하세요.

preload 지시어는 브라우저 프리로드 목록 등록과 결합되어, 첫 방문부터 HTTPS를 쓰도록 도메인을 브라우저에 새겨 넣습니다. 최소 1년의 max-age와 includeSubDomains가 필요합니다. 되돌릴 수 없는 문이라고 생각하세요. 목록에서 제거하는 것은 가능하지만 사용자에게 반영되기까지 오래 걸리며, 그동안 HTTPS를 지원할 수 없는 서브도메인은 접근이 불가능해집니다.

한 줄짜리 헤더들

  • X-Content-Type-Options: nosniff. 브라우저가 선언된 것과 다른 콘텐츠 타입을 추측하지 못하게 해, 텍스트로 제공된 업로드 파일이 스크립트로 실행되는 것을 막습니다.
  • frame-ancestors(CSP 안)와 X-Frame-Options: DENY. 누가 여러분의 페이지를 프레임에 넣을 수 있는지 제어하며, 클릭재킹에 대한 방어입니다. 최신 브라우저는 둘 다 설정되어 있으면 frame-ancestors를 쓰고 X-Frame-Options를 무시하므로, 오래된 클라이언트를 위해 X-Frame-Options도 유지하세요. 자체 페이지를 프레임에 넣는다면 'self'나 SAMEORIGIN을 쓰세요.
  • Referrer-Policy: strict-origin-when-cross-origin. 다른 사이트에 전체 경로와 쿼리 문자열이 아니라 출처만 보냅니다. URL에 담긴 토큰과 ID가 서드파티로 새는 것을 막아 줍니다. 특히 민감한 페이지에는 no-referrer를 쓰세요.
  • Permissions-Policy. camera=(), microphone=(), geolocation=(), payment=()처럼 쓰지 않는 브라우저 기능을 꺼서, 주입되거나 임베드된 코드가 요청할 수 없게 합니다.
  • Cross-Origin-Opener-Policy: same-origin. 페이지를 자체 브라우징 컨텍스트 그룹에 두어, 다른 사이트가 연 창이 그 참조를 유지하지 못하게 합니다. OAuth나 결제 팝업에 의존한다면 same-origin-allow-popups를 쓰세요.
  • Cross-Origin-Resource-Policy: same-origin 또는 same-site. 다른 출처가 여러분의 응답을 이미지나 스크립트 같은 하위 리소스로 불러오지 못하게 브라우저에 알립니다. 형제 서브도메인에서 에셋을 제공한다면 same-site를, 다른 곳에 임베드되도록 의도된 에셋에는 cross-origin을 쓰세요.

X-XSS-Protection은 빼도 됩니다. 이 헤더가 제어하던 필터는 최신 브라우저에서 제거되었고, CSP가 그 대체재입니다. 스캐너가 굳이 요구한다면 0으로 설정하세요. 마찬가지로 공격자에게 정보만 더해 주는 헤더는 피하세요. X-Powered-By와 상세한 Server 배너는 프레임워크와 버전을 공짜로 알려 줍니다. Next.js에서는 설정에 poweredByHeader: false를 넣어 앞의 것을 제거할 수 있습니다.

Next.js 설정 예시

정적 헤더는 next.config.ts에 두고, headers() 함수가 모든 라우트에 적용하게 합니다. 아래 값들은 자신을 프레임에 넣지 않고 카메라나 위치를 사용하지 않으며 자체 에셋을 제공하는 앱에 적절한 기본값입니다. 그대로 복사하기보다 여러분의 앱이 실제로 하는 일에 맞춰 각 항목을 조정하세요.

// next.config.ts
import type { NextConfig } from "next";

const securityHeaders = [
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains",
  },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "X-Frame-Options", value: "DENY" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  {
    key: "Permissions-Policy",
    value: "camera=(), microphone=(), geolocation=(), payment=()",
  },
  { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
  { key: "Cross-Origin-Resource-Policy", value: "same-origin" },
];

const nextConfig: NextConfig = {
  async headers() {
    return [{ source: "/(.*)", headers: securityHeaders }];
  },
};

export default nextConfig;

CSP는 요청마다 새 nonce가 필요하므로 미들웨어에서 설정합니다. Next.js는 요청의 Content-Security-Policy 헤더에서 nonce를 읽어 렌더링 중 프레임워크 자체 스크립트에 적용합니다. 직접 만든 script 태그를 위해서는 서버 컴포넌트에서 headers().get("x-nonce")로 읽을 수 있습니다.

// middleware.ts (Next.js 16에서는 파일이 proxy.ts, 함수가 proxy입니다)
import { NextResponse, type NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
    "upgrade-insecure-requests",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", csp);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", csp);
  return response;
}

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

nonce로 렌더링되는 페이지는 동적으로 렌더링되어야 합니다. 정적 페이지라면 모든 방문자에게 같은 nonce를 재사용하게 되기 때문입니다. 적용 과정에서는 두 곳의 헤더 이름을 Content-Security-Policy-Report-Only로 바꾸고 보고 엔드포인트를 추가하세요. 개발 환경에서는 React의 디버깅 기능을 위해 script-src에 'unsafe-eval'이 필요할 수 있는데, NODE_ENV가 development일 때만 추가하세요.

확인하는 방법

  • 홈페이지와 동적 페이지, API 라우트, 정적 에셋에 대해 curl -sI https://your-domain.example/을 실행하고 각각의 헤더를 읽어 보세요. HTML 페이지에만 설정된 헤더는 흔한 허점입니다.
  • 브라우저 개발자 도구를 여세요. CSP 위반은 지시어와 차단된 리소스와 함께 콘솔에 나타나고, 네트워크 탭에서는 실제로 전송된 헤더를 볼 수 있습니다.
  • 오리진뿐 아니라 CDN이나 리버스 프록시 뒤에서도 확인하세요. 프록시가 헤더를 제거하거나 중복시키거나 덮어쓰는 경우가 있고, 충돌하는 두 CSP 헤더는 둘 다 적용되어 실제 정책이 어느 쪽보다도 엄격해집니다.
  • 빠른 2차 의견을 얻으려면 Mozilla HTTP Observatory 같은 공개 검사 도구를, 약한 CSP 지시어를 찾으려면 Google의 CSP Evaluator를 사용하세요.
  • 주요 라우트에 요청을 보내 헤더가 있는지 검증하는 테스트를 CI에 추가해, 설정 리팩터링이 헤더를 조용히 빠뜨리지 못하게 하세요.

헤더는 설정이고, 그래서 정확히 어긋나기 쉽습니다. 자체 응답을 만드는 새 라우트 핸들러, 기본값을 덮어쓰는 프록시 규칙, 릴리스를 막지 않으려고 'unsafe-inline'으로 느슨해진 CSP 같은 식으로 말이죠. 헤더도 코드처럼 리뷰하세요. CodeAuditAgent는 공개 저장소나 붙여넣은 스니펫의 설정을 나머지 코드와 함께 읽고, 누락되거나 약화된 헤더를 심각도와 인용한 설정, 제안 수정과 함께 보고합니다.

합리적인 작업 순서는 이렇습니다. 오늘은 한 줄짜리 헤더들, 이번 주에는 짧은 max-age의 HSTS, 그리고 보고를 수집할 수 있게 되는 대로 Report-Only 모드의 nonce 기반 CSP입니다.