GraphQL API を悪意あるクエリから守る手法

実サービスで GraphQL API をインターネットに公開する際は、悪意あるクエリに対する防衛が欠かせません。この記事における「悪意あるクエリ」とはサービスに意図的に負荷をかけるクエリのことです。GraphQL では 、木構造再帰的な構造を利用して、一回のクエリで容易に数百万・数千万件のデータを取得することができます。そのようなクエリを実行してしまうと、アプリケーションサーバーや、その後ろにいる別のサービスに甚大な負荷がかかります。これは攻撃者からしてみれば恰好の的で、なんらか対策を講じる必要があります。

幸いこうした問題はよく知られており、クエリを静的に解析するライブラリがいくつか存在します。しかし、そうしたライブラリをどう使うかといったことはあまり議論されておらず、効果的な対策を行うのは依然として難しい状況だと感じます。この記事では、典型的な負荷の高いクエリとその具体的な対策を紹介し、悪意あるクエリから GraphQL API を守る知見を展開したいと思います。

サービスに意図的に負荷をかけるクエリ

ここでは典型的な負荷の高いクエリを3種類紹介します。簡単のためデータは RDB から取得するということにしましょう(現実には別の API など様々なデータソースが想定されます)。

データを大量に取得するクエリ

GraphQL では、以下のようなクエリで簡単に大量のデータを取得することができます。firstSQL でいう limit で、取得する件数を指定する引数です。もしデータが十分に存在していたら、このクエリで 100 countries × 100 cities x 1000 streets = 1000万ノード 取得することができます。

{
  countries(first: 100) {
    name
    cities(first: 100) {
      name
      streets(first: 1000) {
        name
      }
    }
  }
}

重い計算をさせるクエリ

以下のクエリの superComplexStatistics は非常に重い統計値を計算するフィールドです。仮に1回の計算に1秒かかるとしましょう。すると、このクエリでは city を 10,000 件取得するので、superComplexStatistics を 10,000 回計算することになります。これだけで 10,000 秒(約3時間)かかります。

{
  countries(first: 100) {
    cities(first: 100) {
      superComplexStatistics
    }
  }
}

タイムアウトを設定することで多少は問題を回避できますが、タイムアウトに達するまで CPU を使い続けるようなクエリを何度も実行するのは避けたいものです。

大量にDBアクセスを発生させるクエリ

GraphQL ではデータ同士に関連がある場合は実体を結びつけるのが良い、つまり RDB 上で city が country の id なりを持っていなるなら、country の実体を引けるようにする方が良いとされています。この方針に従ってスキーマを設計すると、country --> cities[0] --> country --> cities[0] ... といったように無限に循環する構造を作ることができます。これを利用すると以下のようなクエリを書くことができます。

{
  countries(first: 1) {
    cities(first: 1) {
      country {
        cities(first: 1) {
        ...(これがあと996段続く)
        }
      }
    }
  }
}

基本的に GraphQL サーバーは、あるフィールドを resolve して、そのあと子フィールドを resolve するというように動きます。つまり素朴に実装するとネスト1段ごとに1回 DB アクセスが発生することになります。この例だと、データ量こそ大したことはないですが、ネストが 1000 段あるので DB アクセスが 1000 回発生することになります。アプリケーションレイヤーでのキャッシュなどを工夫すれば問題を回避できる場合もありそうですが、一般の場合に備えるのは難しいでしょう。

悪意あるクエリへの対策

GraphQL API がインターネットに公開されている限り、上述のようなクエリによって GraphQL サーバーや DB が負荷にさらされるリスクは常に存在します。こうしたクエリに対する基本的な対応方針は、クエリを実行前に解析し負荷の問題がない場合に制限して実行するというものです。この記事では「ホワイトリストによる制限」と「complexity による制限」の2つの対策を紹介します。

ホワイトリストによる制限

これは、GraphQL API が受け付けるクエリ一覧をホワイトリストとして持っておいて、クライアントから送られてきたクエリがそれに一致する場合のみ実行するやり方です。この手法のメリットとしては、導入が簡単であることが挙げられます。クエリが文字列として一致するかを検査するだけでよいため非常に簡単です。また、導入が簡単である割に非常に高い効果が期待できます。実際にクライアントで使うクエリのみを実行するため攻撃の余地がありません。一方で、デメリットもいくつかあります。ひとつは、ホワイトリストのメンテナンスコストです。クライアント側を更新するたびにホワイトリストを更新する必要があり、なんらか仕組みが必要になります。もうひとつのデメリットは、そもそも適用できる場面が限られることです。まず、公開 API ではユーザーが自由にクエリを書ける必要があるためホワイトリストは使えません。また、クライアントが複数あったり、クライアントの開発が別チームで行われていたりすると、ホワイトリストの運用は格段に難しくなります。次に紹介する complexity による制限のように、サーバーサイドで完結するほうが簡単である場合もあるでしょう。

complexity による制限

