├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── cost-limiter.png ├── cost-query.png ├── depth-limiter.png ├── evil-query.png └── readme_logo.svg ├── deps.ts ├── example └── opine.ts ├── mod.ts ├── src ├── guardeno.ts ├── protections │ ├── cost-limiter.ts │ ├── depth-limiter.ts │ └── helper-functions.ts └── types.ts └── testing ├── cost-limiter-tests.ts └── depth-limiter-tests.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .vscode 3 | .DS_Store -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish 4 | to make via issue, email, or any other method with the owners of this repository 5 | before making a change. 6 | 7 | Please note we have a code of conduct, please follow it in all your interactions 8 | with the project. 9 | 10 | ## Pull Request Process 11 | 12 | 1. Ensure any install or build dependencies are removed before the end of the 13 | layer when doing a build. 14 | 2. Update the README.md with details of changes to the interface, this includes 15 | new environment variables, exposed ports, useful file locations and container 16 | parameters. 17 | 3. You may merge the Pull Request in once you have the sign-off of two other 18 | developers, or if you do not have permission to do that, you may request the 19 | second reviewer to merge it for you. 20 | 21 | # Code of Conduct 22 | 23 | ## Our Pledge 24 | 25 | In the interest of fostering an open and welcoming environment, we as 26 | contributors and maintainers pledge to making participation in our project and 27 | our community a harassment-free experience for everyone, regardless of age, body 28 | size, disability, ethnicity, gender identity and expression, level of 29 | experience, nationality, personal appearance, race, religion, or sexual identity 30 | and orientation. 31 | 32 | ## Our Standards 33 | 34 | Examples of behavior that contributes to creating a positive environment 35 | include: 36 | 37 | - Using welcoming and inclusive language 38 | - Being respectful of differing viewpoints and experiences 39 | - Gracefully accepting constructive criticism 40 | - Focusing on what is best for the community 41 | - Showing empathy towards other community members 42 | 43 | Examples of unacceptable behavior by participants include: 44 | 45 | - The use of sexualized language or imagery and unwelcome sexual attention or 46 | advances 47 | - Trolling, insulting/derogatory comments, and personal or political attacks 48 | - Public or private harassment attacks 49 | - Publishing others' private information, such as a physical or electronic 50 | address, without explicit permission 51 | - Other conduct which could reasonably be considered inappropriate in a 52 | professional setting 53 | 54 | ## Our Responsibilities 55 | 56 | Project maintainers are responsible for clarifying the standards of acceptable 57 | behavior and are expected to take appropriate and fair corrective action in 58 | response to any instances of unacceptable behavior. 59 | 60 | Project maintainers have the right and responsibility to remove, edit, or reject 61 | comments, commits, code, wiki edits, issues, and other contributions that are 62 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 63 | contributor for other behaviors that they deem inappropriate, threatening, 64 | offensive, or harmful. 65 | 66 | ## Scope 67 | 68 | This Code of Conduct applies both within project spaces and in public spaces 69 | when an individual is representing the project or its community. Examples of 70 | representing a project or community include using an official project e-mail 71 | address, posting via an official social media account, or acting as an appointed 72 | representative at an online or offline event. Representation of a project may be 73 | further defined and clarified by project maintainers. 74 | 75 | ## Enforcement 76 | 77 | All complaints will be reviewed and investigated and will result in a response 78 | that is deemed necessary and appropriate to the circumstances. The project team 79 | is obligated to maintain confidentiality with regard to the reporter of an 80 | incident. Further details of specific enforcement policies may be posted 81 | separately. 82 | 83 | Project maintainers who do not follow or enforce the Code of Conduct in good 84 | faith may face temporary or permanent repercussions as determined by other 85 | members of the project's leadership. 86 | 87 | ## Attribution 88 | 89 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, 90 | available at http://contributor-covenant.org/version/1/4 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | guardenoql-logo 5 |
6 |
7 |

GuarDenoQL

8 |

Simple and customizable security middleware for GraphQL servers in Deno

