👹

GraphQLでのサービス拒否攻撃への対策に関する考察

2022/11/29に公開

GraphQL over HTTPでのセキュリティ対策は一般的なRESTful APIとそう変わらない。適切に入力の検査を行い正常な入力のみを処理するだけである。

しかし、GraphQL over HTTPで1つだけRESTful APIと違い、独自の対策を行わなければいけないケースがある。それは処理コストの高いクエリを意図的に作成しサービス拒否攻撃を行うものへの対策になる。

query {
  user {
    id
    name
    friends {
      id
      name
      friends {
        ...
      }
    }
  }
}

一例にはなるが、上記のように再帰的に何度も繰り返すようなクエリを実行し、サーバーリソースを枯渇させサービス拒否を狙うことができる。
他にも高コストの集計系や、N+1を続けるなど、GraphQLでは正常なクエリ実行でサービス拒否攻撃を行うことができてしまう。

これはある意味、GraphQLのメリットの裏返しであり自由度の高さから生じている問題になり、GraphQLへのメリットを享受しながらどのように攻撃を通さないようにするか考えなければならない。

一般的な解決方法

一般的な解決方法としてはクエリの深さの制限と、コストの制限になる。

クエリの深さ制限は先の例のように再帰をするケースで、例えば深さを3で制限する下記のようなクエリは正常なクエリを実行する。

query {
  user {
    id
    name
    friends {
      id
      name
    }
  }
}

もしfriendsからさらにfriendsを呼び出そうとするような場合にはエラーとしてレスポンスを返すような形になる。

次にコストの制限はGitHubの制限を参考にするとわかりやすい。
https://docs.github.com/en/graphql/overview/resource-limitations
上記のように一定のルールでクエリのコストを計算し、コストが超過するクエリであればエラーとしてレスポンスを返す形になる。
https://ibm.github.io/graphql-specs/cost-spec.html#sec-Example
GitHubの制限とは違いディレクティブを使ってフィールドのコストを定義する方法もある。こちらの方法は集計が必要なフィールドがあり、そういったフィールドを一度のクエリで大量に呼ばれたくないケースなどで有効な仕組みになる。

これらの対策によって無制限にサービス拒否攻撃は行えなくなり、一定の安全性は確保することができる。
しかし、これらの方法では以下の問題を抱えていくことになる

  • 閾値をどこに定めると開発者体験を維持しつつ、サービスを継続できるのか決めることが難しい
  • クライアントの変化、ユーザーの変化など状況の変化に合わせて閾値を調整し続けていく必要がある
  • スキーマや閾値によっては一定の効果のある攻撃ができる可能性が残る

そこで他の方法について深掘りしていく。

ホワイトリスト形式でのクエリの許可

クエリが自由に書けるせいでサービス拒否攻撃を行えるなら、実行可能なクエリを制限してしまえばいい。
https://docs.wundergraph.com/docs/guides/expose-a-graphql-api-from-wundergraph#why-you-should-not-follow-this-guide

In 99.9% of all cases, you will not change your GraphQL Operations once you've deployed your application. This means, you will not benefit from exposing a GraphQL in production because you're just using predefined

So why expose something if you don't need to? Why make yourself vulnerable to a wide array of attacks ?

WunderGraphのドキュメントに記載されたこの説明にはとても納得できる。
一般的なサービス開発においてGraphQLを導入したい理由はクライアント都合でのクエリの変更容易性と、旧バージョンのクエリのサポートの維持のしやすさになる。そう考えると本番環境で自由にクエリを書き換えたいかというと疑問は残る。(クエリを動的に組み立てたいケースとかないよね?ないよね!!?)

WunderGraphの解決する方法とは違うが、Apolloが自社のSaaSと合わせてホワイトリストを実現するSafe List機能を提供している。
https://github.com/apollographql/server-plugin-operation-registry
(Enterpriseでのみ使えるとか言われてるところもあるが、Priceページに記載がなかったり本当に使えるのかは良く分かっていない。古いドキュメントだとOperation Registryみたいなワードがあったりするけど消えてたりするし・・・詳細ご存知の方がいれば教えてください)

仮にApolloのSafe Listが使えない場合、他の方法としてはApolloのPersisted Queryでクエリをハッシュ化しておき、そのハッシュがGraphQL Serverに登録されていれば実行するというやり方になる。
https://www.apollographql.com/docs/react/api/link/persisted-queries/
Persisted Queryは帯域削減目的で使われるため目的が違うが、各クライアントのビルド時にハッシュを生成し、登録することで帯域を削減しつつ攻撃に対する完全な対策を得ることができる。
ただし、この方法はどこにハッシュを保管するのか、どのタイミングで古いクエリを削除するのかなど考えるべきことは多い。

また、先にあげたWunderGraphの考え方も面白い。WunderGraphはGraphQLスキーマとクエリを用意することで対応するJSON RPCを生成するという考え方になる。これによって開発環境ではGraphQLの柔軟性を。本番環境では決まったクエリしか流せずRESTful APIのような振る舞いをするため攻撃を防ぐことができる。