これは、クエリの負荷に比例して大きくなる数値(=complexity)を計算し、その数値が閾値に収まっている場合のみ実行するやり方です。任意のクエリに対して適用できるので、ホワイトリストによる制限が使いづらい場合は、まず complexity による制限を検討することになるでしょう。以下では典型的な負荷について complexity の例を紹介します。

まずはデータ量に関する complexity です。最も素朴なのはノード数を見積もることです。例えば以下のクエリではノード数は 50 countries + 50 × 100 cities = 5050 nodes となります。この値が一定以下の場合のみ実行することにすれば、異常なデータ量を処理してしまうことはなくなります。

{
  countries(first: 50) {
    name
    cities(first: 100) {
      name
    }
  }
}

ノード数を計算する場合、リスト(Relay の言葉では Connection)を取得するフィールドで、first のような件数が分かる引数が必須になっていることが重要です。こうした引数なしに100件固定で取得するフィールドなどが存在すると、そこを起点に大量にデータを取得できてしまう可能性があります。後述する cost ディレクティブなどを使えば対応は可能ですが、当然、クエリ解析の複雑さは増します。GitHub GraphQL API v4 では、まさにこうしたノード数による制限を行っており、かなり妥当な方針であると考えて良さそうです。

次は計算量に関する complexity です。典型的なやり方は、スキーマ側に directive を使って計算コストの情報を付加するというものです。例えば、以下のようにして計算量の多いフィールドに directive をつけることが考えられます。この場合は superComplexStatistics にコスト10を与えています。

type City {
  name
  superComplexStatistics @cost(n: 10)
}

GraphQL サーバーはこの情報をもとに complexity を計算します。例えば以下のようなクエリの計算量は (100 × 100 cities) × 10 cost = 100,000 と計算できます。

{
  countries(first: 100) {
    cities(first: 100) {
      superComplexStatistics
    }
  }
}

このディレクティブを使った制限は用途が広く、こうした計算量だけでなく、DBアクセス回数や、first などの引数を取らない全件取得フィールドの負荷を表現するのにも使えます。

それ以外の complexity として、クエリの文字列長やネストの深さがよく話題に上りますが、一般には負荷に比例しない complexity で、あまり筋の良い方針ではないと考えます。自分のスキーマを眺めてみて、文字列長や深さによって悪意あるクエリを制限できているかをよく検討する必要があるでしょう。スキーマによってはこうした簡単な指標で十分な場合もあり、選択肢としてないわけではありません。

実装と課題

complexity による制限を行うための実装はいくつか存在します。例えば JavaScriptgraphql-cost-analysis や Go の gqlgen が備える complexity limit があります。ライブラリが存在しない言語でも、クエリ解析のみをマイクロサービスとして切り出して、サポートの厚い言語で実装するといったことが考えられます。

しかし、そうしたライブラリのサポートがあってもなお、complexity による制限には多くの課題があります。ひとつは、complexity 設計のベストプラクティスが十分に蓄積されていないことです。本記事ではノード数・計算量といった典型的な負荷にフォーカスして対策を紹介しましたが、そもそもこのように掘り下げて議論している資料からして多くありません。自分で一から悪意あるクエリの対策を検討してみると、十中八九、無数の細かいデザインチョイスに苦しむことになるでしょう。また、本記事ではノード数・計算量・DBアクセス回数といった複数の complexity を紹介しましたが、それらを併用する風潮はあまりなく、complexity がひとつの場合を厚くサポートする実装が多いのが現状です。そうなると、必然的にひとつの complexity の中に複数の尺度を押し込むことになり、コストや閾値の設定が格段に難しくなります。DBアクセス1回はノード数いくつ分にあたるでしょうか。ある重い計算はDBアクセス何回分でしょうか。それらが複合したときの適切な閾値はいくつでしょうか。今のところ、こうした値を実測値などに基づいて無理やりこじつけていく必要があります。graphql-cost-analysis は複数の complexity による制限が可能な設計にはなっており、より厚いサポートが期待されます。

まとめ

この記事では、攻撃として利用できる典型的な負荷の高い GraphQL クエリを挙げ、そうしたクエリを判別するための静的解析の手法を2つ紹介しました。ひとつはホワイトリストによる制限です。単にクエリ文字列の一致を見るだけで良く、効果も高いですが、使える場面が限られます。もうひとつの手法は complexity による制限です。任意のクエリに適用可能ですが、ベストプラクティスが確立されていない面があり、効果的な対策を行う難易度は高いです。今後のライブラリの発展が期待されます。

最後に補足として、本記事で紹介したような対策は必ずしも完璧に行う必要はありません。そこまで高い安全性が必要でなければ、特に危険なノード数に限って complexity による制限を行うといったことも考えられます。そもそも対策を全く行わず、悪意あるクエリを送ってくるクライアントを ban するだけで事足りるかもしれません。最も重要なのは、リスクと対策の全体像を把握した上で要件に合わせた手法を採用することです。