GraphQL APIのセキュリティ対策:ネストクエリDoS、Introspection悪用、フィールドレベル認可の欠陥を防ぐ

Security tutorial - IT technology blog
Security tutorial - IT technology blog

深夜2時の本番障害:GraphQLがクラスタをダウンさせた夜

火曜日の午前2時14分、PagerDutyのアラートが鳴り響きました。Node.jsマイクロサービスクラスタのCPU使用率は瞬時に100%へ跳ね上がり、レイテンシは25秒を超過、KubernetesはOOM(メモリ不足)エラーによりPodの強制終了を開始しました。不可解なことに、Nginx Ingressのログにはトラフィック急増の兆候は一切ありませんでした。リクエスト数は、ローテーションされる3つのプロキシIPからのわずか毎分120件のHTTP POSTリクエストにとどまり、ゲートウェイのレート制限をはるかに下回っていたのです。

数か月前にエッジファイアウォールやIngressゲートウェイの保護設定を完了していたにもかかわらず、わずかなリクエストによってコアAPIがダウンしてしまいました。ログストリームの生ペイロードを確認したところ、原因は一目瞭然でした。それは、激しくネストされた循環GraphQLクエリを含む、わずか800バイトのHTTP POSTペイロードだったのです。その単一のクエリがPostgreSQLを破滅的なリレーショナルJOINループに陥れ、利用可能だった50の接続プールすべてを数秒で枯渇させました。

その10分後、攻撃者がどのようにスキーマを特定したかが判明しました。攻撃者は/graphqlに対して自動化されたIntrospectionスキャンを実行し、内部型、Stripeの決済メタデータ、外部公開を意図していなかった未公開のAdmin Mutationなどを即座に露呈させていたのです。

根本原因の分析:従来のWeb防御策がGraphQLで通用しない理由

RESTエンドポイントは予測可能なコントラクトに従います。1つのURIが単一のコントローラアクションおよび既知のデータベースクエリに対応します。しかし、GraphQLはこのモデルを根底から覆します。クライアントがレスポンスの形状やリレーションの深さを決定するため、実行制御権がバックエンドからクライアントが送信するペイロード側へと移るのです。

1. 再帰的ネストクエリによるDoS攻撃

著者(author)と書籍(book)の標準的な双方向リレーションを考えてみましょう。スキーマがAuthor.booksとBook.authorを公開している場合、クライアントはこれらを無限にネストできてしまいます。

query MaliciousLoop {
  author(id: "1") {
    books {
      author {
        books {
          author {
            books {
              author {
                books {
                  title
                }
              }
            }
          }
        }
      }
    }
  }
}

一見無害に見える8階層のネストクエリでも、15,000回以上のデータベース操作を引き起こす可能性があります。さらに深刻なことに、一般的なWebアプリケーションファイアウォール(WAF)はリクエストパスや一般的なSQLインジェクションのシグネチャを検査します。ペイロードは正当なエンドポイントへの有効なJSONとして到達するため、WAFはそれをそのままNode.jsやPythonのランタイムへ転送してしまいます。

2. Introspectionの悪用:データグラフ全体の露出

GraphQLには、__schemaや__typeといったメタフィールドを通じたスキーマ探索機能が組み込まれています。これらのフィールドは、GraphiQLやApollo Studioなどのローカル開発ツールを動作させるためのものです。しかし、本番環境でこれらを有効のままにしておくと、攻撃者にデータモデルの完全なマップを差し出すことになります。

curl -X POST https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ __schema { types { name fields { name } } } }"}'

GraphQL Voyagerのようなツールや自動脆弱性スキャナーは、そのレスポンスを200ミリ秒未満でパースできます。攻撃者はエンドポイントを1つも推測することなく、隠されたフィーチャーフラグ、ステージング環境用のMutation、内部ユーザーモデルなどを即座に特定してしまいます。

3. 粗粒度な認可 vs フィールドレベルの認可

RESTでは、GET /api/v1/users/financialsのようなエンドポイントをルートミドルウェアレベルで保護します。一方、GraphQLでは複数の属性を1つのクエリにまとめます。ユーザープロフィールの取得において、無害な公開情報とリスクの高いフィールドが混在して返される可能性があります。

query GetUserProfile {
  user(id: "42") {
    id
    name
    socialSecurityNumber # アクセス制限
    accountBalance       # アクセス制限
  }
}

