はじめに
この度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入れる必要があります