
現代社会を生きる皆様こんにちは🐊
普段フロントエンドで開発を行っている二年目のSです。
以前社内のイベント用に初歩的なCSRF保護についてまとめたことがあります。その後Auth.jsではどのように行っているのか気になったので、学習のためにまとめてみました。また、それだけだと実感が持てないので、試しにCSRF攻撃を行ってどんなエラーになるかやってみました。
内容に間違いがあれば申し訳ありません🙇♂️
今回使用したAuth.jsのバージョンは5.0.0-beta.30です。
CSRFとは
悪意のあるWebサイトが、正規のユーザーがログインしている状態を悪用して、ユーザーが意図しない不正なリクエストを送信させる攻撃です。
例えば、ユーザーが銀行サイトにログインした状態で、攻撃者のサイトにアクセスすると、攻撃者のサイトから銀行サイトへの送金リクエストが自動送信されるといったケースがあります。
CSRFトークン
CSRFの対策として、CSRFトークンを使用するというものがあります。
CSRFトークンとは、リクエストが正規のページを経由して送信されたことをサーバー側で確認するために用いられる、ランダムな一意の文字列です。
この対策の手法には二つあり、ひとつが「Synchronizer Token Pattern」です。
この手法では、サーバーはHTMLやJavaScriptを返す際にこのトークンを発行し、フォーム内やJavaScriptコードに埋め込みます。また、発行したトークンはサーバーで管理します。
ユーザーがリクエストを送信する際にこのトークンを一緒に送ることで、サーバーはそのリクエストが自サイトの正規ページから発行されたものかどうかを判別できるようになります。

Auth.jsのCSRF保護
ここからはAuth.jsでのCSRF保護です。
Auth.jsでは、もうひとつの手法「Double Submit Cookie Pattern」を採用しています。
主な違いは、トークンの管理の違いです。 先ほどの「Synchronizer Token Pattern」では、発行したトークンはサーバーで管理するとありました。ただ、「Double Submit Cookie Pattern」はCookieでCSRFトークンを管理します。
こうする理由のひとつは、FaaS(Function as a Service)などの環境でも動くようにするためだと考えられます。
FaaSとは、サーバレスでアプリケーションを開発できるクラウドサービスのことです。有名なサービスにAzure FunctionsやAWS Lambdaなどがあります。
従来のサーバーでは、プロセスが常時稼働していて、リクエストがない時間もメモリやCPUリソースを確保し続けています。サーバーの起動・管理・スケーリングは開発者が行う必要があります。 一方、FaaSはイベント駆動で、トリガーとなるイベント(リクエスト等)が発生した時だけプロセスが起動します。処理が終わればプロセスは終了し、リソースが解放されます。 つまりFaaS環境では、リクエスト間で状態を保持できません。
Synchronizer Token Patternを採用すると、サーバー側でトークンを保存・管理する必要があります。従来のサーバーであればメモリに保持できますが、FaaSでは外部ストレージが必須になります。つまり、Auth.jsを使うために追加のインフラを用意しなければなりません。
一方、Double Submit Cookie Patternでは、すべての情報がcookieに含まれます。サーバー側で状態を保持する必要がなく、完全にステートレスに動作します。もちろん追加のインフラも用意する必要はありません。
Auth.js(NextAuth.js)のreadmeにも、「stateless authentication with any backend」と書いてありますね!

GitHub - nextauthjs/next-auth: Authentication for the Web. · GitHub
秘密鍵を用いた署名による強化
Auth.js は単純な Double Submit Cookie Pattern ではなく、秘密鍵(AUTH_SECRET)を用いた署名でトークンを強化しています。トークン本体と、そのハッシュ値をセットでcookieに保存します。AUTH_SECRETを使ってサーバー側でハッシュを再計算・照合し、改ざんを検知します。

検証環境を作る
Auth.jsでのCSRF保護がわかったところで、実際に攻撃してみます。
以下を参考にさせていただき、GoogleをId Providerとした簡単な認証ができるNext.jsアプリを用意しました。

上の画像から、CSRFトークンのcookieがあるのがわかります。
ただ、cookieのsameSite属性がデフォルトでlaxになっています。これもひとつのCSRF保護なのですが、今回は前提としてcross-site POSTでcookieが送信されるようにしたいので外しておきます!(sessionTokenも外しましたが不要かもしれません!)
export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [Google], cookies: { csrfToken: { name: "authjs.csrf-token", options: { httpOnly: true, sameSite: "none", path: "/", secure: true, }, }, sessionToken: { name: "authjs.session-token", options: { httpOnly: true, sameSite: "none", path: "/", secure: true, }, }, }, });
sameSite属性がnoneになりました。これでcross-siteにcookieが送信されるはずです。

また、攻撃者のサイトも用意しました。具体的な実装は省略しますが、以下のようにすることで、勝手にsubmitされるようにしました。
useEffect(() => { formRef.current?.submit(); }, []);
<iframe name="csrf-fake-token-frame" style={{ display: "none" }} /> <form ref={formRef} method="POST" action={`${targetUrl}/api/auth/signout`} target="csrf-fake-token-frame" > <input type="hidden" name="csrfToken" value={csrfToken} /> </form>
UIはそれっぽい攻撃者サイトを再現しました。

実際に試す
ここから実際に攻撃を行いたいと思います。今回は以下の4パターンを試しました。
- CSRFトークンなしでサインアウトを試みる
- 適当なCSRFトークンを捏造してみる
- 攻撃者が正しいCSRFトークンを送信してみる
- CSRF保護なしでサインアウトを試みる
CSRFトークンなしでサインアウトを試みる
まずはbodyにCSRFトークンを含めずにPOSTしてみます。
cookieは送信されていそうです。

MissingCSRFとなりました。CSRF保護が効いていそうです。

適当なCSRFトークンを捏造してみる
次に、適当なCSRFトークンをbodyに含めてPOSTしてみます。

これも同様のMissingCSRFエラーとなりました。
攻撃者が正しいCSRFトークンを送信してみる
通常、別の脆弱性によってトークンが漏れない限り、このパターンは成立しませんが、今回は被害者の正確なCSRFトークンを入手できるため、それを送信してみます。

サインアウトに成功しました!
![]()
CSRF保護なしでサインアウトを試みる
Auth.js にはskipCSRFCheckというオプションがあり、内部的にCSRFチェックをスキップできます。
export const { handlers, signIn, signOut, auth } = NextAuth({ ..., skipCSRFCheck, });
この状態で再度CSRFトークンなしでサインアウトをしてみます。
すると、先ほどと同様サインアウトに成功しました!
![]()
以上から、実際にCSRF保護の効果を確認できました。
まとめ
今回はAuth.jsがCSRF保護をどのように実現しているのかについて簡単にまとめました。普段当たり前に使用している仕組みも、攻撃する側に回って試してみると理解が深まった気がします。これからも、なんとなく使っている技術を一度立ち止まって覗いてみたいと思います。
おわりに
KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp