脆弱性診断技術やサイバーセキュリティに関する情報を発信するブログメディア

サーバサイドレンダリングの導入から生じるSSRF

オフェンシブセキュリティ部の山崎です。サーバサイドレンダリング(SSR)の導入によってSSRFが発生する問題を見つける機会があったため、本記事では実例を交えながら紹介したいと思います。

サーバサイドレンダリング(SSR)とは?

本記事で扱うSSRとは「サーバ上でHTMLを出力すること」を指しています。ただしerbやjspのようなテンプレートからHTMLを出力するのとは異なり、一般的には以下のようにクライアントサイドレンダリング(CSR)の文脈で使われることが主です。

近年のVue.jsやReactを代表するようなWebフロントエンドフレームワークはブラウザ上で動的にDOMツリーを構築して画面を描画(CSR)するのが主流となっています。これによってページ遷移を挟まずユーザ体験のよいシングルページアプリケーション(SPA)が作ることができるというメリットがあります。

ただ、単純なSPAにはデメリットもあります。画面の描画に必要なロジックがサーバからクライアントのJavaScriptコードに移るため、サーバがURL毎に異なるHTMLを返せなくなりSEOやOGP用のクローラとの相性が悪い問題や、初回描画が少し遅くなる問題が指摘されていました。

そこでCSR用のJavaScriptコードをサーバ上のNode.jsでも動かしてHTMLを返してしまう方法(=SSR)によって、サーバからもURL毎に異なるHTMLを返すという試みが広まりました。実際、Vue.jsやReact等でSSR機能が提供されている他、SSRを前提としてNuxt.jsやNext.jsのようなフレームワークが生まれています。
※ 他にもSSGやISRといった手法もありますがここでは省略します。


ブラウザとサーバ、環境の違いが生む問題

このようなクライアント・サーバ双方で動作するものはUniversal JavaScriptアプリケーションと呼ばれ、CSRとSSRを組み合わせることで先に述べたような不満点を手軽に解消できるように見えます。しかしブラウザというサンドボックス環境でJavaScriptコードを動作させるのと比較して、サーバで同じコードを動作させることは以下のようなパフォーマンス上の問題やセキュリティ上の問題を生む可能性があります。

他にも、APIへのHTTPリクエストの送信主体がサーバとなることによってサーバサイドリクエストフォーリジェリ(SSRF)が引き起こされるパターンも考えられます。ここではこのSSRFの概要やその発生パターンについて掘り下げてみます。

サーバサイドレンダリングによってSSRFが発生するパターン

例としてSSR導入予定のショッピングサイトのSPAを想定してみましょう。商品ページで商品データを表示するにはブラウザからAPIを叩いてデータを取得する必要があります。APIサーバのホスト名がサイトと同じで、パス「/api」以下がAPI用のパスという設計の場合、fetch関数を利用して

fetch("/api/product/1")

と、相対URLでリクエストを送信することができます。これによって接続先のホスト名を省略できる他、認証認可に使用するCookieが自動的に送信されるためコードを簡略化できて一見便利そうです。

さて、このサイトにSSRを導入してサーバ上で上記コードを走らせた場合、まずはじめにNode.jsには組み込みのfetch関数が用意されていないため動作しないという問題が起こります。node-fetchisomorphic-fetchのようなライブラリを使用してfetch関数を呼び出したとしても相対URLのままでは動作しないため、ホスト名をうまく「補完」する必要があります。


相対URLのままではfetchできない

そのような補完を行っている実装例としてGoogleの開発しているフレームワークAngularがあります。
Angularではクライアント・サーバ両方で動作するモジュール「http」が提供されており、バージョン10.1.0以降は以下のようなコードが自動的にサーバでも動作するようになりました。
※は公式の提供するExpress用モジュール等を使用した上でuseAbsoluteUrlオプションが有効である必要があります。

http.get("/api/product/1")

公式ドキュメントでも相対URLのまま使うことができるという説明がなされています。

