🉑

Next.jsでCSP対応をしました

2023/02/16に公開

CSPとは何か

Content Security Policy の略です。
適切なCSPディレクティブを定義することによって、読み込むコンテンツを制限できることで予期しない悪質なコンテンツを読み込まされることを防ぎます。

CSPを有効にするには2つの方法があります。

  • Content-Security-Policy HTTPヘッダーを返す
  • <meta> タグに埋め込む(弊社はこちらで有効にしています)
<meta
  http-equiv="Content-Security-Policy"
  content="default-src 'self'; img-src https://*; child-src 'none';" />

ポリシーの構文は

Content-Security-Policy: <policy-directive>; <policy-directive>

<policy-directive><directive> <value> <value> ...

の形で指定することができます。

適用例はMDNの例が分かりやすいので参考にしてみてください。

https://developer.mozilla.org/ja/docs/Web/HTTP/CSP#一般的な適用例:~:text=てください。-,一般的な適用例,-この章では

JavaScriptの実行を制御するためには以下のような方法があります。

nonce

nonce (number used once) 値をリクエストごとに生成して指定します。

Content-Security-Policy: script-src 'nonce-2726c7f26c'

スクリプトは次のように制御されます。

// nonce が一致するため実行されます。
<script src="/js/hoge.js" nonce="2726c7f26c"></script>

// nonce が一致しないため実行されません。
<script src="/js/hoge.js" nonce="12345"></script>

// nonce が一致しているので実行されます。
<script nonce="2726c7f26c">
  var inline = 1;
</script>

// nonce が一致しないため実行されません。
<script nonce="12345">
  var inline = 1;
</script>

hash

筆者の所感ではサブリソース完全性と似ているなと思いました。

このようなスクリプトを実行したい時

<script>hoge();</script>

スクリプトのハッシュ値をbase64でエンコードします。

sha256, sha384, sha512に対応しています。

echo -n 'hoge();' | openssl sha256 -binary | openssl base64 // 5KUX4DuAakiviv/EBxmQrE/U2E7Uvb+4BTX2tQ24DgU=

ハッシュアルゴリズム-<ハッシュ値> を指定することでブラウザ側で値の検証をし、実行を制御します。

Content-Security-Policy: script-src 'sha256-5KUX4DuAakiviv/EBxmQrE/U2E7Uvb+4BTX2tQ24DgU='

ドメインのホワイトリスト

以下のように設定します。

Content-Security-Policy: script-src 'self' example.com

スクリプトは次のように制御されます。

// example.com から読み込むので実行されます。
<script src="https://example.com/hoge.js"></script>

// 自分自身のドメインから読み込むので実行されます。
<script src="/js/hoge.js"></script>

// インラインスクリプトは実行されません。
<script>alert("hoge")</script>

pixivさんの記事がとても分かりやすいです。

https://inside.pixiv.blog/kobo/5137

やったこと

Strict CSP への対応

Vercelのプレビュー環境以外では Strict CSP という方法を適用しました。

https://csp.withgoogle.com/docs/strict-csp.html

ページのリクエストごとに新しい nonce を生成して、すべての <script> タグに nonce 属性を付与します。JavaScriptの実行は nonce によって制御されます。
strict-dynamicnonce によるルートスクリプトの実行許可を伝播させていきます。これにより、ルートスクリプトのセキュリティをキープしながら、フレームワークやライブラリによって動的に挿入されたスクリプトをCSPによってブロックしないようにすることができます。

Vercel のプレビュー環境への対応

弊社のとあるプロジェクトではVercelとGitHubを連携しており、ブランチへのPushをトリガーにプレビュー環境へデプロイする機能を動作検証で利用しています。
プレビュー環境はコメントを残せる機能があります。(すごく便利!)
そのため、いくつかスクリプトが埋め込まれますが、ポリシーに違反するスクリプトは実行できないため、1つずつエラーを見ていってドメインをホワイトリストに設定し許容するようにしました。

https://vercel.com/docs/concepts/deployments/comments