9 |
10 | 11 | # Features 12 | 13 | - Integrates with an Opine server in a Deno runtime. 14 | - Enables users to customize both a _**maximum depth**_ and a _**cost limit**_ 15 | for all GraphQL queries and mutations sent to the server. 16 | - Validates queries and mutations against the depth limiter and/or cost limiter 17 | before they are executed by the server. 18 | 19 | # Why? 20 | 21 | ### **Depth Limiting** 22 | 23 | Because GraphQL schemas can be cyclic graphs, it is possible that a client could 24 | construct a query such as this one: 25 | 26 |
27 | 28 |
29 | Therefore, if nested deep enough, a malicious actor could potentially bring your server down with an abusive query. 30 |
31 |
32 | 33 | However, using a **Depth Limiter**, you can validate the depth of incoming 34 | queries against a user-defined limit and prevent these queries from going 35 | through. 36 | 37 | ### **Cost Limiting** 38 | 39 | Queries can still be very expensive even if they aren't nested deeply. Using a 40 | **Cost Limiter**, your server will calculate the total cost of the query based 41 | on its types before execution. 42 | 43 |
44 | 45 |
46 | 47 | # Getting Started 48 | 49 | A set up with [gql](https://github.com/deno-libs/gql) and 50 | [Opine](https://github.com/cmorten/opine) out-of-the-box: 51 | 52 | ```typescript 53 | import { opine, OpineRequest } from "https://deno.land/x/opine@2.2.0/mod.ts"; 54 | import { GraphQLHTTP } from "https://deno.land/x/gql@1.1.2/mod.ts"; 55 | import { makeExecutableSchema } from "https://deno.land/x/graphql_tools@0.0.2/mod.ts"; 56 | import { gql } from "https://deno.land/x/graphql_tag@0.0.1/mod.ts"; 57 | import { readAll } from "https://deno.land/std@0.148.0/streams/conversion.ts"; 58 | 59 | import { guarDenoQL } from "https://deno.land/x/guardenoql@v1.0.1/mod.ts"; 60 | // update GuarDenoQL import URL with most recent version 61 | 62 | type Request = OpineRequest & { json: () => Promise }; 63 | 64 | const typeDefs = gql` 65 | type Query { 66 | hello: String 67 | } 68 | `; 69 | 70 | const resolvers = { Query: { hello: () => `Hello World!` } }; 71 | const dec = new TextDecoder(); 72 | const schema = makeExecutableSchema({ resolvers, typeDefs }); 73 | const app = opine(); 74 | 75 | app 76 | .use("/graphql", async (req, res) => { 77 | const request = req as Request; 78 | 79 | request.json = async () => { 80 | const rawBody = await readAll(req.raw); 81 | const body = JSON.parse(dec.decode(rawBody)); 82 | const query = body.query; 83 | 84 | const error = guarDenoQL(schema, query, { 85 | depthLimitOptions: { 86 | maxDepth: 4, // maximum depth allowed before a request is rejected 87 | callback: (args) => console.log("query depth is:", args), // optional 88 | }, 89 | costLimitOptions: { 90 | maxCost: 5000, // maximum cost allowed before a request is rejected 91 | mutationCost: 5, // cost of a mutation 92 | objectCost: 2, // cost of retrieving an object 93 | scalarCost: 1, // cost of retrieving a scalar 94 | depthCostFactor: 1.5, // multiplicative cost of each depth level 95 | callback: (args) => console.log("query cost is:", args), // optional 96 | }, 97 | }); 98 | 99 | if (error !== undefined && !error.length) { 100 | return body; 101 | } else { 102 | const errorMessage = { error }; 103 | return res.send(JSON.stringify(errorMessage)); 104 | } 105 | }; 106 | 107 | const resp = await GraphQLHTTP({ 108 | schema, 109 | context: (request) => ({ request }), 110 | graphiql: true, 111 | })(request); 112 | 113 | for (const [k, v] of resp.headers.entries()) res.headers?.append(k, v); 114 | res.status = resp.status; 115 | res.send(await resp.text()); 116 | }) 117 | .listen(3000, () => console.log(`☁ Started on http://localhost:3000`)); 118 | ``` 119 | 120 | GuarDenoQL is fully customizable. 121 | 122 | Users can use either the depth limiter, cost limiter or both. 123 | 124 | The first argument is the `schema`, the second argument is the `query`, and the 125 | third argument is an `Object` with up to two properties: `depthLimitOptions` 126 | and/or `costLimitOptions`. 127 | 128 | ### **Depth Limit Configuration** 129 | 130 | This feature limits the depth of a document. 131 | 132 | ```typescript 133 | const error = guarDenoQL(schema, query, { 134 | depthLimitOptions: { 135 | maxDepth: 4, // maximum depth allowed before a request is rejected 136 | callback: (args) => console.log("query depth is:", args), // optional 137 | }, 138 | }); 139 | ``` 140 | 141 | The `depthLimitOptions` object has two properties to configure: 142 | 143 | 1. `maxDepth`: the depth limiter will throw a validation error if the document 144 | has a greater depth than the user-supplied `maxDepth` 145 | 146 | 2. optional `callback` function: receives an `Object` that maps the name of the 147 | operation to its corresponding query depth 148 | 149 | ### **Cost Limit Configuration** 150 | 151 | This feature applies a cost analysis algorithm to block queries that are too 152 | expensive. 153 | 154 | ```typescript 155 | const error = guarDenoQL(schema, query, { 156 | costLimitOptions: { 157 | maxCost: 5000, // maximum cost allowed before a request is rejected 158 | mutationCost: 5, // cost of a mutation 159 | objectCost: 2, // cost of retrieving an object 160 | scalarCost: 1, // cost of retrieving a scalar 161 | depthCostFactor: 1.5, // multiplicative cost of each depth level 162 | callback: (args) => console.log("query cost is:", args), // optional 163 | }, 164 | }); 165 | ``` 166 | 167 | The `costLimitOptions` object has six properties to configure: 168 | 169 | 1. `maxCost`: the cost limiter will throw a validation error if the document has 170 | a greater cost than the user-supplied `maxCost` 171 | 172 | 2. `mutationCost`: represents the cost of a mutation (some popular 173 | [cost analysis algorithms](https://shopify.engineering/rate-limiting-graphql-apis-calculating-query-complexity) 174 | make mutations more expensive than queries) 175 | 176 | 3. `objectCost`: represents the cost of an object that has subfields 177 | 178 | 4. `scalarCost`: represents the cost of a scalar 179 | 180 | 5. `depthCostFactor`: the multiplicative cost of each depth level 181 | 182 | 6. optional `callback` function: receives an `Object` that maps the name of the 183 | operation to its corresponding query cost 184 | 185 | # Functionality 186 | 187 | ### **Depth Limiter** 188 | 189 |
190 | 191 |
192 | 193 | ### **Cost Limiter** 194 | 195 |
196 | 197 |
198 | 199 | # How to Contribute 200 | 201 | If you would like to contribute, please see 202 | [CONTRIBUTING.md](https://github.com/oslabs-beta/GuarDenoQL/blob/main/CONTRIBUTING.md) 203 | for more information. 204 | 205 | # Authors 206 | 207 | Finley Decker: [GitHub](https://github.com/finleydecker) | 208 | [LinkedIn](https://www.linkedin.com/in/finleydecker/) 209 | 210 | Hannah McDowell: [GitHub](https://github.com/hannahmcdowell) | 211 | [LinkedIn](https://www.linkedin.com/in/hannah-lisbeth-mcdowell/) 212 | 213 | Jane You: [GitHub](https://github.com/janeyou94) | 214 | [LinkedIn](https://www.linkedin.com/in/janeyou-pharmd-bcacp/) 215 | 216 | Lucien Hsu: [GitHub](https://github.com/LBLuc) | 217 | [LinkedIn](https://www.linkedin.com/in/lucien-hsu/) 218 | 219 | # License 220 | 221 | Distributed under the MIT License. See 222 | [LICENSE](https://github.com/oslabs-beta/GuarDenoQL/blob/main/LICENSE) for more 223 | information. 224 | -------------------------------------------------------------------------------- /assets/cost-limiter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/GuarDenoQL/d9b2000eee227e438440b0a52e571e4c74286f88/assets/cost-limiter.png -------------------------------------------------------------------------------- /assets/cost-query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/GuarDenoQL/d9b2000eee227e438440b0a52e571e4c74286f88/assets/cost-query.png -------------------------------------------------------------------------------- /assets/depth-limiter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/GuarDenoQL/d9b2000eee227e438440b0a52e571e4c74286f88/assets/depth-limiter.png -------------------------------------------------------------------------------- /assets/evil-query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/GuarDenoQL/d9b2000eee227e438440b0a52e571e4c74286f88/assets/evil-query.png -------------------------------------------------------------------------------- /assets/readme_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Source, 3 | parse, 4 | Kind, 5 | ValidationContext, 6 | GraphQLError, 7 | buildSchema, 8 | validate, 9 | specifiedRules, 10 | GraphQLSchema, 11 | TypeInfo, 12 | } from "https://deno.land/x/graphql_deno@v15.0.0/mod.ts"; 13 | 14 | export { opine } from "https://deno.land/x/opine@2.2.0/mod.ts"; 15 | export { GraphQLHTTP } from "https://deno.land/x/gql@1.1.2/mod.ts"; 16 | export { makeExecutableSchema } from "https://deno.land/x/graphql_tools@0.0.2/mod.ts"; 17 | export { gql } from "https://deno.land/x/graphql_tag@0.0.1/mod.ts"; 18 | export { readAll } from "https://deno.land/std@0.148.0/streams/conversion.ts"; 19 | export { Server } from "https://deno.land/std@0.148.0/http/server.ts"; 20 | export { expect } from "https://deno.land/x/expect@v0.2.10/mod.ts"; 21 | 22 | export type { OpineRequest } from "https://deno.land/x/opine@2.2.0/mod.ts"; 23 | export type { 24 | DefinitionNode, 25 | NullValueNode, 26 | ASTNode, 27 | ValidationRule, 28 | DocumentNode, 29 | ASTVisitor, 30 | } from "https://deno.land/x/graphql_deno@v15.0.0/mod.ts"; 31 | -------------------------------------------------------------------------------- /example/opine.ts: -------------------------------------------------------------------------------- 1 | import { opine, OpineRequest } from "https://deno.land/x/opine@2.2.0/mod.ts"; 2 | import { GraphQLHTTP } from "https://deno.land/x/gql@1.1.2/mod.ts"; 3 | import { makeExecutableSchema } from "https://deno.land/x/graphql_tools@0.0.2/mod.ts"; 4 | import { gql } from "https://deno.land/x/graphql_tag@0.0.1/mod.ts"; 5 | import { readAll } from "https://deno.land/std@0.148.0/streams/conversion.ts"; 6 | 7 | import { guarDenoQL } from "../mod.ts"; 8 | 9 | type Request = OpineRequest & { json: () => Promise }; 10 | 11 | // RUN COMMAND 12 | // deno run --allow-read --allow-net example/opine.ts 13 | 14 | const typeDefs = gql` 15 | type Query { 16 | posts: [Post] 17 | post(id: ID!): Post 18 | } 19 | 20 | type Post { 21 | id: ID! 22 | title: String! 23 | related: [Post] 24 | } 25 | `; 26 | 27 | const posts = [{ id: "graphql", title: "Learn GraphQL!" }]; 28 | 29 | const resolvers = { 30 | Query: { 31 | posts: () => posts, 32 | post: (_parent: any, args: { id: string }) => 33 | posts.find((post) => post.id === args.id), 34 | }, 35 | Post: { 36 | related: () => posts, 37 | }, 38 | }; 39 | 40 | const dec = new TextDecoder(); 41 | 42 | const schema = makeExecutableSchema({ resolvers, typeDefs }); 43 | 44 | const app = opine(); 45 | 46 | app 47 | .use("/graphql", async (req, res) => { 48 | const request = req as Request; 49 | 50 | request.json = async () => { 51 | const rawBody = await readAll(req.raw); 52 | const body = JSON.parse(dec.decode(rawBody)); 53 | const query = body.query; 54 | 55 | const error = guarDenoQL(schema, query, { 56 | // customize depth limiter options 57 | depthLimitOptions: { 58 | maxDepth: 4, 59 | callback: (args) => console.log("query depth is:", args), 60 | }, 61 | // customize cost Limiter options 62 | costLimitOptions: { 63 | maxCost: 5000, 64 | mutationCost: 5, 65 | objectCost: 2, 66 | scalarCost: 1, 67 | depthCostFactor: 1.5, 68 | callback: (args) => console.log("query cost is:", args), 69 | }, 70 | }); 71 | 72 | if (error !== undefined && !error.length) { 73 | return body; 74 | } else { 75 | const errorMessage = { error }; 76 | return res.send(JSON.stringify(errorMessage)); 77 | } 78 | }; 79 | 80 | const resp = await GraphQLHTTP({ 81 | schema, 82 | context: (request) => ({ request }), 83 | graphiql: true, 84 | })(request); 85 | 86 | for (const [k, v] of resp.headers.entries()) res.headers?.append(k, v); 87 | 88 | res.status = resp.status; 89 | 90 | res.send(await resp.text()); 91 | }) 92 | .listen(3000, () => console.log(`☁ Started on http://localhost:3000`)); 93 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export { guarDenoQL } from "./src/guardeno.ts"; 2 | -------------------------------------------------------------------------------- /src/guardeno.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Source, 3 | parse, 4 | validate, 5 | specifiedRules, 6 | GraphQLSchema, 7 | } from "../deps.ts"; 8 | 9 | import { depthLimit } from "./protections/depth-limiter.ts"; 10 | 11 | import { costLimit } from "./protections/cost-limiter.ts"; 12 | 13 | import { GuarDenoOptions, CostLimitOptions } from "./types.ts"; 14 | 15 | // merged the depthLimiter and costLimiter into a single function 16 | export function guarDenoQL( 17 | schema: GraphQLSchema, 18 | query: string, 19 | options: GuarDenoOptions 20 | ) { 21 | const { depthLimitOptions, costLimitOptions } = options; 22 | const document = createDocument(query); 23 | 24 | if (depthLimitOptions && costLimitOptions) { 25 | if (depthLimitOptions.maxDepth === undefined) { 26 | throw "Missing max depth property on depthLimiter!"; 27 | } 28 | if (checkCostProps(costLimitOptions)) { 29 | return validate(schema, document, [ 30 | ...specifiedRules, 31 | depthLimit(depthLimitOptions.maxDepth, depthLimitOptions.callback), 32 | costLimit(costLimitOptions), 33 | ]); 34 | } 35 | } else if (depthLimitOptions) { 36 | if (depthLimitOptions.maxDepth === undefined) { 37 | throw "Missing max depth property on depthLimiter!"; 38 | } 39 | return validate(schema, document, [ 40 | ...specifiedRules, 41 | depthLimit(depthLimitOptions.maxDepth, depthLimitOptions.callback), 42 | ]); 43 | } else if (costLimitOptions) { 44 | if (checkCostProps(costLimitOptions)) { 45 | return validate(schema, document, [ 46 | ...specifiedRules, 47 | costLimit(costLimitOptions), 48 | ]); 49 | } 50 | } else { 51 | throw "Missing depthLimiter & costLimiter options!"; 52 | } 53 | } 54 | 55 | // helper function to determine if the correct properties of costLimiterOptions are provided 56 | function checkCostProps(costLimiterOptions: CostLimitOptions) { 57 | const props = [ 58 | "maxCost", 59 | "mutationCost", 60 | "objectCost", 61 | "scalarCost", 62 | "depthCostFactor", 63 | ]; 64 | 65 | const badPropsArr = props.filter( 66 | (prop) => !Object.prototype.hasOwnProperty.call(costLimiterOptions, prop) 67 | ); 68 | 69 | if (badPropsArr.length) { 70 | throw `Error with ${badPropsArr} prop(s) on costLimiter!`; 71 | } else { 72 | return true; 73 | } 74 | } 75 | 76 | // given a GraphQL source, the function parses the source into an AST, which represents a GraphQL document in a type-safe, machine-readable format 77 | function createDocument(query: string) { 78 | const source = new Source(query); 79 | return parse(source); 80 | } -------------------------------------------------------------------------------- /src/protections/cost-limiter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Kind, 3 | ValidationContext, 4 | GraphQLError, 5 | ASTNode, 6 | ASTVisitor, 7 | } from "../../deps.ts"; 8 | 9 | import { getFragments, getQueriesAndMutations } from "./helper-functions.ts"; 10 | 11 | import { 12 | CostLimitOptions, 13 | QueryInfo, 14 | ValidationFunc, 15 | DefinitionNodeObject, 16 | } from "../types.ts"; 17 | 18 | // creating a validation rule for cost limit that the query will be checked against, based on options specified by the user 19 | export function costLimit(options: CostLimitOptions): ValidationFunc { 20 | return (validationContext: ValidationContext) => { 21 | const { definitions } = validationContext.getDocument(); 22 | const fragments = getFragments(definitions); 23 | const queries = getQueriesAndMutations(definitions); 24 | const queryCostLimit: QueryInfo = {}; 25 | 26 | for (const name in queries) { 27 | queryCostLimit[name] = determineCost( 28 | queries[name], 29 | fragments, 30 | 0, 31 | options, 32 | validationContext, 33 | name 34 | ); 35 | } 36 | const { callback } = options; 37 | if (callback !== undefined) { 38 | callback(queryCostLimit); 39 | } 40 | return validationContext; 41 | }; 42 | } 43 | 44 | // determine cost of specified query by traversing through sections of the query 45 | function determineCost( 46 | node: ASTNode, 47 | fragments: DefinitionNodeObject, 48 | depth: number, 49 | options: CostLimitOptions, 50 | context: ValidationContext, 51 | operationName: string 52 | ): number | undefined { 53 | const { maxCost, mutationCost, objectCost, scalarCost, depthCostFactor } = options; 54 | 55 | let cost = scalarCost; 56 | 57 | // addresses the operation type 58 | if (node.kind === Kind.OPERATION_DEFINITION) { 59 | cost = 0; 60 | if (node.operation === "mutation") { 61 | cost = mutationCost; 62 | } 63 | if ("selectionSet" in node && node.selectionSet) { 64 | for (const child of node.selectionSet.selections) { 65 | const additionalCost = determineCost( 66 | child, 67 | fragments, 68 | depth + 1, 69 | options, 70 | context, 71 | operationName 72 | ); 73 | if (additionalCost === undefined) { 74 | return; 75 | } 76 | cost += additionalCost; 77 | } 78 | } 79 | } 80 | 81 | // will ignore introspection queries 82 | if (node.kind === Kind.FIELD && 83 | /^__/.test(node.name.value) 84 | ) { 85 | return 0; 86 | } 87 | 88 | // addresses section of query that is classified as an object 89 | if (node.kind !== Kind.OPERATION_DEFINITION && 90 | "selectionSet" in node && 91 | node.selectionSet 92 | ) { 93 | cost = objectCost; 94 | for (const child of node.selectionSet.selections) { 95 | const additionalCost = determineCost( 96 | child, 97 | fragments, 98 | depth + 1, 99 | options, 100 | context, 101 | operationName 102 | ); 103 | if (additionalCost === undefined) { 104 | return; 105 | } 106 | cost += depthCostFactor * additionalCost; 107 | } 108 | } 109 | 110 | // addresses section of query that is classified as a fragment spread 111 | if (node.kind === Kind.FRAGMENT_SPREAD && 112 | "name" in node 113 | ) { 114 | const fragment = fragments[node.name?.value]; 115 | if (fragment) { 116 | const additionalCost = determineCost( 117 | fragment, 118 | fragments, 119 | depth + 1, 120 | options, 121 | context, 122 | operationName 123 | ); 124 | if (additionalCost === undefined) { 125 | return; 126 | } 127 | cost += depthCostFactor * additionalCost; 128 | } 129 | } 130 | 131 | if (cost > maxCost) { 132 | return context.reportError( 133 | new GraphQLError( 134 | `'${operationName}' exceeds maximum operation cost of ${maxCost}`, 135 | [node] 136 | ) 137 | ); 138 | } 139 | 140 | return cost; 141 | } 142 | -------------------------------------------------------------------------------- /src/protections/depth-limiter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Kind, 3 | GraphQLError, 4 | ASTNode, 5 | ValidationContext, 6 | ASTVisitor, 7 | } from "../../deps.ts"; 8 | 9 | import { ValidationFunc, DefinitionNodeObject, QueryInfo } from "../types.ts"; 10 | 11 | import { getFragments, getQueriesAndMutations } from "./helper-functions.ts"; 12 | 13 | // creating a validation rule for depth limit that the query will be checked against, based on options specified by the user 14 | export function depthLimit( 15 | maxDepth: number, 16 | callback: Function = () => {} 17 | ): ValidationFunc { 18 | return (validationContext) => { 19 | const { definitions } = validationContext.getDocument(); 20 | const fragments: DefinitionNodeObject = getFragments(definitions); 21 | const queries: DefinitionNodeObject = getQueriesAndMutations(definitions); 22 | const queryDepths: QueryInfo = {}; 23 | 24 | for (const name in queries) { 25 | queryDepths[name] = determineDepth( 26 | queries[name], 27 | fragments, 28 | 0, 29 | maxDepth, 30 | validationContext, 31 | name 32 | ); 33 | } 34 | callback(queryDepths); 35 | return validationContext; 36 | }; 37 | } 38 | 39 | // determine depth of specified query by traversing through sections of the query 40 | function determineDepth( 41 | node: ASTNode, 42 | fragments: DefinitionNodeObject, 43 | depthSoFar: number, 44 | maxDepth: number, 45 | context: ValidationContext, 46 | operationName: string 47 | ): number | undefined { 48 | if (depthSoFar > maxDepth) { 49 | return context.reportError( 50 | new GraphQLError( 51 | `'${operationName}' exceeds maximum operation depth of ${maxDepth}`, 52 | [node] 53 | ) 54 | ); 55 | } 56 | 57 | switch (node.kind) { 58 | // will ignore introspection queries 59 | case Kind.FIELD: { 60 | const shouldIgnore = /^__/.test(node.name.value); 61 | 62 | if (shouldIgnore || !node.selectionSet) { 63 | return 0; 64 | } 65 | 66 | const depthArray = node.selectionSet.selections.map((selection) => 67 | determineDepth( 68 | selection, 69 | fragments, 70 | depthSoFar + 1, 71 | maxDepth, 72 | context, 73 | operationName 74 | ) 75 | ); 76 | 77 | if (depthArray.includes(undefined)) { 78 | return; 79 | } 80 | return 1 + Math.max(...(depthArray)); 81 | } 82 | // addresses section of query that is classified as a fragment spread 83 | case Kind.FRAGMENT_SPREAD: 84 | return determineDepth( 85 | fragments[node.name.value], 86 | fragments, 87 | depthSoFar, 88 | maxDepth, 89 | context, 90 | operationName 91 | ); 92 | // addresses sections of query that do not affect depth 93 | case Kind.INLINE_FRAGMENT: 94 | case Kind.FRAGMENT_DEFINITION: 95 | case Kind.OPERATION_DEFINITION: { 96 | const depthArray = node.selectionSet.selections.map((selection) => 97 | determineDepth( 98 | selection, 99 | fragments, 100 | depthSoFar, 101 | maxDepth, 102 | context, 103 | operationName 104 | ) 105 | ); 106 | 107 | if (depthArray.includes(undefined)) { 108 | return; 109 | } 110 | return Math.max(...(depthArray)); 111 | } 112 | default: 113 | throw `The ${node.kind} section of the query cannot be handled by the depth limiter function.`; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/protections/helper-functions.ts: -------------------------------------------------------------------------------- 1 | import { Kind, DefinitionNode } from "../../deps.ts"; 2 | 3 | import { DefinitionNodeObject } from "../types.ts"; 4 | 5 | export function getFragments( 6 | definitions: ReadonlyArray 7 | ): DefinitionNodeObject { 8 | return definitions.reduce((map: DefinitionNodeObject, definition) => { 9 | if (definition.kind === Kind.FRAGMENT_DEFINITION) { 10 | map[definition.name.value] = definition; 11 | } 12 | return map; 13 | }, {}); 14 | } 15 | 16 | export function getQueriesAndMutations( 17 | definitions: ReadonlyArray 18 | ): DefinitionNodeObject { 19 | return definitions.reduce((map: DefinitionNodeObject, definition) => { 20 | if (definition.kind === Kind.OPERATION_DEFINITION) { 21 | map[definition.name ? definition.name.value : ""] = definition; 22 | } 23 | return map; 24 | }, {}); 25 | } 26 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DefinitionNode, ValidationContext, ASTVisitor } from "../deps.ts"; 2 | 3 | export type DefinitionNodeObject = { 4 | [key: string]: DefinitionNode; 5 | }; 6 | 7 | export type QueryInfo = { 8 | [key: string]: number | undefined; 9 | }; 10 | 11 | export interface ValidationFunc { 12 | (arg0: ValidationContext): ASTVisitor; 13 | } 14 | 15 | export type CostLimitOptions = { 16 | maxCost: number; 17 | mutationCost: number; 18 | objectCost: number; 19 | scalarCost: number; 20 | depthCostFactor: number; 21 | callback?: (arg0: QueryInfo) => any; 22 | }; 23 | 24 | export type DepthLimitOptions = { 25 | maxDepth: number; 26 | callback?: (arg0: QueryInfo) => any; 27 | }; 28 | 29 | export type GuarDenoOptions = { 30 | depthLimitOptions?: DepthLimitOptions; 31 | costLimitOptions?: CostLimitOptions; 32 | }; 33 | -------------------------------------------------------------------------------- /testing/cost-limiter-tests.ts: -------------------------------------------------------------------------------- 1 | import "https://unpkg.com/mocha@10.0.0/mocha.js"; 2 | import { costLimit } from "../src/protections/cost-limiter.ts"; 3 | import { 4 | expect, 5 | makeExecutableSchema, 6 | Source, 7 | parse, 8 | validate, 9 | specifiedRules, 10 | } from "../deps.ts"; 11 | 12 | function createDocument(query: string) { 13 | const source = new Source(query); 14 | return parse(source); 15 | } 16 | 17 | const typeDefinitions = ` 18 | type Book { 19 | title: String 20 | author: String 21 | } 22 | 23 | type Query { 24 | books: [Book] 25 | getBook(title: String): Book 26 | } 27 | `; 28 | 29 | const books = [ 30 | { 31 | title: "The Awakening", 32 | author: "Kate Chopin", 33 | }, 34 | { 35 | title: "City of Glass", 36 | author: "Paul Auster", 37 | }, 38 | ]; 39 | 40 | const resolvers = { 41 | Query: { 42 | books: () => books, 43 | getBook: (title: String) => books.find((book) => book.title === title), 44 | }, 45 | }; 46 | 47 | export const schema = makeExecutableSchema({ 48 | resolvers: [resolvers], 49 | typeDefs: [typeDefinitions], 50 | }); 51 | 52 | function onCompleted(failures: number): void { 53 | if (failures > 0) { 54 | Deno.exit(1); 55 | } else { 56 | Deno.exit(0); 57 | } 58 | } 59 | 60 | (window as any).location = new URL("http://localhost:0"); 61 | 62 | mocha.setup({ ui: "bdd", reporter: "spec" }); 63 | 64 | mocha.checkLeaks(); 65 | 66 | describe("cost limit tests", () => { 67 | const query = ` 68 | query { 69 | books { 70 | title 71 | author 72 | } 73 | } 74 | `; 75 | 76 | it("should work for a default query", () => { 77 | const document = createDocument(query); 78 | 79 | const errors = validate(schema, document, [ 80 | ...specifiedRules, 81 | costLimit({ 82 | maxCost: 20, 83 | mutationCost: 5, 84 | objectCost: 2, 85 | scalarCost: 1, 86 | depthCostFactor: 2, 87 | }), 88 | ]); 89 | 90 | expect(errors).toEqual([]); 91 | }); 92 | 93 | it("should limit cost", () => { 94 | const document = createDocument(query); 95 | 96 | const errors = validate(schema, document, [ 97 | ...specifiedRules, 98 | costLimit({ 99 | maxCost: 5, 100 | mutationCost: 5, 101 | objectCost: 2, 102 | scalarCost: 1, 103 | depthCostFactor: 2, 104 | }), 105 | ]); 106 | 107 | expect(errors[0].message).toEqual( 108 | "'' exceeds maximum operation cost of 5" 109 | ); 110 | }); 111 | 112 | it("should ignore introspection", () => { 113 | const introQuery = ` 114 | query IntrospectionQuery { 115 | __schema { 116 | queryType { name } 117 | mutationType { name } 118 | subscriptionType { name } 119 | types { 120 | ...FullType 121 | } 122 | directives { 123 | name 124 | description 125 | locations 126 | args { 127 | ...InputValue 128 | } 129 | } 130 | } 131 | } 132 | 133 | fragment FullType on __Type { 134 | kind 135 | name 136 | description 137 | fields(includeDeprecated: true) { 138 | name 139 | description 140 | args { 141 | ...InputValue 142 | } 143 | type { 144 | ...TypeRef 145 | } 146 | isDeprecated 147 | deprecationReason 148 | } 149 | inputFields { 150 | ...InputValue 151 | } 152 | interfaces { 153 | ...TypeRef 154 | } 155 | enumValues(includeDeprecated: true) { 156 | name 157 | description 158 | isDeprecated 159 | deprecationReason 160 | } 161 | possibleTypes { 162 | ...TypeRef 163 | } 164 | } 165 | 166 | fragment InputValue on __InputValue { 167 | name 168 | description 169 | type { ...TypeRef } 170 | defaultValue 171 | } 172 | 173 | fragment TypeRef on __Type { 174 | kind 175 | name 176 | ofType { 177 | kind 178 | name 179 | ofType { 180 | kind 181 | name 182 | ofType { 183 | kind 184 | name 185 | ofType { 186 | kind 187 | name 188 | ofType { 189 | kind 190 | name 191 | ofType { 192 | kind 193 | name 194 | ofType { 195 | kind 196 | name 197 | } 198 | } 199 | } 200 | } 201 | } 202 | } 203 | } 204 | } 205 | `; 206 | 207 | const document = createDocument(introQuery); 208 | 209 | const errors = validate(schema, document, [ 210 | ...specifiedRules, 211 | costLimit({ 212 | maxCost: 5, 213 | mutationCost: 5, 214 | objectCost: 2, 215 | scalarCost: 1, 216 | depthCostFactor: 2, 217 | }), 218 | ]); 219 | 220 | expect(errors).toEqual([]); 221 | }); 222 | 223 | it("should support fragments", () => { 224 | const fragmentQuery = ` 225 | query { 226 | ...BookFragment 227 | } 228 | fragment BookFragment on Query { 229 | books { 230 | title 231 | author 232 | } 233 | } 234 | `; 235 | 236 | const document = createDocument(fragmentQuery); 237 | 238 | const errors = validate(schema, document, [ 239 | ...specifiedRules, 240 | costLimit({ 241 | maxCost: 30, 242 | mutationCost: 5, 243 | objectCost: 2, 244 | scalarCost: 1, 245 | depthCostFactor: 2, 246 | }), 247 | ]); 248 | 249 | expect(errors).toEqual([]); 250 | }); 251 | }); 252 | 253 | mocha.run(onCompleted).globals(["onerror"]); 254 | -------------------------------------------------------------------------------- /testing/depth-limiter-tests.ts: -------------------------------------------------------------------------------- 1 | import "https://unpkg.com/mocha@10.0.0/mocha.js"; 2 | import { depthLimit } from "../src/protections/depth-limiter.ts"; 3 | import { 4 | expect, 5 | buildSchema, 6 | Source, 7 | parse, 8 | validate, 9 | specifiedRules, 10 | } from "../deps.ts"; 11 | 12 | function createDocument(query: string) { 13 | const source = new Source(query); 14 | return parse(source); 15 | }; 16 | 17 | const petMixin = ` 18 | name: String! 19 | owner: Human! 20 | `; 21 | 22 | const schema = buildSchema(` 23 | type Query { 24 | user(name: String): Human 25 | version: String 26 | user1: Human 27 | user2: Human 28 | user3: Human 29 | } 30 | 31 | type Human { 32 | name: String! 33 | email: String! 34 | address: Address 35 | pets: [Pet] 36 | } 37 | 38 | interface Pet { 39 | ${petMixin} 40 | } 41 | 42 | type Cat { 43 | ${petMixin} 44 | } 45 | 46 | type Dog { 47 | ${petMixin} 48 | } 49 | 50 | type Address { 51 | street: String 52 | number: Int 53 | city: String 54 | country: String 55 | } 56 | `); 57 | 58 | function onCompleted(failures: number): void { 59 | if (failures > 0) { 60 | Deno.exit(1); 61 | } else { 62 | Deno.exit(0); 63 | } 64 | }; 65 | 66 | (window as any).location = new URL("http://localhost:0"); 67 | 68 | mocha.setup({ ui: "bdd", reporter: "spec" }); 69 | 70 | mocha.checkLeaks(); 71 | 72 | describe("depth limit tests", () => { 73 | it("should should count depth without fragment", () => { 74 | const query = ` 75 | query read0 { 76 | version 77 | } 78 | query read1 { 79 | version 80 | user { 81 | name 82 | } 83 | } 84 | query read2 { 85 | matt: user(name: "matt") { 86 | email 87 | } 88 | andy: user(name: "andy") { 89 | email 90 | address { 91 | city 92 | } 93 | } 94 | } 95 | query read3 { 96 | matt: user(name: "matt") { 97 | email 98 | } 99 | andy: user(name: "andy") { 100 | email 101 | address { 102 | city 103 | } 104 | pets { 105 | name 106 | owner { 107 | name 108 | } 109 | } 110 | } 111 | } 112 | `; 113 | 114 | const document = createDocument(query); 115 | 116 | const expectedDepths = { 117 | read0: 0, 118 | read1: 1, 119 | read2: 2, 120 | read3: 3, 121 | }; 122 | 123 | const spec = (queryDepths) => expect(queryDepths).toEqual(expectedDepths); 124 | 125 | const errors = validate(schema, document, [ 126 | ...specifiedRules, 127 | depthLimit(10, spec), 128 | ]); 129 | 130 | expect(errors).toEqual([]); 131 | }); 132 | 133 | it("should count with fragments", () => { 134 | const query = ` 135 | query read0 { 136 | ... on Query { 137 | version 138 | } 139 | } 140 | query read1 { 141 | version 142 | user { 143 | ... on Human { 144 | name 145 | } 146 | } 147 | } 148 | fragment humanInfo on Human { 149 | email 150 | } 151 | fragment petInfo on Pet { 152 | name 153 | owner { 154 | name 155 | } 156 | } 157 | query read2 { 158 | matt: user(name: "matt") { 159 | ...humanInfo 160 | } 161 | andy: user(name: "andy") { 162 | ...humanInfo 163 | address { 164 | city 165 | } 166 | } 167 | } 168 | query read3 { 169 | matt: user(name: "matt") { 170 | ...humanInfo 171 | } 172 | andy: user(name: "andy") { 173 | ... on Human { 174 | email 175 | } 176 | address { 177 | city 178 | } 179 | pets { 180 | ...petInfo 181 | } 182 | } 183 | } 184 | `; 185 | 186 | const document = createDocument(query); 187 | 188 | const expectedDepths = { 189 | read0: 0, 190 | read1: 1, 191 | read2: 2, 192 | read3: 3, 193 | }; 194 | 195 | const spec = (queryDepths) => expect(queryDepths).toEqual(expectedDepths); 196 | 197 | const errors = validate(schema, document, [ 198 | ...specifiedRules, 199 | depthLimit(10, spec), 200 | ]); 201 | 202 | expect(errors).toEqual([]); 203 | }); 204 | 205 | it("should ignore the introspection query", () => { 206 | const introQuery = ` 207 | query IntrospectionQuery { 208 | __schema { 209 | queryType { name } 210 | mutationType { name } 211 | subscriptionType { name } 212 | types { 213 | ...FullType 214 | } 215 | directives { 216 | name 217 | description 218 | locations 219 | args { 220 | ...InputValue 221 | } 222 | } 223 | } 224 | } 225 | 226 | fragment FullType on __Type { 227 | kind 228 | name 229 | description 230 | fields(includeDeprecated: true) { 231 | name 232 | description 233 | args { 234 | ...InputValue 235 | } 236 | type { 237 | ...TypeRef 238 | } 239 | isDeprecated 240 | deprecationReason 241 | } 242 | inputFields { 243 | ...InputValue 244 | } 245 | interfaces { 246 | ...TypeRef 247 | } 248 | enumValues(includeDeprecated: true) { 249 | name 250 | description 251 | isDeprecated 252 | deprecationReason 253 | } 254 | possibleTypes { 255 | ...TypeRef 256 | } 257 | } 258 | 259 | fragment InputValue on __InputValue { 260 | name 261 | description 262 | type { ...TypeRef } 263 | defaultValue 264 | } 265 | 266 | fragment TypeRef on __Type { 267 | kind 268 | name 269 | ofType { 270 | kind 271 | name 272 | ofType { 273 | kind 274 | name 275 | ofType { 276 | kind 277 | name 278 | ofType { 279 | kind 280 | name 281 | ofType { 282 | kind 283 | name 284 | ofType { 285 | kind 286 | name 287 | ofType { 288 | kind 289 | name 290 | } 291 | } 292 | } 293 | } 294 | } 295 | } 296 | } 297 | } 298 | `; 299 | 300 | const document = createDocument(introQuery); 301 | 302 | const errors = validate(schema, document, [ 303 | ...specifiedRules, 304 | depthLimit(5), 305 | ]); 306 | 307 | expect(errors).toEqual([]); 308 | }); 309 | 310 | it("should catch a query that is too deep", () => { 311 | const query = ` 312 | { 313 | user { 314 | pets { 315 | owner { 316 | pets { 317 | owner { 318 | pets { 319 | name 320 | } 321 | } 322 | } 323 | } 324 | } 325 | } 326 | } 327 | `; 328 | 329 | const document = createDocument(query); 330 | 331 | const errors = validate(schema, document, [ 332 | ...specifiedRules, 333 | depthLimit(4), 334 | ]); 335 | 336 | expect(errors[0].message).toEqual( 337 | "'' exceeds maximum operation depth of 4" 338 | ); 339 | }); 340 | }); 341 | 342 | mocha.run(onCompleted).globals(["onerror"]); 343 | --------------------------------------------------------------------------------