If you are using one of the @nguniversal/*-engine packages (such as @nguniversal/express-engine), this is taken care for you automatically. You don't need to do anything to make relative URLs work on the server.

出典: https://angular.io/guide/universal#using-absolute-urls-for-http-data-requests-on-the-server

さて、サーバ上でもこのコードを「自動的に」動作させるためには、どこかから接続先のホスト名を持ってきて補完する必要がありますが、Angularではどのように実装しているでしょうか。
このバージョンのAngularでは「platformLocation.hostname」というパラメータを使用して補完していますが(http.ts)、これは「config.url」が元になっており(location.ts)、更に遡ると「受信したHTTPリクエスト中のHostヘッダ」が元となっており(main.ts)HTTPリクエスト送信者が自由に設定可能となっていました。


出典: https://github.com/angular/angular/blob/10.1.0/packages/platform-server/src/http.ts#L114

このため、攻撃者がHostヘッダの値を攻撃者の管理するサーバのホスト名「attacker.example.com」に変えてこの関数を呼び出した場合、SSR中のリクエストの送信先をAPIサーバではなく攻撃者のサーバへ変えることができてしまいます。この攻撃はホストヘッダインジェクションの一種とも言え、攻撃成立のためにはホスト名が経路中のリバースプロキシによって書き換わらず、サーバが任意のホスト名でのリクエストを許可する必要がありますが、ありえない設定ではなさそうです。

攻撃が成功した場合、攻撃者のサーバの返すレスポンスを調整することでSSR中に扱われるデータを制御できるだけではなく、リダイレクトを返すことでURLパスも含めた任意のURLへのSSRFを引き起こすことができます。これは一般的なSSRFにも言えることですが、SSRFの結果によっては任意コード実行であったり機密情報などの奪取へとつながる可能性があります。
例えば通常このような流れで動作するサイトに対しては、


通常の流れ

以下のように攻撃後、パブリッククラウドのメタデータAPI(http://169.254.169.254/latest/api/token)にリダイレクトすることでトークンを奪取できるかもしれません。


SSRFによってトークンを奪取

この問題をAngularへ報告した結果、接続先URLの補完にはHostヘッダ由来の値ではなく開発者が設定するオプションを使用する修正が行われました。
https://github.com/angular/angular/pull/39334

その他のケース

Auth0のサンプルコード

紹介したケース以外にも同様のパターンがしばしば見られます。

Next.jsでの認証の実装例を紹介するAuth0公式ブログの記事では、SSR中に使用する初期データをAPIサーバから取得するサンプルコードがありましたが、ここでも「req.get(“Host”)」即ちHostヘッダの値がAPIサーバのホスト名となっていました。そのため、このサンプルコードを参考に実装するとSSRFの脆弱性が発生する可能性がありました。※ こちらも報告済みで、現在は別の記事に差し替えられています。


出典: https://web.archive.org/web/20200808093458/https://auth0.com/blog/next-js-authentication-tutorial/

GraphQLクライアント

こちらはSSRが主要な原因とは言えませんが、SSR中にSSRFが発生するケースをもう1つ紹介します。
GraphQLクライアントとしてApolloを使用していて、認証用のCookieやUser-AgentをGraphQLサーバに伝える必要性から以下のように「req.headers」を引数に指定するようなコードがたまに見つかりますが、これもSSRFが発生する可能性があります。

client = ApolloClient({
  uri: 'https://nextjs-graphql-with-prisma-simple.vercel.app/api',
  headers: req.headers, // req.headers: HTTPリクエストヘッダから生成されたオブジェクト
  cache: new InMemoryCache()
})

少しコードを追って確かめてみましょう。

ApolloClientに渡された「req.headers」はHttpLinkクラスの初期値として使用され、HTTPリクエスト送信時のオプションとして保存されます。


出典: https://github.com/apollographql/apollo-client/blob/f7137be58b9950e30b12535497f5d120aa4a9d92/src/core/ApolloClient.ts#L164

このように作成されたクライアントからクエリが発行されると「ApolloLink.request」が呼び出され、続けて先程保存されたオプションと共にfetcher関数が呼び出されます。


出典: https://github.com/apollographql/apollo-client/blob/f7137be58b9950e30b12535497f5d120aa4a9d92/src/link/http/createHttpLink.ts#L146

このfetcherの指す関数は設定によって変更可能ですが、Node.jsで動作する場合にはnode-fetchが推奨されています。

結果としてnode-fetchが実行されてGraphQLサーバにリクエストが送信されますが、このリクエストにはユーザの送信した認証用ヘッダの他にHostヘッダも再利用されます。
通信先のURLを直接変更することはできませんが、GraphQLサーバのURLがCDNを指す場合、攻撃者がそのCDNにホスト「attacker.example.com」を登録してHostヘッダの値を「attacker.example.com」に書き換えてしまえば、CDNは攻撃者のサーバにリクエストを転送するため、リダイレクトと組み合わせることで同様にSSRFが発生します。

RANというツールキット(2k stars)では、SSR中に生成されるApolloClientの「headers」オプションに「req.headers」が丸ごと設定されます。実はこの例では「link」オプションが別途設定されていること等の理由からSSRFは発生しませんが、潜在的にSSRFの可能性があるコードであると考えられます。


出典: https://github.com/Sly777/ran/blob/9879d908d2a8f79b4cd1d23910db62960a99fef8/libraries/apolloClient.js#L32

このSSRFの少し面白いポイントとしては、SSRの特性によって発覚が遅くなる可能性があるという点です。
今回のようにHostヘッダが上書き可能であった場合、(フロントエンドサーバとGraphQLサーバのホスト名が同じでなければ)通常利用でも上書きが発生してしまいます。これによって普通はエラーが発生するか必要なデータが表示されなくなるでしょうし、自然と異常に気づくことができるかもしれません。
ただ、SSR中にこの問題が発生した場合には、例えSSRでデータが空であっても代わりにCSRで適切にデータを取得して表示することが可能であるため、ユーザからは異常が見え辛くなり発覚が遅れる可能性が考えられます。

先程紹介したRANでもSSR中に発生したGraphQL関連のエラーは表に出ないように処理されているため、問題に気づくことが少し難しくなるかもしれません。


出典: https://github.com/Sly777/ran/blob/master/libraries/withData.js#L82

安全に実装するには

SSR関連のフレームワークとしてはNext.jsやNuxt.jsが有名ですが、これらで推奨されている実装方法に従えばこの問題は起きづらいようです。
Nuxt.jsのhttpモジュールでは、デフォルトの通信先ホスト名は環境変数等から選択されるためユーザ入力値が自動的に使用されることはありません。このように、開発者に明示的に通信先を指定させるか、補完する場合でもユーザ入力値に拠らない値を使用することがこの問題のシンプルな回避策と言えそうです。
https://http.nuxtjs.org/options/#host

その他、BlitzというフルスタックフレームワークではAPIのロジックもJavaScriptで記述することを前提しているため内部のAPIの呼び出しにHTTPリクエストが発生せず、この問題が起こりづらいと考えられます。


出典: https://blitzjs.com/

同様にNext.jsでもAPI routeという機能を使用している場合には、サーバから内部のAPIをHTTPで呼び出すのではなく直接そのロジックを呼び出すことが推奨されています。

Note: You should not use fetch() to call an API route in getServerSideProps. Instead, directly import the logic used inside your API route.

出典: https://nextjs.org/docs/basic-features/data-fetching

まとめ

クライアント・サーバ双方で動作するUniversal JavaScriptアプリケーションを記述するためにAPIのリクエスト先をユーザ入力値から補完するとSSRFの脆弱性が生まれる、という事例を紹介しました。本記事が安全なアプリケーション開発の一助となれば幸いです。
イエラエセキュリティでは脆弱性を探すのが好きな人を積極的に募集しています。お気軽にご連絡ください。

セキュリティ診断ならお任せください

Webサービスやアプリにおけるセキュリティ上の問題点を解消し、収益の最大化を実現する相談役としてぜひお気軽にご連絡ください。
国内トップクラスのセキュリティエンジニアが診断を行います 。

ホワイトハッカー

セキュリティ診断
ご相談はこちら