· Security· CSP· Next.js· Web

CSP 설정, 인프라에서 프론트엔드 앱으로 가져오기

인프라팀이 관리하던 CSP를 Next.js 앱으로 이관한 과정 — 이관 이유, 검증 방법, 그리고 nonce를 검토 후 보류한 이유

우리 서비스에는 이미 CSP가 걸려 있었다. 인프라팀이 웹서버 레이어에서 설정한 Content-Security-Policy 헤더. 문제는 이 정책을 수정할 일이 생길 때마다 타팀에 요청해야 한다는 것이었다.

서드파티 스크립트 하나 추가하려면 요청을 넣고, 확인을 기다리고, 반영 일정을 조율한다. 프론트엔드 배포는 이미 나갔는데 CSP 반영이 늦어서 스크립트가 차단된 적도 있고, 반대로 급하다고 하니 정책을 넓게 열어버려서 CSP가 형식적으로만 존재하게 된 부분도 있었다. 정책이 막는 대상은 프론트엔드 코드인데, 정책의 관리 주체는 인프라인 미스매치.

그래서 이번에 CSP 설정을 프론트엔드 애플리케이션으로 가져오는 작업을 했다. 그 과정을 정리해본다.

CSP가 막아주는 것

CSP는 브라우저에게 “이 페이지에서는 이런 출처의 리소스만 실행해라”라고 알려주는 HTTP 응답 헤더다. 핵심은 XSS 방어다. 공격자가 어떻게든 페이지에 스크립트를 주입해도, 허용 목록에 없는 출처면 브라우저가 실행 자체를 거부한다.

Content-Security-Policy: script-src 'self'; object-src 'none'

그런데 CSP는 어느 레이어에서든 걸 수 있다. CDN에서도, 웹서버(nginx)에서도, 애플리케이션에서도. 헤더만 내려가면 브라우저 입장에선 똑같다. 그래서 “어디에 두느냐”는 보안 문제가 아니라 운영 문제다.

왜 앱 레이어로 옮겼나

인프라 레이어에 두는 게 나은 경우도 있다. 여러 서비스에 일괄로 기본 정책을 깔아야 한다거나, 앱 배포와 무관하게 보안팀이 정책을 통제해야 하는 조직이라면 그쪽이 맞다.

우리는 반대였다. CSP 정책이 바뀌는 이유가 전부 프론트엔드 쪽 변경이었다.

  • 애널리틱스 태그 추가 → script-src에 도메인 추가
  • 이미지 CDN 교체 → img-src 수정
  • 외부 위젯 embed → frame-src 추가

즉, 정책 변경이 항상 프론트엔드 코드 변경과 한 몸으로 움직인다. 그렇다면 정책도 같은 저장소에서, 같은 PR에서, 같은 배포로 나가는 게 맞다. 앱으로 가져오면서 얻은 것들:

  • 코드리뷰에 정책 변경이 함께 보인다. 스크립트 추가하는 PR에 CSP 수정도 같이 들어오니, 리뷰어가 “이 도메인 왜 열어?”를 그 자리에서 물어볼 수 있다.
  • 배포/롤백이 한 단위다. 스크립트는 나갔는데 정책이 안 나가서 차단되는 사고가 구조적으로 사라진다.
  • 변경 이력이 git에 남는다. 언제 어떤 도메인이 왜 허용됐는지 커밋 로그로 추적된다.

이관 과정

1. 기존 정책을 코드로 옮기기

먼저 인프라에 설정된 정책을 그대로 next.config.js로 옮겼다. 정책을 바꾸는 것과 옮기는 것을 한 번에 하면 문제가 생겼을 때 원인을 못 찾는다. 이관 시점에는 정책 내용을 1도 바꾸지 않는 것이 원칙.

// next.config.js
const csp = [
  "default-src 'self'",
  "script-src 'self' https://analytics.example.com",
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: https:",
  "object-src 'none'",
  "base-uri 'self'",
  "frame-ancestors 'none'",
].join('; ');

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          { key: 'Content-Security-Policy', value: csp },
        ],
      },
    ];
  },
};

