GraphQL攻撃への防御:よくある脆弱性の徹底解説
本記事では、最もよくあるGraphQLの脆弱性について、それが発生する理由と緩和方法を詳しく解説します。
GraphQL攻撃への防御:よくある脆弱性の徹底解説
GraphQLは、柔軟で効率的なデータクエリの手法により、API開発に革命をもたらしました。しかし、他のあらゆる技術と同様に、GraphQLにも固有のセキュリティ上の課題があります。
本記事は、GraphQLの脆弱性の検出とテストを自動化してきたOstorlabでの当社の経験を反映したものです。
最もよくあるGraphQLの脆弱性について、それが発生する理由と緩和方法を詳しく見ていきます。
GraphQLとは
GraphQLは、API向けのクエリ言語であり、そのクエリを実行するためのランタイムです。クライアントは必要なデータを過不足なく正確にリクエストできるため、APIとのやり取りを大幅に最適化できます。
GraphQLは2012年にFacebookによって開発され、2015年にオープンソースとして公開されました。データの過剰取得や取得不足といった、従来のREST APIの制約を克服するために設計されたものです。
Wappalyzerによると、現時点で176,000以上のWebサイトがGraphQLを使用しています。AWS、PayPal、GitHubをはじめ、GraphQLを採用する企業が増えるにつれて、この数は急速に増加しています。全リストはGraphQL Landscapeを参照してください。

query CurrentUser {
currentUser {
name
age
}
}
レスポンス:
{
"currentUser":{
"name":"John Doe",
"age":23
}
}
主な特徴
- 単一のエンドポイント:GraphQL APIは単一のエンドポイントを公開し、すべてのやり取りはそこを通じて行われます。
- 正確なデータ取得:クライアントは必要なものだけを正確にリクエストするため、ネットワークのオーバーヘッドが削減されます。
- 強く型付けされたスキーマ:明確に定義されたスキーマが型と関係を規定し、強力なクエリを可能にします。GraphQLに組み込まれたスキーマの整合性と型安全性により、データのバージョン管理が不要になります。

GraphQLの型
GraphQLのスキーマは、主に3つの型で構成されます。
- ルート型
- スカラー型
- オブジェクト型

ルート型
1. クエリ(Query):サーバーからデータを取得します。
2. ミューテーション(Mutation):データを変更・操作します(作成、更新、削除)。
3. サブスクリプション(Subscription):クライアントがリアルタイムでデータの更新を受け取れるようにします。

スカラー型
スカラー型は、整数、文字列、真偽値などの単純な値を表します。これらはスキーマの基本的な構成要素です。
オブジェクト型
オブジェクト型は、複数のフィールドを持つ複雑なエンティティを表します。これらのフィールドは、スカラー型でも他のオブジェクト型でもかまいません。例を示します。
type Human {
id: String
name: String
homePlanet: Planet
}
type Planet {
id: String
name: String
}

GraphQLスキーマの発見とイントロスペクション
GraphQLにはイントロスペクションが用意されており、開発者はスキーマに問い合わせて、利用可能な型、クエリ、ミューテーションを把握できます(RESTにおけるOPTIONSリクエストのようなものと考えてください)。
イントロスペクションはそれ自体がセキュリティ上の問題ではなく、開発時には便利ですが、攻撃者はこれを悪用してAPIの機能をより深く理解し、GraphQL APIを不正に利用する可能性があります。

