⚙️

graphql-rubyで複雑なQueryを制限する (DoS対策)

2023/11/11に公開

はじめに

先日公開された記事「開発者フレンドリーは“ハッカーにとってもフレンドリー” 「GraphQL」の機能を使った悪用事例と究極の対策」を拝見して、graphql-ruby で複雑な Query を制限して DoS 対策できるか確認してみました。

https://logmi.jp/tech/articles/329491

環境

Ruby, Gem

  • ruby 3.2.2
  • rails 7.1.1
  • graphql 2.1.6
  • graphiql-rails 1.9.0

GraphQL スキーマ

"""
Requires that exactly one field must be supplied and that field must not be `null`.
"""
directive @oneOf on INPUT_OBJECT

"""
Exposes a URL that specifies the behavior of this scalar.
"""
directive @specifiedBy(
  """
  The URL that specifies the behavior of this scalar.
  """
  url: String!
) on SCALAR

type Comment {
  content: String!
  id: ID!
}

type Mutation {
  """
  An example field added by the generator
  """
  testField: String!
}

"""
A blog post
"""
type Post {
  """
  This post's comments, or null if this post has comments disabled.
  """
  comments: [Comment!]
  content: String!
  id: ID!
  title: String!
}

type Query {
  """
  Find a post by ID
  """
  post(id: ID!): Post

  """
  An example field added by the generator
  """
  testField: String!
}

実行例

query

Query
{
  post(id: 1) {
    id
    title
    content
    comments {
      id
      content
    }
  }
}
Result
{
  "data": {
    "post": {
      "id": "1",
      "title": "Post 1",
      "content": "Content for post 1",
      "comments": [
        {
          "id": "1",
          "content": "Comment 1"
        },
        {
          "id": "2",
          "content": "Comment 2"
        },
        {
          "id": "3",
          "content": "Comment 3"
        }
      ]
    }
  }
}

複雑な Query を制限 (DoS 対策)

タイムアウト設定

複雑な Query で処理に時間がかかる場合、タイムアウトを設定することによって、その時間を超えたときに中断してエラーを返すことができます。

詳しくはこちらの「Timeout」を参照ください。
https://graphql-ruby.org/queries/timeout.html

コード例

class MySchema < GraphQL::Schema
  use GraphQL::Schema::Timeout, max_seconds: 3 # タイムアウトを3秒で設定
end
module Types
  class QueryType < Types::BaseObject
    def post(id:)
      # - 省略 -
      sleep 5 # タイムアウトするように、sleepを追加
      # - 省略 -
    end
  end
end

実行例

timeout

Query
{
  post(id: 1) {
    id
    title
    content
    comments {
      id
      content
    }
  }
}
Result
{
  "data": {
    "post": null
  },
  "errors": [
    {
      "message": "Timeout on Post.id",
      "locations": [
        {
          "line": 3,
          "column": 5
        }
      ],
      "path": [
        "post",
        "id"
      ]
    }
  ]
}

Complexity 制限

リクエストされた Query を解析して、設定された Complexity を超えたときにエラーを返すことができます。

詳しくはこちらの「Prevent complex queries」を参照ください。
https://graphql-ruby.org/queries/complexity_and_depth#prevent-complex-queries

コード例

class MySchema < GraphQL::Schema
  max_complexity 10 # 最大のComplexityを10で設定
end

実行例

complexity

Query
{
  post1: post(id: 1) {
    id
    title
    content
    comments {
      id
      content
    }
  }
  post2: post(id: 1) {
    id
    title
    content
    comments {
      id
      content
    }
  }
  post3: post(id: 1) {
    id
    title
    content
    comments {
      id
      content
    }
  }
}
Result
{
  "errors": [
    {
      "message": "Query has complexity of 21, which exceeds max complexity of 10"
    }
  ]
}

Depth 制限

リクエストされた Query を解析して、設定された Depth を超えたときにエラーを返すことができます。

詳しくはこちらの「Prevent deeply-nested queries」を参照ください。
https://graphql-ruby.org/queries/complexity_and_depth#prevent-deeply-nested-queries

コード例

class MySchema < GraphQL::Schema
  max_depth 2 # 最大のComplexityを2で設定
end

実行例

depth

Query
{
  post(id: 1) {
    id
    title
    content
    comments {
      id
      content
    }
  }
}
Result
{
  "errors": [
    {
      "message": "Query has depth of 3, which exceeds max depth of 2"
    }
  ]
}

独自の Analyzer で制限

上記のようにタイムアウトや Complexity, Depth で制限できますが、独自な制限を行いたい場合があります。
その場合、独自の Analyzer を用意して、そちらで制限することが可能です。

詳しくはこちらの「Ahead-of-Time AST Analysis」を参照ください。
https://graphql-ruby.org/queries/ast_analysis

例として、Query の条件数で制限するものを用意していました。

コード例

class MaxQueryCountAnalyzer < GraphQL::Analysis::AST::Analyzer
  def initialize(query)
    @query_count = 0
    super
  end

  def on_enter_field(node, parent, visitor)
    @query_count += 1 if parent.is_a?(GraphQL::Language::Nodes::OperationDefinition)
  end

  def result

    if @query_count > 1
      GraphQL::AnalysisError.new("Query count of #{@query_count}, which exceeds max count of 1")
    else
      nil
    end
  end
end

class MySchema < GraphQL::Schema
  query_analyzer(MaxQueryCountAnalyzer)
end

実行例

analyzer

Query
{
  post1: post(id: 1) {
    id
    title
    content
    comments {
      id
      content
    }
  }
  post2: post(id: 1) {
    id
    title
    content
    comments {
      id
      content
    }
  }
  post3: post(id: 1) {
    id
    title
    content
    comments {
      id
      content
    }
  }
}
Result
{
  "errors": [
    {
      "message": "Query count of 3, which exceeds max count of 1"
    }
  ]
}

まとめ

GraphQL の Query は柔軟に記載できるので、悪意ある Query を受け取る場合もあります。
悪意ある Query を受け取っても問題ないように、タイムアウトや Complexity, Depth で制限する設定を追加してみてはいかがでしょうか?

Discussion