●サーバサイドレンダリング(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 コードを動作させるのと比較して、サーバで同じコードを動作させることは以下のようなパフォーマンス上の問題やセキュリティ上の問題を生む可能性があります。
・サーバにかかる負荷の増加
・ブラウザ専用のオブジェクトや関数による制約
・サーバサイドではグローバル空間がユーザ間で共有される問題
参考: Nuxt.js の Vuex は State がユーザー間で共有される恐れがあるので注意が必要
・サーバ上での任意の JavaScript の実行のバグは、ブラウザ上と比較してリスクが大きい
信頼できないコンテンツをコンポーネントのテンプレートとして絶対に使わないということです。そうすることは、あなたのアプリケーション内で任意の JavaScript 実行を許してしまうことと同じです。さらに悪いことに、コードがサーバーサイドレンダリング中に実行された場合には、サーバー側の欠陥につながります。
出典: セキュリティ - Vue.js / ルール No.1: 信頼できないテンプレートを絶対に使わない
他にも、API への HTTP リクエストの送信主体がサーバとなることによってサーバサイドリクエストフォーリジェリ(SSRF)が引き起こされるパターンも考えられます。ここではこの SSRF の概要やその発生パターンについて掘り下げてみます。
●サーバサイドレンダリングによって SSRF が発生するパターン
例として SSR 導入予定のショッピングサイトの SPA を想定してみましょう。商品ページで商品データを表示するにはブラウザから API を叩いてデータを取得する必要があります。API サーバのホスト名がサイトと同じで、パス「/api」以下が API 用のパスという設計の場合、fetch 関数を利用して
fetch("/api/product/1")
と、相対 URL でリクエストを送信することができます。これによって接続先のホスト名を省略できる他、認証認可に使用する Cookie が自動的に送信されるためコードを簡略化できて一見便利そうです。
さて、このサイトに SSR を導入してサーバ上で上記コードを走らせた場合、まずはじめに Node.js には組み込みの fetch 関数が用意されていないため動作しないという問題が起こります。node-fetch や isomorphic-fetch のようなライブラリを使用して fetch 関数を呼び出したとしても相対 URL のままでは動作しないため、ホスト名をうまく「補完」する必要があります。

そのような補完を行っている実装例として 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 リクエスト送信者が自由に設定可能となっていました。

このため、攻撃者が Host ヘッダの値を攻撃者の管理するサーバのホスト名「attacker.example.com」に変えてこの関数を呼び出した場合、SSR 中のリクエストの送信先を API サーバではなく攻撃者のサーバへ変えることができてしまいます。この攻撃はホストヘッダインジェクションの一種とも言え、攻撃成立のためにはホスト名が経路中のリバースプロキシによって書き換わらず、サーバが任意のホスト名でのリクエストを許可する必要がありますが、ありえない設定ではなさそうです。
攻撃が成功した場合、攻撃者のサーバの返すレスポンスを調整することで SSR 中に扱われるデータを制御できるだけではなく、リダイレクトを返すことで URL パスも含めた任意の URL への SSRF を引き起こすことができます。これは一般的な SSRF にも言えることですが、SSRF の結果によっては任意コード実行であったり機密情報などの奪取へとつながる可能性があります。
例えば通常このような流れで動作するサイトに対しては、

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

この問題を Angular へ報告した結果、接続先 URL の補完には Host ヘッダ由来の値ではなく開発者が設定するオプションを使用する修正が行われました。
https://github.com/angular/angular/pull/39334
●その他のケース
Auth0 のサンプルコード
紹介したケース以外にも同様のパターンがしばしば見られます。
Next.js での認証の実装例を紹介する Auth0 公式ブログの記事では、SSR 中に使用する初期データを API サーバから取得するサンプルコードがありましたが、ここでも「req.get(“Host”)」即ち Host ヘッダの値が API サーバのホスト名となっていました。そのため、このサンプルコードを参考に実装すると SSRF の脆弱性が発生する可能性がありました。※ こちらも報告済みで、現在は別の記事に差し替えられています。

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 リクエスト送信時のオプションとして保存されます。

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

この 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 の可能性があるコードであると考えられます。

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

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

同様に 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 の脆弱性が生まれる、という事例を紹介しました。本記事が安全なアプリケーション開発の一助となれば幸いです。
イエラエセキュリティでは脆弱性を探すのが好きな人を積極的に募集しています。お気軽にご連絡ください。
(連載「イエラエセキュリティ CSIRT支援室」は、「診断業界の切り込み隊長」として存在感を発揮する株式会社イエラエセキュリティの公式ブログ「SECURITY BLOG」から、創造力あふれるイエラエセキュリティのCSIRT支援の情熱を伝える記事を厳選して掲載しています)