From 54d3643e9fd5497b850e4a4470af71850dc66035 Mon Sep 17 00:00:00 2001 From: trivernis Date: Tue, 14 Jan 2020 13:09:57 +0100 Subject: [PATCH] Add graphql query complexity limit - Add query complexity limit that is calculated with directive fields in the schema - Add complexity limit config option in the config file --- CHANGELOG.md | 1 + config/default.toml | 18 ++++++++++++-- package.json | 6 +++-- src/app.ts | 19 +++++++++++++- src/graphql/schema.graphql | 51 ++++++++++++++++++++++---------------- yarn.lock | 19 ++++++++++++++ 6 files changed, 88 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d40474..b147ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - joined field to Event gql type - joined field to Group gql type - rate limits with defaults of 10/min for `/upload` and 30/min for `/graphql` +- complexity limits for graphql queries that can be configured with the `api.maxQueryComplexity` option ### Removed diff --git a/config/default.toml b/config/default.toml index 62d1a87..9c52a26 100644 --- a/config/default.toml +++ b/config/default.toml @@ -3,11 +3,13 @@ # A connection uri string to the database connectionUri = "sqlite://greenvironment.db" + # Configuration for the redis connection [redis] # A connection uri string to the redis server connectionUri = "redis://localhost:6379" + # Configuration of the http server [server] # The port the server is running on @@ -17,6 +19,7 @@ cors = false # The timeout for a server response timeout = 30000 + # Configuration for the sessions [session] # A secret that is used for the sessions. Must be secure @@ -24,27 +27,38 @@ secret = "REPLACE WITH SAFE RANDOM GENERATED SECRET" # The maximum age of a cookie. The age is reset with every request of the client cookieMaxAge = 6048e+5 # 7 days + # Configuration for markdown rendering [markdown] # The plugins used in the markdown parser plugins = ["markdown-it-emoji"] + # Configuration for logging [logging] + # The loglevel level = "info" + # Configuration for the frontend files [frontend] + # The path to the angular index file. Its relative to the public path. angularIndex = "index.html" + # The path to the public files publicPath = "./public" + # Configuration for the api [api] - # if graphiql should be enabled - graphiql = true +# if graphiql should be enabled +graphiql = true + +# The maximum complexity of queries +maxQueryComplexity = 5000 + # Configuration for the api rate limit [api.rateLimit] # rate limit of /upload diff --git a/package.json b/package.json index 413f25b..5eb0ea5 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@types/bluebird": "^3.5.27", "@types/chai": "^4.2.7", "@types/compression": "^1.0.1", + "@types/config": "^0.0.36", "@types/connect-pg-simple": "^4.2.0", "@types/cookie-parser": "^1.4.2", "@types/cors": "^2.8.6", @@ -32,20 +33,20 @@ "@types/express-session": "^1.15.14", "@types/express-socket.io-session": "^1.3.2", "@types/fs-extra": "^8.0.0", + "@types/graphql-query-complexity": "^0.2.1", "@types/http-status": "^0.2.30", "@types/js-yaml": "^3.12.1", "@types/markdown-it": "0.0.9", "@types/mocha": "^5.2.7", "@types/node": "^12.7.12", "@types/pg": "^7.11.0", + "@types/redis": "^2.8.14", "@types/sequelize": "^4.28.5", "@types/sharp": "^0.23.1", "@types/socket.io": "^2.1.2", "@types/socket.io-redis": "^1.0.25", "@types/uuid": "^3.4.6", "@types/validator": "^10.11.3", - "@types/config": "^0.0.36", - "@types/redis": "^2.8.14", "chai": "^4.2.0", "delete": "^1.1.0", "gulp": "^4.0.2", @@ -72,6 +73,7 @@ "fs-extra": "^8.1.0", "graphql": "^14.4.2", "graphql-import": "^0.7.1", + "graphql-query-complexity": "^0.4.1", "http-status": "^1.3.2", "js-yaml": "^3.13.1", "markdown-it": "^10.0.0", diff --git a/src/app.ts b/src/app.ts index f1f0267..5507ec0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,7 @@ import sharedsession = require("express-socket.io-session"); import * as fsx from "fs-extra"; import {buildSchema} from "graphql"; import {importSchema} from "graphql-import"; +import queryComplexity, {simpleEstimator, directiveEstimator} from "graphql-query-complexity"; import {IncomingMessage, ServerResponse} from "http"; import * as http from "http"; import * as httpStatus from "http-status"; @@ -19,6 +20,7 @@ import * as redis from "redis"; import {Sequelize} from "sequelize-typescript"; import * as socketIo from "socket.io"; import * as socketIoRedis from "socket.io-redis"; +import {query} from "winston"; import {resolver} from "./graphql/resolvers"; import dataaccess from "./lib/dataAccess"; import globals from "./lib/globals"; @@ -189,13 +191,28 @@ class App { path: "/graphql", total: config.get("api.rateLimit.graphql.total"), }); - this.app.use("/graphql", graphqlHTTP((request, response) => { + + // @ts-ignore + this.app.use("/graphql", graphqlHTTP(async (request, response, {variables}) => { return { // @ts-ignore all context: {session: request.session}, graphiql: config.get("api.graphiql"), rootValue: resolver(request, response), schema: buildSchema(importSchema(path.join(__dirname, "./graphql/schema.graphql"))), + validationRules: [ + queryComplexity({ + estimators: [ + directiveEstimator(), + simpleEstimator(), + ], + maximumComplexity: config.get("api.maxQueryComplexity"), + onComplete: (complexity: number) => { + logger.debug(`QueryComplexity: ${complexity}`); + }, + variables, + }), + ], }; })); // allow access to cluster information diff --git a/src/graphql/schema.graphql b/src/graphql/schema.graphql index 8c974d2..48075fa 100644 --- a/src/graphql/schema.graphql +++ b/src/graphql/schema.graphql @@ -1,3 +1,12 @@ +"a directive to assign a complexity to a query field" +directive @complexity( + "The complexity value for the field" + value: Int!, + + "Optional multipliers" + multipliers: [String!] +) on FIELD_DEFINITION + type Query { "returns the user object for a given user id or a handle (only one required)" getUser(userId: ID, handle: String): User @@ -18,10 +27,10 @@ type Query { getRequest(requestId: ID!): Request "searches for users, groups, events, posts and returns a search result" - search(query: String!, first: Int = 20, offset: Int = 0): SearchResult! + search(query: String!, first: Int = 20, offset: Int = 0): SearchResult! @complexity(value: 1, multipliers: ["first"]) "returns the post filtered by the sort type with pagination." - getPosts(first: Int=20, offset: Int=0, sort: SortType = NEW): [Post!]! + getPosts(first: Int=20, offset: Int=0, sort: SortType = NEW): [Post!]! @complexity(value: 1, multipliers: ["first"]) "returns all activities" getActivities: [Activity] @@ -121,25 +130,25 @@ interface UserData { postCount: Int! "returns a given number of posts of a user" - posts(first: Int=10, offset: Int=0): [Post!]! + posts(first: Int=10, offset: Int=0): [Post!]! @complexity(value: 1, multipliers: ["first"]) "creation date of the user account" joinedAt: String! "all friends of the user" - friends(first: Int=10, offset: Int=0): [User!]! + friends(first: Int=10, offset: Int=0): [User!]! @complexity(value: 1, multipliers: ["first"]) "The number of friends the user has" friendCount: Int! "The groups the user has joined" - groups(first: Int=10, offset: Int=0): [Group!]! + groups(first: Int=10, offset: Int=0): [Group!]! @complexity(value: 1, multipliers: ["first"]) "The number of groups the user has joined" groupCount: Int! "The events the user is participating in" - events(first: Int=10, offset: Int=0): [Event!]! + events(first: Int=10, offset: Int=0): [Event!]! @complexity(value: 1, multipliers: ["first"]) "The number of events the user is participating in" eventCount: Int! @@ -169,7 +178,7 @@ type User implements UserData{ numberOfPosts: Int! "returns a given number of posts of a user" - posts(first: Int=10, offset: Int): [Post!]! + posts(first: Int=10, offset: Int): [Post!]! @complexity(value: 1, multipliers: ["first"]) "the number of posts the user has created" postCount: Int! @@ -178,7 +187,7 @@ type User implements UserData{ joinedAt: String! "all friends of the user" - friends(first: Int=10, offset: Int=0): [User!]! + friends(first: Int=10, offset: Int=0): [User!]! @complexity(value: 1, multipliers: ["first"]) "The number of friends the user has" friendCount: Int! @@ -187,13 +196,13 @@ type User implements UserData{ points: Int! "the groups the user has joined" - groups(first: Int=10, offset: Int=0): [Group!]! + groups(first: Int=10, offset: Int=0): [Group!]! @complexity(value: 1, multipliers: ["first"]) "The numbef of groups the user has joined" groupCount: Int! "The events the user is participating in" - events(first: Int=10, offset: Int=0): [Event!]! + events(first: Int=10, offset: Int=0): [Event!]! @complexity(value: 1, multipliers: ["first"]) "The number of events the user is participating in" eventCount: Int! @@ -213,7 +222,7 @@ type Profile implements UserData { email: String! "returns the chatrooms the user joined." - chats(first: Int=10, offset: Int): [ChatRoom] + chats(first: Int=10, offset: Int): [ChatRoom] @complexity(value: 1, multipliers: ["first"]) "the count of the users chats" chatCount: Int! @@ -231,13 +240,13 @@ type Profile implements UserData { postCount: Int! "returns a given number of posts of a user" - posts(first: Int=10, offset: Int): [Post!]! + posts(first: Int=10, offset: Int): [Post!]! @complexity(value: 1, multipliers: ["first"]) "creation date of the user account" joinedAt: String! "all friends of the user" - friends(first: Int=10, offset: Int=0): [User!]! + friends(first: Int=10, offset: Int=0): [User!]! @complexity(value: 1, multipliers: ["first"]) "The number of friends the user has" friendCount: Int! @@ -255,13 +264,13 @@ type Profile implements UserData { createdGroups: [Group!]! "all groups the user has joined" - groups(first: Int=10, offset: Int=0): [Group!]! + groups(first: Int=10, offset: Int=0): [Group!]! @complexity(value: 1, multipliers: ["first"]) "The numbef of groups the user has joined" groupCount: Int! "The events the user is participating in" - events(first: Int=10, offset: Int=0): [Event!]! + events(first: Int=10, offset: Int=0): [Event!]! @complexity(value: 1, multipliers: ["first"]) "The number of events the user is participating in" eventCount: Int! @@ -335,10 +344,10 @@ type ChatRoom { namespace: String "the members of the chatroom" - members(first: Int=10, offset: Int=0): [User!]! + members(first: Int=10, offset: Int=0): [User!]! @complexity(value: 1, multipliers: ["first"]) "return a specfic range of messages posted in the chat" - messages(first: Int = 10, offset: Int): [ChatMessage!]! + messages(first: Int = 10, offset: Int): [ChatMessage!]! @complexity(value: 1, multipliers: ["first"]) "id of the chat" id: ID! @@ -375,16 +384,16 @@ type Group { creator: User! "all admins of the group" - admins(first: Int=10, offset: Int=0): [User!]! + admins(first: Int=10, offset: Int=0): [User!]! @complexity(value: 1, multipliers: ["first"]) "the members of the group with pagination" - members(first: Int = 10, offset: Int = 0): [User!]! + members(first: Int = 10, offset: Int = 0): [User!]! @complexity(value: 1, multipliers: ["first"]) "the groups chat" chat: ChatRoom! "the events of the group" - events(first: Int=10, offset: Int=0): [Event!]! + events(first: Int=10, offset: Int=0): [Event!]! @complexity(value: 1, multipliers: ["first"]) "If the user with the specified id has joined the group" joined(userId: Int): Boolean! @@ -404,7 +413,7 @@ type Event { group: Group! "The participants of the event." - participants(first: Int=10, offset: Int=0): [User!]! + participants(first: Int=10, offset: Int=0): [User!]! @complexity(value: 1, multipliers: ["first"]) "Returns if the user with the specified id has joined the event" joined(userId: Int): Boolean diff --git a/yarn.lock b/yarn.lock index 6bb21a2..30bee06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -152,6 +152,13 @@ dependencies: "@types/node" "*" +"@types/graphql-query-complexity@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@types/graphql-query-complexity/-/graphql-query-complexity-0.2.1.tgz#5166c7f32b6cd0a24f1aad5e00ca513b82b7f0e0" + integrity sha512-PxYhF92UFagAl9UIep8seEUd9j18JardL9ZM9tOfP02fWot9ZlkBYYGFwSZ7fRE6HTva/Yr4BQem7b4P/TgDPA== + dependencies: + graphql-query-complexity "*" + "@types/http-status@^0.2.30": version "0.2.30" resolved "https://registry.yarnpkg.com/@types/http-status/-/http-status-0.2.30.tgz#b43a1e1673b6ed9b5a28e8647862b51b6473634d" @@ -2273,6 +2280,13 @@ graphql-import@^0.7.1: lodash "^4.17.4" resolve-from "^4.0.0" +graphql-query-complexity@*, graphql-query-complexity@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/graphql-query-complexity/-/graphql-query-complexity-0.4.1.tgz#06ad49de617da0d74c8196fb4a641349f104552d" + integrity sha512-Uo87hNlnJ5jwoWBkVYITbJpTrlCVwgfG5Wrfel0K1/42G+3xvud31CpsprAwiSpFIP+gCqttAx7OVmw4eTqLQQ== + dependencies: + lodash.get "^4.4.2" + graphql@^14.4.2, graphql@^14.5.3: version "14.5.8" resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.8.tgz#504f3d3114cb9a0a3f359bbbcf38d9e5bf6a6b3c" @@ -3040,6 +3054,11 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= + lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"