https://github.com/schwer/graphql-to-openapi
似たようなものでGraphQLスキーマとクエリからOpenAPIのスキーマを生成するツールもある。ここで生成したOpenAPIのスキーマからAPI Gatewayを生成し、GraphQLサーバーと接続するという方法も考えることができる。

サービスを停止させないアーキテクチャという選択肢

ホワイトリスト形式でGraphQLにおけるサービス拒否攻撃への完全な対策が行える目処は立った。しかし、ホワイトリスト形式をうまくやるためには開発やフローの立ち上げにコストをかけるか、お金で殴るしかない形になる。

そこで、一度立ち止まって、そもそもアーキテクチャレベルでサービスを停止させない方法はないのか考えてみる。

GraphQLにおけるサービス拒否攻撃のような方法でのサービスが停止するケースは大きく分けると2種類のケースしかない。

  • GraphQL Serverで何かしらのリソースが枯渇して応答できなくなる
  • GraphQL Serverが解決するDBなどのバックエンドで何かしらのリソースが枯渇して応答できなくなる。またはそこに引きずられてGraphQL Serverのリソースが枯渇する

つまりGraphQL Serverやそのバックエンドにスケーラビリティがないために攻撃が成功してしまう。

簡単に言えばオートスケールできるアーキテクチャにしようね。と一言で済むが、実際にはそうはいかない。この手の問題の場合、スケールする速度が攻撃によって詰まるより早くなければならず、その速度を出せるインフラストラクチャというものはかなり限定される。
例えばAWSを例に出すと、EC2で数分、EKSで数秒から数分(Nodeの起動が入ると遅い)、ECSでもEKSと同様と条件次第では高速なスケールができるが、条件次第では低速なものが多くスケール速度を維持するには手間とお金がかかってしまう。
DBで言えばさらに遅くRDBMS系などはスケールさせるのに倍以上の時間がかかる上にスケールの上限が低い。

それではどうするべきかと言うと、FaaSを採用したサーバーレスアーキテクチャで分散DBを利用することでサービスの継続性を高めることができる。(自前でスケールさせるのは大変なのでマネージドなものが最適になる)
また、例によってAWSを出すがFaaSであるAWS Lambdaでは障害の局所化をリクエスト単位に行えるため、仮に攻撃を受けても影響範囲を局所化できる。DBに関してもDynamoDBを使うことでスケールしやすく、問題が起きてもShard単位に閉じやすい。

このようにアーキテクチャレベルでスケーラビリティを担保した場合、サービス停止攻撃に対するサービスの継続性を担保することができる
(ただし、従量課金なのでクラウド破産には注意。本当に注意)

リクエストを遮断する機構

また、別の見方をすると攻撃を検知して適切に遮断すれば良いという考え方もできる。

簡単な例としては下記のような仕組みが考えられる

  1. 終端で認証トークンがなければエラーを返す
  2. 終端でアクセスログで処理時間と認証トークンをログに記録する
  3. ログを処理して一定時間がかかったリクエストの認証トークンを弾くように終端に登録
  4. 一定時間経過したら弾かないように解除

終端ではWAFのようにリクエストを遮断できる仕組みがあることが前提になる。
もし、人間を介在させるなら人間が見る用のログにはハッシュ化したトークンを保存し、終端ではハッシュの一致を見て弾くような仕組みにすると良い。(というか人間が遮断するフローは必須なのでこの仕組み一択かもしれないけど)

一定時間という判定方式では後続で障害が起きたときに連鎖的に誤った遮断が多発する可能性があるため、何を見て遮断するかに関してはもう少し練る必要がある。

このリクエストを遮断するという方法はGraphQL特有のサービス拒否攻撃に限らず、通常のサービス拒否攻撃に対して効果が高いため、どのような方法で対策するかに関わらず導入した方が良い。

余談だが、ここでIPを使わないのは最近の攻撃ではIP制限は有効な方法ではないからになる。
本格的に狙ってくる攻撃者はIPを潤沢に用意しており1つのIPを制限してもすぐに新しいIPでアクセスしてくる。UAや通常のリクエストに乗ってくる情報のほとんどはすぐに切り替えてくると考えた方が良い。
そうなると有効な遮断に有効なものとしては、自身がコントロール可能で、絶対量が少ない認証トークンが扱いやすい。(ただしトークンが簡単に再発行できると意味がないので調整は必要)

まとめ

と言うわけで攻撃についてどうすべきか考えてみた。
この手の話は多層防御が基本でどれか1つをやれば良いというより、それぞれ違う対策を行い少しずつカバー範囲が違うことでより強固なシステムにすることができる。

僕がこれからGraphQLの立ち上げを行うなら、スケジュールと予算が合えばたぶん全部やると思う。
(クエリの深さの制限と、コストの制限は情報として記録して分析、遮断可能な状態にするまでで止めるかもだけど)

Discussion