イントロスペクションが有効な場合
すべてのミューテーションを取得するイントロスペクションクエリの例:
リクエスト
{
__schema {
mutationType {
kind
name
fields {
name
description
deprecationReason
}
}
}
}
レスポンス
{
"__schema":{
"mutationType":{
"kind":"OBJECT",
"name":"Mutation",
"fields":[
{
"name":"createUser",
"description":"Create a new user"
}
]
}
}
}
クエリ、ミューテーション、オブジェクト型を含むスキーマ全体をダンプできるイントロスペクションの例:
query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
subscriptionType { name }
types {
...FullType
}
directives {
name
locations
args {
...InputValue
}
}
}
}
fragment FullType on __Type {
kind
name
fields(includeDeprecated: true) {
name
args {
...InputValue
}
type {
...TypeRef
}
isDeprecated
deprecationReason
}
inputFields {
...InputValue
}
interfaces {
...TypeRef
}
enumValues(includeDeprecated: true) {
name
isDeprecated
deprecationReason
}
possibleTypes {
...TypeRef
}
}
fragment InputValue on __InputValue {
name
type { ...TypeRef }
defaultValue
}
fragment TypeRef on __Type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
}
}
}
}
}
}
`__schema`に対する正規表現フィルターの回避
開発者がイントロスペクションを無効にする際、正規表現を使ってクエリ内の__schemaというキーワードを除外することがあります。スペース、改行、カンマなどの文字を試してみてください。これらはGraphQLでは無視されますが、不備のある正規表現フィルターでは無視されません。
たとえば、開発者が __schema{ だけを除外している場合、__schema の後に改行を入れた次のイントロスペクションクエリは除外されません。
{
"query": "query{__schema
{queryType{name}}}"
}
サジェストとエラー処理からのスキーマの推測
イントロスペクションが無効になっていても、サーバーが返すサジェストやエラーメッセージを利用して、GraphQLスキーマの一部を推測できる場合があります。たとえば、リクエストに含まれるフィールド名のスペルが正しくないものの既存のフィールドに近い場合、GraphQLサーバーは正しいフィールド名を提案するエラーを返すことがあります。

例:

ミューテーションや引数の推測についても同様です。

イントロスペクションの無効化
可能であれば、イントロスペクションは本番環境では無効にし、開発中のみ有効にすべきです。 GraphQLの仕様には次のように記されています。
「スキーマ内で定義されるすべての型およびディレクティブは、__(2つのアンダースコア)で始まる名前を持ってはならない。これはGraphQLのイントロスペクションシステム専用に予約されているためである。」
イントロスペクションを無効にする一般的な方法は、__で始まるフィールドを除外することです。
この方法はイントロスペクションを効果的に制限しますが、この設定をすべての環境に一貫して適用することが不可欠です。
サービス拒否(DoS)攻撃
このセクションでは、攻撃者がアプリケーションに対して用いることができる(そして実際に用いるであろう)さまざまなDoS(サービス拒否)攻撃を詳しく見ていきます。
GraphQLはアプリケーションのデータをグラフとして公開し、クライアントはノード(型)間の関係をたどってデータを取得できます。
以下に挙げる脆弱性のほとんどは、GraphQLのグラフの部分に起因します。
循環フラグメント(重大度:低)
GraphQLのフラグメントを使うと、再利用可能なフィールドを定義してクエリのロジックを再利用できます。
しかし、循環フラグメントを使うと、サーバーのリソースを過剰に消費するクエリを作成できてしまいます。
循環フラグメントの例:
fragment UserFields on User {
comments {
...CommentFields
}
}
fragment CommentFields on Comment {
owner {
...UserFields
}
}
ご覧のとおり、UserFieldsフラグメントは CommentFields を参照し、CommentFields は UserFields を参照し返しているため、無限ループが発生します。