ルートリゾルバ(Query.user)でのみ認証・認可をチェックしていると、機密フィールドが無防備なままになります。認証されたユーザーであれば、セレクションセットに含めるだけで制限されたカラムのデータを取得できてしまいます。

対策の評価:各ソリューションの比較

これらの問題に直面したチームは、通常3つの防御モデルを検討します。

  • アプローチ1:リバースプロキシによるレート制限(IPまたはリクエスト数)
    メリット:Cloudflare、AWS WAF、Nginxなどで簡単に設定可能。
    デメリット:GraphQLには効果が薄い。攻撃者は毎分10件未満の悪意あるリクエストを送るだけで、CPUコアを飽和させ、データベーススレッドを枯渇させることができます。
  • アプローチ2:ペイロードサイズ制限と実行タイムアウト
    メリット:大容量のファイルアップロードをブロックし、5秒以上実行されているリクエストをキャンセルできる。
    デメリット:正当なバッチMutationに対する誤検知(フォールスポジティブ)が増加する。さらに、接続を5秒間保持するだけでも、持続的な攻撃を受けた際にデータベースの接続プールを枯渇させることが可能です。
  • アプローチ3:AST検証、深度制限、フィールドレベルのガード
    メリット:実行開始前にクエリ構造を解析可能。高負荷なクエリを2ミリ秒未満で拒否し、機密フィールドを個別に保護できる。
    デメリット:スキーマレベルの設定が必要であり、ASTパース時にわずかな事前計算オーバーヘッドが発生する。

本番環境対応の防御パイプライン

堅牢なGraphQLセキュリティは、公開環境でのIntrospectionのブロック、実行前における危険なクエリツリーの拒否、個々のフィールド単位での認可チェックの分離という、3つの相互補完的なレイヤーによって成り立ちます。

ステップ1:本番環境でIntrospectionを無効化する

環境設定に基づいてIntrospectionの有効/無効を切り替えます。以下はApollo Server v4での設定例です。

import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';

const isProduction = process.env.NODE_ENV === 'production';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: !isProduction,
  plugins: isProduction ? [ApolloServerPluginLandingPageDisabled()] : [],
});

利用しているフレームワークに標準のトグル機能がない場合は、__schemaや__typeを含むクエリをインターセプトして即座にエラーを返すAST検証ルールを登録します。

ステップ2:クエリの深度と複雑性の制限を適用する

データベースドライバに命令が送られる前のコンパイルフェーズで、不正なクエリを阻止します。graphql-depth-limitを導入することで、クエリが安全なネストのしきい値を超えないように保証できます。

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    // 5階層を超えるリレーションを持つクエリをブロック
    depthLimit(5),
  ],
});

深度制限と併せてgraphql-query-complexityを導入します。このパッケージは各クエリをフィールドの重み付けに基づいてスコアリングし、高負荷な操作をブロックします。

import { createComplexityRule, simpleEstimator } from 'graphql-query-complexity';

const complexityRule = createComplexityRule({
  maximumComplexity: 100,
  estimators: [simpleEstimator({ defaultComplexity: 1 })],
  onComplete: (complexity) => {
    console.log(`クエリの複雑性スコア: ${complexity}`);
  },
});

// validationRules配列にcomplexityRuleを追加

ステップ3:フィールドレベルの認可を実装する

ルートレベルのチェックを通過したからといって、すべてのネストされたプロパティを安全に返せると想定してはいけません。個々のフィールドリゾルバ内で、呼び出し元の権限を直接検証してください。

# Python(AriadneまたはStrawberryリゾルバの例)
def resolve_social_security_number(parent, info):
    current_user = info.context.get('user')
    
    if not current_user:
        raise PermissionError("認証が必要です。")
        
    # レコードの所有者または認可されたコンプライアンス担当者のみに許可
    is_owner = current_user.id == parent.id
    has_role = "COMPLIANCE_ADMIN" in current_user.roles
    
    if not (is_owner or has_role):
        return None  # または PermissionError を発生させる
        
    return parent.social_security_number

この多層防御パイプラインにより、サーバーリソースが消費される前に攻撃を食い止めることができます。Introspectionスキャンは即座に失敗し、再帰的クエリはデータベースにアクセスすることなく即座に検証エラーを返し、機密属性はクエリの構造に関係なく厳重に保護されます。

Share: