環境
- java 17
- spring-boot 3.1.2
- graphql-java 21.0
発端
GraphQL API の利用者から「クエリが実行できない!」という報告を受けました。
報告があったエラーは以下。
graphql.parser.exceptions.ParseCancelledException:
More than 15,000 'grammar' tokens have been presented. To prevent Denial Of Service attacks, parsing has been cancelled.
メッセージを見てすぐに「巨大なクエリを送っているクライアント側の責任だろう」とは思いましたが、そんな制限を実装したつもりは無いし、 API ドキュメントでも明言していなかったので、発生源を確認しました。
サイズ制限エラーの発生源
ログにスタックトレースが残っていたので、発生源はすぐに特定できました。
graphql-java の ParserOptions.java を見ると制限内容や実装の意図がわかります。
A graphql hacking vector is to send nonsensical queries with lots of tokens that burn lots of parsing CPU time and burn memory representing a document that won't ever execute.
GraphQL サーバに巨大なクエリを送り付けることで計算リソースを過剰に消費させるような DoS 攻撃が成立し得るということで、デフォルトで制限が入っているということですね。
コードからは、以下の制限がデフォルトで適用されていることが見て取れます。
件のクライアントはこのひとつに該当してしまったというわけです。
制限 | デフォルト上限 |
---|---|
クエリのトークン数 | 15,000 |
クエリの文字数 1 | 1 MB |
クエリの深さ 2 | 500 |
クエリに含まれる空白の数 | 200,000 |
トークン数のカウント例
トークン数は、クエリの構成要素の数です。
例えばこのクエリは・・・
{
foo(input: {
attr1: "hoge"
attr2: 3
}) {
id
... on User {
name
address
}
}
}
以下の 25 トークンとして認識されます。
1 {
2 foo
3 (
4 input
5 :
6 {
7 attr1
8 :
9 "hoge"
10 attr2
11 :
12 3
13 }
14 )
15 {
16 id
17 ...
18 on
19 User
20 {
21 name
22 address
23 }
24 }
25 }
解決策
graphql-java がデフォルトで守ってくれていることはわかりました。
とは言ったものの、クエリを実行できない問題には何らかの解決策が必要なので、対応を考えます。
上限を増やす
JVM グローバルの設定として ParserOptions#setDefaultOperationParserOptions(ParserOptions) が用意されています。
これで設定値を調整することができます。
final ParserOptions newOpts =
ParserOptions.getDefaultOperationParserOptions()
.transform(
builder -> {
builder.maxTokens(100);
builder.maxCharacters(500);
builder.maxRuleDepth(10_000);
builder.maxWhitespaceTokens(100_000);
});
ParserOptions.setDefaultOperationParserOptions(newOpts);
・・・が、公開 API のように信頼できないクライアントからリクエストを受け付けなければならない場合は、緩和するという判断も簡単にはできないでしょう。
オススメは次の方法です。
variables や fragment を使って GraphQL クエリをコンパクトにする
サーバ側で制限を緩和するのではなく、制限内でクライアントに「うまいことやってもらう」方針です。
mutation の場合
トークン数の制限は、あくまでも GraphQL クエリに課せられる制限です。
variables は別腹です。
問題のクライアントは mutation
を呼び出していましたが、その入力パラメータが多すぎて件のエラーになっていました。
こういう場合は入力値を variables に移すことで制限を回避することができます。
それに、ユーザ入力のような動的入力項目はリテラルで指定するよりも variables で指定する方が安全 です。
#
# 変更前 (巨大な GraphQL クエリ)
#
{
foo(input: {
attr1: "hoge"
attr2: 3
# ... その他、ものすごい大量のパラメータを指定しているとする
}) {
id
... on User {
name
address
}
}
}
#
# 変更後
#
mutation foo($input: FooInput!) {
foo(input: $input) { # 入力値は variables に移すことでクエリ自体は短くできる
id
... on User {
name
address
}
}
}
注意として、
variables は HTTP POST データの一部なので、当然 HTTP サーバに設定されている制限を受けることになります。
Spring Boot で Tomcat を動かしているのであれば、クエリ含めて Tomcat の Max POST size (デフォルト: 2MB) 以内でなければなりません。
必要であれば、合わせてこのへんも調整が必要になります。3
query の場合
query
が巨大な場合は、共通項目が抜き出せる場合においては fragment で共通化することでクエリのサイズを小さくできるケースもあるでしょう。
#
# 変更前 (巨大な GraphQL クエリ)
#
query foo {
repositories {
name
author {
id
name
avatar
status
}
}
currentUser {
id
name
avatar
status
}
}
#
# 変更後
#
query foo {
repositories {
name
author {
...userFragment # fragment で共通点をまとめる
}
}
currentUser {
...userFragment
}
}
fragment userFragment on User {
id
name
avatar
status
}
今回の教訓
- graphql-java にはデフォルトでいくつかの制限が入っていることを認識する
- クライアントは、巨大な GraphQL ペイロードを送信する場合は variables や fragment の利用を検討する
- クライアントに影響を与える制限は、きちんとドキュメントに明記する (ココ重要)
-
制限に抵触すると ParseCancelledTooManyCharsException が発生します。 ↩
-
制限に抵触すると ParseCancelledTooDeepException が発生します。 ↩
-
まあ 2 MB の JSON なんて日常的に送りつけられたら、とてもサクサク応答なんてできないと思いますが... ↩