GraphQLサーバーは通常、循環するフラグメント参照を含むクエリを識別して拒否し、エラーメッセージを返します。
しかし場合によっては、サーバーの実装がGraphQLの仕様に厳密に従っていないことがあります。
そのため、追加の対策を講じる必要があります。
循環フラグメントの検出:GraphQL-ESLintのようなスキーマ解析ツールを使って、循環フラグメントを検出・防止します。
クエリコスト分析の実装:フィールドとクエリにコストを割り当て、あらかじめ定めた上限を超えるものを拒否します。たとえばApollo Serverを使用している場合は、graphql-query-complexityライブラリを利用できます。
const {
queryComplexity,
simpleEstimator
} = require('graphql-query-complexity');
const server = new ApolloServer({
schema,
validationRules: [
queryComplexity({
estimators: [
simpleEstimator({
defaultComplexity: 1
})
],
maximumComplexity: 1000, // Reject queries that exceed this cost
}),
],
});
エイリアスのオーバーロード(重大度:低)
GraphQLにおけるエイリアスのオーバーロードは、攻撃者がクエリ内で大量のエイリアスを使用し、サーバーの処理能力を圧迫することで発生します。
GraphQLでは、エイリアスを使うことで、クライアントは同じフィールドを異なる名前で複数回リクエストできます。しかし、一つのクエリでエイリアスを過剰に使用すると、サーバーのリソースが枯渇し、サービス拒否(DoS)攻撃につながる可能性があります。この攻撃により、パフォーマンスが低下したり、サービスが完全に停止したりすることがあります。
例:
query AliasOverLoading {
alias1: __typename
alias2: __typename
alias3: __typename
alias4: __typename
alias5: __typename
}
エイリアスのオーバーロードがセキュリティに与える影響:
エイリアスのオーバーロードは、GraphQL APIに重大なセキュリティリスクをもたらします。主な影響の一つが サービス拒否(DoS) であり、過剰な数のエイリアスを含むクエリによって、サーバーは処理と応答のために不釣り合いなリソースを割り当てざるを得なくなります。これによりサービスが遅延したりクラッシュしたりして、可用性が損なわれる可能性があります。さらに、多数のエイリアスを同時に処理することでメモリの枯渇、CPU使用率の急上昇、深刻なパフォーマンス低下が起こり、サーバーのリソースが枯渇するおそれがあります。
エイリアスのオーバーロードのリスクを緩和するために、いくつかの戦略を実装できます。まず、クエリのタイムアウトを強制すること が効果的な対策です。最大実行時間を設定することで、サーバーは解決に時間がかかりすぎるクエリを自動的に終了でき、悪意のあるクエリがリソースを圧迫してDoSを引き起こすのを防げます。
もう一つの重要な対策は、前のセクションで取り上げたように エイリアスの数を制限すること です。
循環参照(重大度:中)

攻撃者はこれを悪用して再帰的なクエリを作成し、サーバーのリソースを圧迫して サービス拒否 と リソースの枯渇 を引き起こすことができます。
この脆弱性の影響を(実際の対象で)テストした際には、Django、graphene-python、そして42 GBのメモリ、8 vCPU、2.2 TBのSSDストレージを備えたCloud SQLインスタンスを使用しました。
当社は、Cloud SQLインスタンス のCPU使用率を100%に到達させる再帰的なクエリを作成することに成功し、ハングしたSQLトランザクションを手動で強制終了するまで問題は続きました。

次の型を使って、ユーザーと、そのユーザーが投稿したコメントのリストを公開したいとします。
type User {
id: ID!
comments: Comment
username: String
}
type Comment {
id: ID
owner: User!
content: String!
}
User 型は 循環参照 をもたらし、これを使って複雑なクエリや再帰的なクエリを作成できます。
循環クエリの例:
query {
user(id: 1) {
id
comments {
id
owner {
id
comments {
id
owner {
id
... # can go forever
}
}
}
}
}
}

GraphQLにおける循環参照のリスクを緩和するには、次の方法があります。
スキーマをリファクタリングする:可能な限り、直接的な循環参照を避けます。たとえば、`Comment` 型の中で新しい型 `LimitedUser` を使い、ループを断ち切ります。
type User {
id: ID!
username: String!
comments: [Comment]!
}
type LimitedUser {
id: ID!
username: String!
}
type Comment {
id: ID!
owner: LimitedUser!
content: String!
}