2. 헤더 중복 구간 조심하기

이관 중에는 인프라와 앱 양쪽에서 CSP가 내려가는 구간이 생긴다. CSP 헤더가 두 개면 브라우저는 둘 다 적용한다. 합집합이 아니라 교집합처럼 동작해서, 둘 중 더 엄격한 쪽에 걸리면 차단된다. 양쪽 정책이 동일하면 문제없지만, 조금이라도 다르면 여기서 사고가 난다.

그래서 앱 쪽 헤더를 먼저 배포하고, 실제 응답에서 두 헤더가 완전히 동일한지 확인한 후에 인프라 쪽 제거를 요청했다. 타팀 요청은 이 마지막 한 번이 끝이다.

# 응답 헤더 확인
curl -sI https://service.example.com | grep -i content-security-policy

3. 위반 리포트 수집 붙이기

앱에서 관리하게 됐으니, 이참에 위반 리포트 수집도 붙였다. 정책이 뭘 차단하고 있는지 이제 우리가 직접 볼 수 있다.

// app/api/csp-report/route.ts
export async function POST(request: Request) {
  const report = await request.json();
  console.warn('[CSP Violation]', JSON.stringify(report['csp-report']));
  return new Response(null, { status: 204 });
}

정책에 report-uri /api/csp-report를 추가하면 위반이 생길 때마다 이 엔드포인트로 리포트가 날아온다.

이관 후: 정책 조이기

옮기고 나니 그동안 급하게 열어뒀던 부분들이 눈에 들어왔다. 특히 script-src'unsafe-inline'. 주입된 스크립트도 실행되니까 XSS 방어라는 CSP 본연의 목적에는 구멍인 상태다.

바로 조이면 서비스가 깨질 수 있으니, 더 엄격한 정책은 Content-Security-Policy-Report-Only 헤더로 나란히 걸었다. 차단 헤더와 Report-Only 헤더는 동시에 쓸 수 있다. 검증 끝난 정책은 차단으로, 다음에 조일 정책은 Report-Only로 — 운영에 영향 없이 다음 단계를 테스트하는 구조다.

const headers = [
  { key: 'Content-Security-Policy', value: currentPolicy },       // 검증된 정책
  { key: 'Content-Security-Policy-Report-Only', value: nextPolicy }, // 조일 정책
];

Report-Only로 일주일 정도 돌리니 위반 리포트가 쌓였다.

  • Next.js가 hydration에 쓰는 인라인 스크립트
  • 스타일 라이브러리가 주입하는 인라인 스타일
  • 애널리틱스 태그가 동적으로 로드하는 2차 스크립트

이걸 다 확인하지 않고 바로 조였으면 한꺼번에 터졌을 것이다.

nonce 방식 검토

'unsafe-inline'을 없애는 정석은 nonce다. 요청마다 랜덤 값을 만들어 헤더와 스크립트 태그 양쪽에 심으면, nonce가 일치하는 스크립트만 실행된다. 공격자가 스크립트를 주입해도 nonce를 모르면 무용지물.

그래서 nonce 도입을 검토했다. 결론부터 말하면 이번에는 적용하지 않고 보류했는데, 검토하며 정리한 내용과 보류한 이유를 남겨둔다.

Next.js에서 nonce는 미들웨어에서 처리하는 게 일반적이다.

// middleware.ts
import { NextResponse } from 'next/server';
import 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' 'unsafe-inline'",
    "img-src 'self' data: https:",
    "object-src 'none'",
    "base-uri 'self'",
    "frame-ancestors 'none'",
  ].join('; ');

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-nonce', nonce);
  // Next.js는 요청 헤더에 실린 CSP에서 nonce를 읽는다. 이 줄이 없으면
  // 자기가 만드는 인라인 스크립트에 nonce가 안 붙는다.
  requestHeaders.set('Content-Security-Policy', csp);

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