ちなみに、Vercelの公式ドキュメントに記載のある値も追加しています。
https://nextjs.org/docs/advanced-features/security-headers#:~:text=cross-origin' }-,Content-Security-Policy,-This header helps

実装

上記を踏まえ、以下のような実装になりました。

_document.tsx
import Document, {
  Html,
  Head,
  Main,
  NextScript,
  DocumentContext,
} from 'next/document';
import crypto from 'crypto';
import { v4 } from 'uuid';

type DocumentProps = {
  csp: string;
  nonce: string;
};

const isVercelPreview = process.env.VERCEL_ENV === 'preview';

const generateCsp = () => {
  const hash = crypto.createHash('sha256');
  hash.update(v4());
  const nonce = hash.digest('base64');

  /**
   * Strict SCPを適用します
   * @see https://csp.withgoogle.com/docs/strict-csp.html
   */
  const csp = `
  script-src 'unsafe-eval' 'unsafe-inline' https: http: ${
    isVercelPreview
      ? `https://vercel.live/ https://vercel.com`
      : `'nonce-${nonce}' 'strict-dynamic'`
  };
  object-src 'none';
  base-uri 'none';
  ${
    isVercelPreview
      ? `connect-src 'self' https://vercel.live/ https://vercel.com https://*.pusher.com/ wss://*.pusher.com/;
      img-src 'self' https://vercel.live/ https://*.vercel.com https://vercel.com https://*.pusher.com/ data: blob:;
      font-src 'self' https://*.vercel.com https://*.gstatic.com;
      frame-src 'self' https://vercel.live/ https://vercel.com;
      `
      : ''
  }
  `
    .replace(/\s{2,}/g, ' ')
    .trim();

  return {
    csp,
    nonce,
  };
};

export default class MyDocument extends Document<DocumentProps> {
  static async getInitialProps(ctx: DocumentContext) {
    const initialProps = await Document.getInitialProps(ctx);
    const { csp, nonce } = generateCsp();

    return {
      ...initialProps,
      csp,
      nonce,
    };
  }

  render() {
    const { csp, nonce } = this.props;
    return (
      <Html lang="ja">
        <Head nonce={nonce}>
          <meta httpEquiv="Content-Security-Policy" content={csp}></meta>
        </Head>
        <body>
          <Main />
          <NextScript nonce={nonce} />
        </body>
      </Html>
    );
  }
}

サブドメインのワイルドカードはメインのドメインをカバーしないため、 https://*.vercel.com https://vercel.com のようにそれぞれ指定しているところもあります。

https://csplite.com/csp89/#:~:text=3. subdomains in the host. For example script-src *.site.com will allow loading scripts from any subdomains of site.com (subdomains of any level)%2C but will not allow loading scripts from the very domain site.com.

懸念事項

弊社のとあるプロジェクトはSSR前提で設計しているため、リクエストの度に nonce を生成するという条件をクリアできるのですが、SSGではこれをクリアできません。

筆者は利用したことがないのですが、このようなライブラリもあるみたいです。
https://github.com/nibtime/next-safe-middleware

このライブラリは

  • getStaticProps -> hash方式
  • getServerSideProps -> nonce方式
  • getStaticProps + Revalidate -> hash方式

というようにレスポンスのHTTPヘッダーにCSPを設定してくれるようです。

参考

Next.jsでの実装に際して、以下の記事を大変参考にさせていただきました。
ありがとうございました!
https://kotamat.com/post/nextjs-strict-csp/

おわりに

XSSなどの攻撃に対して予期しないスクリプトをブロックできるCSPは強力な対策になります。
また、スクリプトの改竄でユーザーの情報が漏洩するケースが多く見られるため、SRIの対応をしたり、以下のような製品と組み合わせてさらにセキュリティを向上させていくのが良いかと思いました。
https://www.akamai.com/ja/products/page-integrity-manager
https://www.cloudflare.com/ja-jp/page-shield/

株式会社モニクル

Discussion