React Router v7で、CSRF対策を行う
React〜BFFを触り出してから、常々フロントとバックエンドの連携が必須となるCSRF対策のやりづらさを感じていたのですが、Remix〜React Router v7の導入以降、実装難易度が下がったのでは?と思い、簡単に実装を試してみました。
CSRF(クロスサイトリクエストフォージェリ)とは?
ユーザーが意図しない操作を、認証済みのWebサイトに対して実行させる攻撃手法
https://cyber-insurance.jp/column/1630/
実行例
- ユーザーAがサービスXにログインしている
- ユーザーAが別のサイト(悪意あり)で、フォームに入力し、投稿する
- フォームはサービスXにPOSTし、ユーザーAはログイン状態を維持しているため、意図せぬ内容がサービスXに投稿されてしまう
Cookieを使った認証の場合、Same Origin Policyはクロスオリジンからのレスポンス読み取りを制限するが、リクエスト送信自体は許可してしまうため、GET以外ではセキュリティ上の問題があります。
CSRFトークン
上記の問題を防ぐために、CSRFトークンという技術があります。サーバー側で生成したトークンをフロント側のフォームに仕込んで、送信の際にサーバー側で受け取ったものを照合します。この機構を利用することで、悪意ある外部サイトからのリクエストに対応することができます。
実行例
- サービスAのバックエンドで生成したトークンを、フォームのhiddenフィールドに埋め込む(CSRFトークン)
- フォームを送信した際に、バックエンド側に保持しているトークンと、hiddenフィールドの値を照合し、適合しない場合は不適切な送信とみなしてエラーを投げる
React Router v7での実装例
React Router v7(旧Remix)では、loader()とaction()といったサーバーサイドの処理と、Reactコンポーネントとして書かれるフロントエンドの実装が一つのファイルで実現するため、この手の連携が非常にやりやすくなっています。以下、CSRFトークン周りの処理だけに絞ってわかりやすいよう3パートに分けて書いていますが、本来は一つのファイルで実装されるものです。
loader()
GETリクエストを処理するloader()では、CSRFトークンを発行してセッションに保存し(そのセッションIDをCookieに保持しています)、クライアントに渡しています。
export const loader = async ({ request }: Route.LoaderArgs) => {
const session = await getSession(request.headers.get('Cookie'));
// CSRFトークンの発行(実際には有効期限などを考慮した方が良い)
let csrfToken = session.get('csrfToken')?.toString();
if (!csrfToken) {
csrfToken = crypto.randomUUID();
session.set('csrfToken', csrfToken);
}
return data(
{
csrfToken,
},
{
headers: {
'Set-Cookie': await commitSession(session),
},
}
);
};
フロントエンド
フロントでは、loader()から渡されたCSRFトークンを、hiddenフィールドにセットします。
export default function LoginPage() {
const { csrfToken } = useLoaderData<typeof loader>();
return (
<Form method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
<button type="submit">送信</button>
</Form>
);
}
action()
POSTリクエストを扱うaction()で、送信されたトークンとセッションに保存されているトークンの照合を行います。一致しない場合は、外部からのアクセスとみなし、エラーを投げます。
export const action = async ({ request }: Route.ActionArgs) => {
try {
const session = await getSession(request.headers.get('Cookie'));
const csrfToken = session.get('csrfToken')?.toString();
const body = await request.formData();
const csrfTokenFromForm = body.get('csrfToken')?.toString();
if (!csrfToken || !csrfTokenFromForm || csrfToken !== csrfTokenFromForm) {
throw new Error('Invalid CSRF Token');
}
// なんかいろいろ好きにやってよ…
return {
status: 'success',
}
} catch (e) {
console.error(e);
throw data(
{ message: 'Invalid CSRF Token' },
{ status: 403 }
);
}
};
Discussion