- 2023.11.20
- CSRF対策のあれこれ
お疲れ様です!㉔です♪
AzOneでは学習サポートを開始しています
現在AzOneでは新人を抜け出したくらいの有志のメンバーを対象に、Web開発のスキルを底上げするためのトレーニングを実施しています。主に以下を対象にこちらで学習するためのコンテンツを用意して取り組んでもらっています。
- Linux
- サーバーサイドプログラミング
- フロントエンドプログラミング
- HTML、CSS
- React、Next.js
このトレーニングを完遂すれば簡単なウェブアプリケーションを一人で実装できるようになるようにコンテンツを組んでいるため、メンバーには頑張ってもらいたいです。
CSRF is 何?
上記のコンテンツの中でCSRFという脆弱性に関する対策を行うトレーニングがあります。IPA(独立行政法人情報処理推進機構)が折角丁寧な図解を用意してくれているので紹介させていただきます。
利用者が意図したリクエストであるかどうかを識別する仕組みを持たないウェブサイトは、外部サイトを経由した悪意のあるリクエストを受け入れてしまう場合があります。このようなウェブサイトにログインした利用者は、悪意のある人が用意した罠により、利用者が予期しない処理を実行させられてしまう可能性があります。このような問題を「CSRF(Cross-Site Request Forgeries/クロスサイト・リクエスト・フォージェリ)の脆弱性」と呼び、これを悪用した攻撃を、「CSRF攻撃」と呼びます。
安全なウェブサイトの作り方 – 1.6 CSRF(クロスサイト・リクエスト・フォージェリ)
CSRF攻撃は本人になりすまして特定のウェブサービスの操作を実行するという点で非常に悪質で、これによる誤認逮捕なども発生しておりパソコン遠隔操作事件は特に有名です。
CSRFの実践方法などは多くのブログなどで紹介されているためこの記事ではCSRF自体の説明は要点だけにとどめ、現在のWebの仕様(W3C)から対策を実施せずとも結果的にCSRF攻撃への対策ができてしまっている状況と、正しい対策方法について紹介してみようと思います。
CSRF攻撃を実際にやってみる
CSRFのなりすましはセッション情報としてCookieが保存されていればよいので今回は認証部分については省略します。(CSRFは認証自体が重要な要素という訳ではない)
できる限りシンプルにしたいのでNode.jsのhttpモジュールからサーバーを作成します。こうしてみるとExpressも同じ位のコード量で書けるのExpressでやっても良かったかもしれませんね。
const port = 3000
const server = createServer(async (req, res) => {
// 省略
})
server.listen(port, () => {
console.info(`listening on port ${port}`)
})
<form method="post" action="/posts" name="post-content">
<label for="content_simple">label</label>
<input id="content_simple" type="text" name="content_simple" />
<button type="submit">send</button>
</form>
かなり省略しましたが、これでサーバーにアクセスするとこんな感じになります。シンプルですね。シンプル過ぎたかもしれません。
それではこのフォームから任意の値を送信し、それをサーバー側で処理します。
if (req.method === "POST") {
let data = ""
req
.on("data", (chunk) => (data += chunk))
.on("end", () => {
// DB操作などの処理をする
})
res.writeHead(303, { Location: "/"
})
res.end()
return
}
当然このようなpayloadがサーバーに送信されます。この際、認証が済んでいる場合Cookieはヘッダ情報として送信されます。
POST /posts HTTP/1.1
Host: localhost
Origin: http://localhost:3000
Content-Type: application/x-www-form-urlencoded
Cookie: content.sid=s%ofojwe...
content_simple=sample
ここで、以下のようなダミーサイトを用意します(このダミーサイトへどうやって誘導するかは別の問題ですが)
<form id="dummy" method="post" action="http://localhost:3000/posts">
<label for="dummy_content">😊安全😊なサイトです</label>
<input hidden id="content_simple" type="text" name="content_simple" value="👿👿👿" />
</form>
<script>
document.getElementById("dummy").submit()
</script>
このサイトは実際にはアクセスした瞬間http://localhost:3000/postsへcontent_simple=👿👿👿というpayloadを送信します。(その後すぐにリダイレクトされるの実際には上図の画面は一瞬しか表示されない)セッション情報はCookieに保存されており、有効期限が切れていない限りはサーバーはログインユーザーが送信したリクエストとして処理してしまいます。これで他人になりすまし任意のリクエストを送信させることができたため晴れてCSRF攻撃完了です👏
CORSは全てを解決する?
CORSの軽い軽い説明
Web開発において必ずといっていいほど直面する問題の一つにCORSがあります。CORSに関しては解説している記事が山程あるのでここでは簡単な説明にとどめます。
セキュリティの観点からブラウザは異なるオリジン(ドメイン)のリソースへのアクセスを制限しています。この制約に対して異なるオリジンにあるリソースへのアクセス権を与えるようブラウザーに指示するための仕組みがCORSです。
さっき異なるドメインにPOSTできたんじゃ…?
先程CSRF攻撃した際ダミーサイトは異なるドメインであるlocalhost:3000へPOSTしましたが、サーバーはそのリクエストを正常に処理しています。サーバーはCORSの許可を何も設定していない(異なるオリジンからのアクセスを許可していない)にも関わらず正常にリクエストを送信できたのには理由があります。
実はブラウザは問答無用で異なるオリジンへのアクセスを制限している訳ではありません。例えば画像ファイルをCDNなど異なるオリジンから取得することは珍しくありませんが、これらを制限すると画像を参照する静的ページを作成することすら困難になります。そのため、<img>タグ等昔からクロスオリジンアクセスを実施していたものは制約の対象外になっています。
この制約の対象外に単純リクエストというものがあります。(単純リクエストはW3Cの古いCORS仕様書での呼称であり、現在のW3CのCORSに関する仕様書ではそのような名称では呼ばれず、単に〜の条件はクロスオリジンの制約に含まれないといった感じで条件だけを列挙している感じです)単純リクエストとなる条件の詳細はみんな大好きMDNの説明に任せるとして、先程の<form>タグによるPOSTは単純リクエストに該当します。よって、クロスオリジンの制約を受けずにリクエストを送信できたというわけです。
閑話休題
少し話は逸れますがなぜこんな抜け道(単純リクエスト)のようなものがあるのかという話をしておきます。
HTML4.0からの<form>はそもそもクロスオリジンにリクエストを送信が可能でした。そのため、XMLHttpRequestやfetchにも同じことができるのはおかしな話ではありません。CORSはこれら(クロスオリジンへリクエストの送信が可能なWebの世界)より後に誕生した仕様です。もしクロスオリジンへのリクエストの送信にCORSヘッダを必須とするようブラウザの仕様が変わった場合、クロスオリジンとなるリクエストを送信していたウェブアプリケーションの内、サーバーがCORSヘッダを設定していないものは全て機能しなくなります。Webの仕様は後方互換性を重要視しているため、単純リクエストのような例外が存在することになりました。
W3C的にはそもそもCORS以前からもCSRFの対策は必須だったはずなので、クロスオリジンの制約から単純リクエストが外れているとしても、それがCSRFへの脆弱性を内包した仕様だという主張は正しくないという話のようです。
え…じゃあうちのウェブアプリケーションやばい…?
話を戻します。
CSRF対策特ににしてないけどやばい…?と思ったあなた、結果的に大丈夫になっている場合があります。主観ですがモダンなウェブアプリケーションでは<form>タグの機能をそのまま利用することは少なく、formの入力をjson形式に整形してサーバーへ送信するというユースケースの方が多いのでは無いかと思います。
この場合ヘッダにはContent-Type: application/jsonを指定すると思います。実はこのヘッダは単純リクエストの条件から外れるため、クロスオリジンへのアクセスが制限されます。実際に試してみましょう。先程のダミーサイトで以下のヘッダを指定してPOSTしてみます。
fetch("/posts", {
method: "POST",
headers: {
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/json",
},
body: JSON.stringify({
hoge: "hoge-data",
}),
})
すると上記のようなエラーが返ってきます。CORSが設定されていないためにリクエストが失敗していることがわかります。
うちのアプリケーションは問題ないってことでOKですか?
そうとも言い切れないところがセキュリティの怖いところです。例えばこんなフォームがあったとします。<input>タグのnameとvalueに注目してください。
<form id="dummy" method="post" action="http://localhost:3000/posts"> <label for="dummy_content">😊安全😊なサイトです</label>
<input hidden id="content_simple" type="text" name='{"hoge":"hoge","end":"end' value='"}' />
</form>
<script>
document.getElementById("dummy").submit()
</script>
先程説明した通りこれは単純リクエストに該当するのでダミーサイトからサーバーへリクエストが送信されます。その際以下のようなリクエストの内容になります。
POST /posts HTTP/1.1
Content-Type: application/x-www-form-urlencoded
// 見やすくするためJSONっぽく改行してます
{
"hoge" : "hoge",
"end":"end="
}
これは<form>タグによるpostが${name}=${value}というpayloadになることを利用して、jsonフォーマットに従うようにnameとvalueを指定しています。(使用上「=」が絶対入ってしまいますが)もしサーバー側でContent-Typeを確認せずに、payloadをそのままjson形式に変換して処理を継続するような実装をしていた場合、CSRF攻撃が成立する場合があります。
サーバーの実装依存なのでこの攻撃が成立することは稀だとは思いますが、CORSの許可を正しく設定している、クライアントからのリクエストは<form>タグの機能は利用していないというだけではCSRF攻撃は防げないことがわかったと思います。
CSRFへの礼儀正しい対策
CSRFへの対策は色々あり、改めてこの記事で説明することでは無いので紹介にとどめます。
- IPAによる根本的対策の紹介
- トークンを利用したCSRF
個人的に正しいCSRF対策は成熟したフレームワークを利用し、そのフレームワークで提供されているCSRF対策用のツールを利用する(オレオレCSRF対策はやめる)、CookieではなくJWTなどのトークンを利用した認証を利用するというのがベターなのかなと思っています。
最後に
今回はCSRFについて色々書きました。CSRF対策はSPA、JWTなどの実装方法、認証方式の変遷により不要、必要など色々な紹介のされ方をしています。ここまでに紹介したことを踏まえてあえて言うのであればSPAだから安全、formタグを利用してないから安全のような〜をしたら安全と言い切れるような脆弱性ではないと思います。プロジェクトで使用しているフレームワーク、利用している認証方式、フォームの実装方法などを踏まえた上でCSRFの対策できているか確認するというのが正しい姿勢だと思います。