ただし、この解決策はクライアントを壊す可能性があり、コードベースに大きな変更が必要になるため、効果的でない場合もあります。
クエリの深さ制限を強制する:クエリがどこまで深くなれるかに上限を設け、過度に深いクエリを防ぎます。
ほとんどのGraphQLサーバー実装は、この機能をデフォルトで提供しています。たとえばApollo Serverでは次のとおりです。
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
schema,
validationRules: [depthLimit(10)], // Set maximum query depth to 10
});
エイリアスのバッチ処理によるログインのブルートフォース(重大度:中)
GraphQLにおけるエイリアスのバッチ処理を用いたログインのブルートフォースとは、攻撃者がエイリアス機能を利用してログイン試行を自動化し、一つのクエリで多数の認証情報の組み合わせを送信しやすくする手法です。
GraphQLでは、エイリアスを使うことで、クライアントは同じクエリの複数のバージョンを異なる名前で送信できます。攻撃者はこれを悪用し、試行ごとに異なるエイリアス名を使ってログインリクエストを一つのクエリにまとめます。これにより、従来のレート制限による保護を回避し、認証システムをログイン試行で圧迫する効率的なブルートフォース攻撃が可能になります。
例:
query loginBatch {
login1: login(username: "user1", password: "password1") {
token
}
login2: login(username: "user2", password: "password2") {
token
}
login3: login(username: "user3", password: "password3") {
token
}
...
}
この問題を緩和するには、一つのクエリで許可される エイリアスの数を制限 すべきです。サーバー側で制限を設定するか、GraphQL Armorのようなツールを使用することで、リクエストあたりのエイリアス数に上限を設けられます
ユーザーごとのログイン失敗回数を制限することも、この問題の解決に効果的です。
GraphQLにおける認可の設定ミス(重大度:高)
GraphQL APIでは、機密データへのアクセスがあるクエリ経路では正しく制限されているにもかかわらず、アクセス制御のチェックに一貫性がないために別の経路では露出したままになっているという、深刻な脆弱性が発生することがあります。攻撃者は、制限を回避する別のクエリ経路をたどることで、権限のないデータを取得できます。
この種の脆弱性は容易に発生します。
Djangoとdjango-grapheneを使った簡単な例を示します。
ユーザー同士が会話したり、投稿を公開したりできる小さなソーシャルネットワークプラットフォームがあるとします。
from django.db import models
from django.contrib.auth.models import AbstractUser
class SocialUser(AbstractUser):
pass
class Post(models.Model):
user = models.ForeignKey(SocialUser, related_name='posts', on_delete=models.CASCADE)
content = models.TextField()
class Discussion(models.Model):
user = models.ForeignKey(SocialUser, related_name='discussions', on_delete=models.CASCADE)
content = models.TextField()
そして`graphene_django`を使って、次の型を定義しています。
class DiscussionType(DjangoObjectType):
class Meta:
model = models.Discussion
class PostType(DjangoObjectType):
class Meta:
model = models.Post
class UserProfileType(DjangoObjectType):
class Meta:
model = models.SocialUser
次のクエリを宣言します。
class Query(graphene.ObjectType):
my_discussions = graphene.List(DiscussionType)
posts = graphene.List(PostType)
my_profile = graphene.Field(UserProfileType)
def resolve_my_discussions(self, info):
user = info.context.user
if user.is_anonymous:
raise Exception("Not logged in!")
return user.discussions.all() # Only the discussions of the logged in user
def resolve_posts(self, info):
user = info.context.user
if user.is_anonymous:
raise Exception("Not logged in!")
return models.Post.objects.all() # All posts are public of course.
def resolve_my_profile(self, info):
user = info.context.user
if user.is_anonymous:
raise Exception("Not logged in!")
return user
一見すると、ここには何の問題もありません。ユーザーは自分自身の ディスカッション しか見ることができません。

また、他のユーザーの公開投稿のリストも取得できます。

しかし、`PostType` 型をよく見ると、UserProfileTypeのフィールドを持っていることがわかります。

これは、Postモデルが SocialUser モデルへの `ForeignKey`を持っており、django-grapheneが最初に見つけたオブジェクト型を使って公開したためです。

そのため、次のクエリを実行することで、他のユーザーのディスカッションに不正にアクセスできます。

