ReactRouter v7 でセキュリティ対策をしたら CSP(Content Security Policy)のインラインスクリプトでつまずいた

Pocket
LINEで送る

はじめに

この度ReactRouter v7を使って開発する機会があり、制作物をsecurityheaders.comというサイトでセキュリティを調査したところ判定は「D」でした。ちなみに×だったものは

  • Strict-Transport-Security
  • Content-Security-Policy
  • Referrer-Policy
  • Permissions-Policy

これをすべてokにし、評価を「A+」にした話です。

まずはExpressを用意する

公式の手順にてプロジェクトを作成するとpackage.jsonのstartスクリプトではreact-router-serveを使って起動させていることが分かります。

npx create-react-router@latest my-react-router-app

package.json
  "scripts": {
    "build": "react-router build",
    "dev": "react-router dev",
    "start": "react-router-serve ./build/server/index.js",
    ・・・

セキュリティ対策や他の機能を使いたい場合、自前でserverを作る必要があります。
このreact-router-serve自体はexpressを使っており、このソースコードを参考にexpressサーバーを作りました

import { createRequestHandler } from '@react-router/express'; ①
import express from 'express';
import * as build from './build/server/index.js';  ②

const app = express();

app.use(express.static('build/client'));  ③

app.all('*',createRequestHandler({build}));  ④

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`App listening on http://localhost:${port}`);
});

① createRequestHandlerでbuildしたファイルを配信できる。標準でインストールされています
② ビルド生成物のサーバーを指定
③ ビルド生成物のクライアント側の場所を指定
④ createRequestHandlerで②を配信

package.jsonのstartスクリプトに “express-start”: “node ./server.js”, 等追記して実行します

helmetを実装する

これらのセキュリティ対策をシンプルかつ効果的に実装できるライブラリにHelmetというものがありますのでこちらを利用します。
まずはインストール

npm install helmet

expressに実装

const app = express();

app.use(helmet())

たったこれだけで

  • Strict-Transport-Security
  • Content-Security-Policy
  • Referrer-Policy

はokとなり、評価は「A」となりました

Permissions-Policyが残ってる

こちらについてはヘッダーに直接書き込みます

app.use((req, res, next) => {
  res.setHeader(
    'Permissions-Policy',
    'geolocation=(self), camera=(), microphone=(), fullscreen=(self), payment=(), autoplay=(), picture-in-picture=()'
  );
  next();
});

これで「A+」になりました

インラインスクリプトが許可されない

ようやく対策が終わってやれやれと思っていると、chromeのconsoleにエラーが・・・

Refused to execute inline script because it violates the following Content Security Policy directive: “script-src ‘self'”. Either the ‘unsafe-inline’ keyword, a hash (‘sha256-dNy0y48tFd3Ueh+izbVssQPVSHSkZce1VpirT2wc1j4=’), or a nonce (‘nonce-…’) is required to enable inline execution.

unsafe-inlineが許可されていない、もしくはhashやnonceが無いのでインラインスクリプトが実行できません・・という内容

先ほどのhelmetでcontentSecurityPolicyのオプションにunsafe-inlineというキーワードを入れれば簡単に解決できるのですが、クロスサイトスクリプティング(XSS)攻撃の脆弱性があるとのこと。実際にどのように攻撃されどんな被害になるかはあまり想像できないのですが、危ないと言われたらやりたくない

もう一つの方法として、contentSecurityPolicyにnonce-****(****は毎回ランダム)を記載し、なおかつインラインスクリプトにも埋め込むという方法があります

<script nonce="****"> ・・・・ </script>

インラインスクリプトが上記の様になるように実装していきます

nonceの実装方法

express側

expressで実行することは

  • nonceを作成
  • contentSecurityPolicyに設定
  • Reactに渡す
//nonceをランダムで作成
app.use((req, res, next) => {
  res.locals.nonce = randomBytes(16).toString('hex');
  next();
});

//contentSecurityPolicyに設定
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: [
          "'self'",
          (req, res) => `'nonce-${res.locals.nonce}'`, // ここで nonce を記載
          "'strict-dynamic'", // 動的インポートされるスクリプトも許可
        ],
      },
    },
  })
); // セキュリティヘッダーを設定

//Reactに渡す
app.all(
  '*',
  createRequestHandler({
    build,
    getLoadContext: (req, res) => { //getLoadContextとして渡す
      return { nonce: res.locals.nonce };
    },
  })
);

React側

React側では下記ファイルにnonceの設定をしていきます。

  • root.tsx
  • entry.server.tsx

root.tsx
root.tsxで先ほどのexpressから渡されたnonceを受け取る


export async function loader({ request, context }: LoaderFunctionArgs) {
  const nonce = context.nonce;
  return { nonce };
}

Layoutに埋め込む

export function Layout({ children }: { children: React.ReactNode }) {
  const data = useLoaderData();
  const nonce = data.nonce; //ここでnonceを取り出す
  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration nonce={nonce} /> //nonceを埋め込む
        <Scripts nonce={nonce} /> //nonceを埋め込む
      </body>
    </html>
  );
}

entry.server.tsx
entry.server.tsxは最初からありません。下記コードをコンソールで実行すると作成されます

react-router reveal

次にexpressから渡されたnonceを受け取り、nonceを使う設定をします

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  routerContext: EntryContext,
  loadContext: AppLoadContext
) {
  return new Promise((resolve, reject) => {

//省略・・・

    const nonce = loadContext.nonce as string; //ここでnonceを取り出す

    const { pipe, abort } = renderToPipeableStream(
      <ServerRouter context={routerContext} url={request.url} nonce={nonce} />, //nonceを埋め込む
      {
        nonce: nonce, //nonceを埋め込む
        [readyOption]() {

//省略・・・

完成!

上記によりすべてのエラーがなくなりました!
今回は該当しませんでしたが、インラインcss(<div style=”*****”>こんなやつ)使う場合もnonce入れる必要があります

Pocket
LINEで送る

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

This site uses Akismet to reduce spam. Learn how your comment data is processed.