はじめに
GraphQLクライアントからクエリを呼び出す際、取得するデータに制限がない場合
意図しない膨大なデータ量のリクエストや、悪意のある攻撃を受けてしまう危険性があります。
そういった事態を防ぐために、GraphQLサーバー側で対策をすることができます。
環境
- GraphQL Server
- Node.js
- Express
- MongoDB
- Apollo Server
- GraphQL Client
- React.js
- Apollo Client
データの制限
下記のようなスキーマ・クエリを想定します。
type Photo {
id: ID!
url: String!
name: String!
postedBy: User!
taggedUsers: [User!]!
created: DateTime!
}
type User {
name: String
avatar: String
postedPhotos: [Photo!]!
inPhotos: [Photo!]!
}
query allPhotos {
allPhotos(first=99999) {
name
url
postedBy {
name
avatar
}
}
}
クエリの引数にfirst
を追加すると、一つのデータページが返却するデータ件数を指定することができます。
上記のクエリでは、first
に99999
を指定しているため、99999のデータ取得が発生してしまいます。
リゾルバに制限を加える
クエリリゾルバに引数のチェック処理を加えることで、データサイズを制限します。
allPhots: (root, data, context) {
if (data.first > 100) {
throw new Erroe('Only 100 photos can be requested at a time')
}
}
クエリ深さの制限
GraphQLがクエリに提供する利点の一つとして、関係するデータを一度に問い合わせられることです。
例えば、下記のようなPhoto APIがあったとします。
query getPhoto($id: ID!) {
Photo(id: $id) {
name
url
postedBy {
name
avatar
postedPhotos {
name
url
}
}
}
}
このクエリは、postedBy
とpostedPhoto
という2つの関連するフィールドを問い合わせているので、深さは3であると言えます。
query getPhoto($id: ID!) {
Photo(id: $id) {
name # 深さ:1
url # 深さ:1
postedBy {
name # 深さ:2
avatar # 深さ:2
postedPhotos {
name # 深さ:3
url # 深さ:3
}
}
}
}
さらに、上記のクエリを活用すると、下記のように、深さがより深くなるクエリも発行できてしまいます。
query getPhoto($id: ID!) {
Photo(id: $id) {
name # 深さ:1
url # 深さ:1
postedBy {
name # 深さ:2
avatar # 深さ:2
postedPhotos {
name # 深さ:3
url # 深さ:3
taggedUsers {
name # 深さ:4
avatar # 深さ:4
postedPhots {
name # 深さ:5
url # 深さ:5
}
}
}
}
}
}
クエリの深さが深くなると、サーバーへかかる負荷は指数関数的に増大してしまいます。
こういったクエリを許容しないために、クエリの深さ制限を実現するnpmパッケージもあります。
npm install graphql-depth-limit
上記パッケージをinstallすると、depthLimit関数を私用してGraphQLサーバーの設定にバリデーションルールを追加できます。
const { ApolloServer } = require(`apollo-server-express`);
const depthLimit = require('graphql-depth-limit');
.....
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)],
.....
})
クエリの複雑さ制限
次にクエリ複雑度
が高い場合のクエリを考えます。
上記のように深さがそこまで深くないクエリであったとしても、問い合わせられるフィールドの両が多いことで負荷が高くなるクエリがあります。
query everything($id: ID!) {
totalUsers # 深さ:1
Photo(id: $id) {
name # 深さ:1
url # 深さ:1
}
allUsers {
id # 深さ:2
name # 深さ:2
avatar # 深さ:2
postedPhotos {
name # 深さ:3
url # 深さ:3
}
inPhotos {
name # 深さ:3
url # 深さ:3
taggedUsers {
id # 深さ:4
}
}
}
}
everythingクエリはクエリの深さ制限5以内には収まっていますが、問い合わせられるフィールドの数が多く、非常に高コストな処理になります。
クエリ複雑度の制限の実装をサポートするnpmパッケージもあります。
npm install graphql-validation-complexity
このgraphql-validation-complexity
にはクエリの複雑度を決定するためのルールが定められています。
スカラーフィールドはそれぞれ値1が設定され、フィールドがリストなら10倍ずつ値を増やしたものが設定されています。
query everything($id: ID!) {
totalUsers # 複雑度:1
Photo(id: $id) {
name # 複雑度:1
url # 複雑度:1
}
allUsers {
id # 複雑度:10
name # 複雑度:10
avatar # 複雑度:10
postedPhotos {
name # 複雑度:100
url # 複雑度:100
}
inPhotos {
name # 複雑度:100
url # 複雑度:100
taggedUsers {
id # 複雑度:1000
}
}
}
} # 総複雑度1433
クエリの複雑度の制限を1000にすると、こういった特殊なクエリの実行を防ぐことができます。
const { createComplexityLimitRule } = require('graphql-validation-complexity')
.....
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(5),
createComplexityLimitRule(1000, {
onCost: cost => console.log('query cost: ', cost)
})
],
.....
}
この例では、最大複雑度を1000に制限しました。
また、それぞれのクエリの総コストが計算されたときに、その値を引数として呼び出されるonCost関数も実装しています。
先ほどのクエリは最大複雑度が1000を超えているので、この条件下では実行が許可されません。
最後に
GraphQL Server/Clientにそれぞれ設定を入れることで、負荷がかかるクエリの発行を未然に防ぐことができる、ということがわかりました。
膨大なデータの要求をしなくてよいだけでなく、悪意のある攻撃からも一定の効果があると思いますので、実務にも取り入れられるところは積極的に取り入れていたいです。