요청 헤더에 CSP를 함께 실어 보내면 Next.js가 거기서 nonce를 읽어 자기가 생성하는 인라인 스크립트에 붙여준다. 응답에만 헤더를 걸면 이 부분이 동작하지 않아서 Next.js 자체 스크립트가 차단된다. 직접 넣는 스크립트 태그에는 x-nonce에서 값을 꺼내 붙인다.

import { headers } from 'next/headers';
import Script from 'next/script';

export default async function RootLayout({ children }) {
  const nonce = (await headers()).get('x-nonce') ?? undefined;

  return (
    <html lang="ko">
      <body>
        {children}
        <Script src="https://example.com/analytics.js" nonce={nonce} />
      </body>
    </html>
  );
}

'strict-dynamic'도 중요하다. nonce로 신뢰한 스크립트가 동적으로 로드하는 스크립트까지 자동으로 신뢰해줘서, 애널리틱스 태그가 불러오는 2차 스크립트의 도메인을 일일이 허용 목록에 추가하지 않아도 된다.

nonce를 보류한 이유

검토해보니 지금 당장 적용하기에는 걸리는 게 많았다.

정적 페이지와 nonce

이게 제일 컸다. nonce는 요청마다 달라야 해서 응답을 캐시하면 안 된다. 그런데 정적으로 생성되는 페이지는 빌드 시점에 HTML이 굳는다. nonce 방식을 쓰는 순간 해당 페이지들이 전부 동적 렌더링으로 바뀐다는 뜻이다. 캐시를 포기하면 서버 부하와 응답 속도에 바로 영향이 온다. 정적 페이지 비중이 큰 서비스라면 nonce 대신 해시('sha256-...') 방식을 쓰거나, 경로별로 정책을 다르게 가져가는 설계가 먼저 필요하다.

인라인 스타일

style-src에 nonce를 적용하려면 사용 중인 UI 라이브러리가 nonce를 지원해야 하는데, 우리가 쓰는 라이브러리는 런타임에 인라인 스타일을 그냥 주입한다. 라이브러리가 지원하지 않으면 방법이 없다. 적용한다 해도 style-src'unsafe-inline'으로 타협해야 하는 상황.

개발 환경

개발 모드에서는 Next.js가 eval을 쓴다. 프로덕션과 같은 정책을 걸면 로컬에서 개발이 안 되니, 환경별 정책 분기도 같이 챙겨야 한다.

const scriptSrc =
  process.env.NODE_ENV === 'development'
    ? `'self' 'unsafe-eval' 'nonce-${nonce}'`
    : `'self' 'nonce-${nonce}' 'strict-dynamic'`;

정리하면, nonce는 헤더 한 줄 추가가 아니라 렌더링 전략까지 건드리는 변경이다. 이관 작업과 한 번에 하기엔 리스크가 크고, 캐시 전략을 정리한 다음 단계로 미루는 게 맞다고 판단했다. 대신 object-src 'none', base-uri 'self', frame-ancestors 'none'처럼 렌더링에 영향 없는 지시어들은 Report-Only에서 위반이 없는 걸 확인하고 차단 모드로 조여뒀다.

정리

  • CSP를 어디에 두느냐는 보안 문제가 아니라 운영 문제다. 정책 변경이 프론트엔드 코드와 함께 움직인다면 앱 레이어가 맞다
  • 이관할 때는 정책 내용을 바꾸지 말고 위치만 옮긴다. 헤더가 중복되는 구간은 양쪽이 동일한지 확인하고 넘어간다
  • 정책 조이기는 Report-Only를 나란히 걸어 검증 후 전환한다
  • nonce + 'strict-dynamic'이 정석이지만 캐시·렌더링 전략에 미치는 영향까지 검토하고 도입 시점을 정한다 — 우리는 다음 단계로 보류했다

이관 자체는 헤더를 옮기는 단순한 작업이다. 하지만 정책의 주인이 바뀌는 순간, CSP는 “인프라에 있는 잘 모르는 설정”에서 “우리가 PR로 관리하는 코드”가 된다. 조이는 작업은 이제부터가 시작이다.