GraphQLの拡張機能:デバッグモードを例に
GraphQLの拡張機能とは
GraphQLの拡張機能とは、GraphQLの環境に新しい機能を追加するコードのことです。新しいObjectTypeの追加やデバッグ情報の公開など、GraphQLが通常単独では行わないことを実現するのに役立ちます。
便利な機能ではありますが、Graphene-Django や graphql-ruby など一部のGraphQLサーバーでデフォルトで実装されている拡張機能の中には、深刻なセキュリティ問題を引き起こすものがあります。
GraphQLのデバッグ(重大度:高)
GraphQLで問題に対処する際、開発者はデバッグ情報を活用します。
デバッグモードが有効になっていると、GraphQLサーバーはクライアントのリクエストに対し、通常は表示されないバックエンドサーバーのエラーについて詳細なメッセージを返します。
たとえば、クライアントは標準的なエラーメッセージの代わりに、スタックトレースやエラーに関する詳細な情報を受け取ることがあります。
GraphQLのデバッグモードは多くのGraphQL実装でデフォルトで実装されていますが、すべてではありません(下の表を参照)。
これは開発中には非常に役立ちますが、本番環境で有効のままにしておくと、サーバーの内部構造や実装の詳細に関する機密情報が露出するおそれがあります。
デバッグモードが有効な場合、エラーレスポンスには次の情報が含まれることがあります。
- 詳細なスタックトレース
- データベースクエリの情報(SQLクエリを含む)
- サーバー内部のパスとファイル名
- 機密性の高い設定の詳細
DjangoのデバッグをラップしているDjango Grapheneで、デバッグモードを有効にした場合のレスポンスの例:
{
query {
nonexistentField {
id
}
}
_debug {
sql {
sql
transId
transStatus
isoLevel
encoding
}
}
}
{
"errors":[
{
"message":"Cannot query field \"nonexistentField\" on type \"Query\".",
"locations":[
{
"line":3,
"column":3
}
],
"path":[
"nonexistentField"
]
}
],
"data":null,
"extensions":{
"debug":{
"sql":[
{
"sql":"SELECT \"auth_user\".\"id\", \"auth_user\".\"password\", \"auth_user\".\"last_login\", \"auth_user\".\"is_superuser\", \"auth_user\".\"username\", \"auth_user\".\"first_name\", \"auth_user\".\"last_name\", \"auth_user\".\"email\", \"auth_user\".\"is_staff\", \"auth_user\".\"is_active\", \"auth_user\".\"date_joined\" FROM \"auth_user\" WHERE \"auth_user\".\"id\" = %s LIMIT 21",
"time":"0.000",
"params":[
"1"
]
}
],
"python_version":"3.8.5",
"django_version":"3.1.3",
"graphene_version":"2.1.8",
"graphql_version":"3.0.0"
}
}
}
GraphQLのデバッグモードがセキュリティに与える影響:
-
情報漏えい:デバッグ情報によって、GraphQLサーバーの構造、依存関係、潜在的な脆弱性に関する内部の詳細が明らかになり、攻撃者がより的を絞った攻撃を計画するために利用するおそれがあります。
-
機密データの露出:スタックトレース、エラーメッセージ、特にSQLクエリには、データベースの構造、内部のファイルパス、環境変数などの機密情報が意図せず含まれる可能性があります。
-
悪用の容易化:詳細なエラーメッセージやSQLクエリは、悪意のあるクエリで何が成功し何が失敗したかについて即座にフィードバックを与えるため、攻撃者が攻撃を洗練させる手助けとなります。
-
パフォーマンス情報の漏えい:SQLクエリについて提供されるタイミング情報は、攻撃者がデータベースの構造を推測したり、タイミング攻撃を行ったりするために利用される可能性があります。
GraphQLのデバッグモードに関連するリスクを緩和するために、何よりもまず行うべきことがあります。
それは、本番環境で デバッグモードを無効にすること です。たとえば、DjangoをGraphene-Djangoと組み合わせて使用している場合、GraphQLのデバッグモードはDjangoのデバッグと同じ設定で制御されます。
settings.py
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
まとめ
GraphQLは、柔軟なクエリや効率的なデータ取得など、API開発に大きな利点をもたらす一方で、固有のセキュリティ上の課題ももたらします。これらの課題に対処するには、潜在的な脆弱性だけでなく、使用している特定のGraphQLサーバーと、それがサポートするセキュリティ機能についても深く理解する必要があります。
以下は、最も人気のあるGraphQLサーバーと、それぞれがサポートする機能の一覧です。
✅ - デフォルトで有効
⚠️ - デフォルトで無効
❌ - サポートなし
