diff --git a/modules/ROOT/content-nav.adoc b/modules/ROOT/content-nav.adoc index 7242354..4de277a 100644 --- a/modules/ROOT/content-nav.adoc +++ b/modules/ROOT/content-nav.adoc @@ -10,6 +10,7 @@ * *Reference* * xref:security/index.adoc[] +** xref:security/securing-a-graphql-api.adoc[] ** xref:security/configuration.adoc[] ** xref:security/authentication.adoc[] ** xref:security/authorization.adoc[] diff --git a/modules/ROOT/pages/security/securing-a-graphql-api.adoc b/modules/ROOT/pages/security/securing-a-graphql-api.adoc new file mode 100644 index 0000000..cc0d362 --- /dev/null +++ b/modules/ROOT/pages/security/securing-a-graphql-api.adoc @@ -0,0 +1,514 @@ +[[securing-an-api]] +:description: This page is a tutorial on how to secure your API created with the Neo4j GraphQL Library. += Securing your GraphQL API + +This page is a tutorial on how to secure your API created with the Neo4j GraphQL Library. + + +== Prerequisites + +. Set up a new AuraDB instance. +Refer to link:https://neo4j.com/docs/aura/getting-started/create-instance/[Creating a Neo4j Aura instance]. +. Populate the instance with the Northwind data set. + +[NOTE] +==== +If you have completed the GraphQL and Aura Console getting started guide and would like to get rid of the example nodes you have created there, run the following in **Query** before populating your data base with the Northwind set: +[source,cypher] +---- +MATCH (n) DETACH DELETE n; +---- +==== + +This tutorial builds on top of the xref:northwind-api.adoc[Northwind API tutorial]. +Specifically, it extends the following type definitions: + +[source, graphql, indent=0] +---- +type Customer @node { + contactName: String! + customerID: ID! @id + orders: [Order!]! @relationship(type: "PURCHASED", direction: OUT) +} +type Order @node { + orderID: ID! @id + customer: [Customer!]! @relationship(type: "PURCHASED", direction: IN) + products: [Product!]! @relationship(type: "ORDERS", direction: OUT, properties: "ordersProperties") +} +type Product @node { + productName: String! + category: [Category!]! @relationship(type: "PART_OF", direction: OUT) + orders: [Product!]! @relationship(type: "ORDERS", direction: IN, properties: "ordersProperties") + supplier: [Supplier!]! @relationship(type: "SUPPLIES", direction: IN) +} +type Category @node { + categoryName: String! + products: [Product!]! @relationship(type: "PART_OF", direction: IN) +} +type Supplier @node { + supplierID: ID! @id + companyName: String! + products: [Product!]! @relationship(type: "SUPPLIES", direction: OUT) +} +type ordersProperties @relationshipProperties { + unitPrice: Float! + quantity: Int! +} +---- + +== Security-related directives + +The GraphQL Library has several directives dedicated to security: xref:security/authentication.adoc[`@authentication`] and xref:security/authorization.adoc[`@authorization`], as well as xref:security/configuration.adoc#_jwt[`@jwt`] and xref:security/configuration.adoc#_jwtclaim[`@jwtClaim`]. +The xref:directives/schema-configuration/field-configuration.adoc#_selectable[`@selectable`] and xref:directives/schema-configuration/field-configuration.adoc#_settable[`@settable`] directives can be used to control accessibility of data fields through certain operations. + + +=== Authentication + +The xref:security/authentication.adoc[`@authentication` directive] can be applied globally, only to certain fields or only to certain types, and only for certain operations. + +Add admin authorization for operations on customers, orders, products, categories and suppliers: + +* `DELETE` for customers, +* `UPDATE` and `DELETE` for orders, +* `CREATE`, `UPDATE` and `DELETE` for products, categories and suppliers. + +[source, graphql, indent=0] +---- +type Customer + @node + @authentication( <1> + operations: [DELETE], + jwt: { roles: { includes: "admin" } } + ) { + contactName: String! + customerID: ID! @id + orders: [Order!]! @relationship(type: "PURCHASED", direction: OUT) +} + +type Order + @node + @authentication( <2> + operations: [UPDATE, DELETE], + jwt: { roles: { includes: "admin" } } + ) { + orderID: ID! @id + customer: [Customer!]! @relationship(type: "PURCHASED", direction: IN) + products: [Product!]! @relationship(type: "ORDERS", direction: OUT, properties: "ordersProperties") +} + +type Product + @node + @authentication( <3> + operations: [CREATE, UPDATE, DELETE], + jwt: { roles: { includes: "admin" } } + ) { + productName: String! + category: [Category!]! @relationship(type: "PART_OF", direction: OUT) + orders: [Product!]! @relationship(type: "ORDERS", direction: IN, properties: "ordersProperties") + supplier: [Supplier!]! @relationship(type: "SUPPLIES", direction: IN) +} + +type Category + @node + @authentication( <4> + operations: [CREATE, UPDATE, DELETE], + jwt: { roles: { includes: "admin" } } + ) { + categoryName: String! + products: [Product!]! @relationship(type: "PART_OF", direction: IN) +} + +type Supplier + @node + @authentication( <5> + operations: [CREATE, UPDATE, DELETE], + jwt: { roles: { includes: "admin" } } + ) { + supplierID: ID! @id + companyName: String! + products: [Product!]! @relationship(type: "SUPPLIES", direction: OUT) +} +---- + +==== JSON Web Token (JWT) authentication + +JWT authentication is a popular method for token-based authentication. +It allows clients to obtain and use tokens to authenticate subsequent requests. + +JWT are represented by encoded JSON data. +These data can have arbitrary fields - which ones they should contain depends on the application preferences. + +For instance, if the server side is trying to parse the `roles` field that was introduced in xref:#_authentication[], then the JWT should contain that. +With `@jwtClaim`, you can specify a path to a customer ID in a nested location. +For example: + +[source, graphql, indent=0] +---- +type JWT @jwt { + roles: [String!]! + customerID: String! @jwtClaim(path: "sub") +} +---- + +You can encode and decode JWT using a site like link:https://www.jwt.io/[https://www.jwt.io/]. + + +=== Authorization + +The xref:security/authorization.adoc[`@authorization` directive] can either be used to filter out data which users should not have access to or throw an error if a query is executed against such data. + +Both have their own use cases. + +To make customer data and order data inaccessible to anyone who is not the specific user or an admin, consider the following uses of filters with the `@authorization` directive: + +[source, graphql, indent=0] +---- +type Customer + @node + @authentication(operations: [DELETE], jwt: { roles: { includes: "admin" } }) + @authorization( + filter: [ <1> + { operations: [READ], where: { node: { customerID: { eq: "$jwt.customerID" } } } } + { where: { jwt: { roles: { includes: "admin" } } } } + ] + ) { + contactName: String! + customerID: ID! @id + orders: [Order!]! @relationship(type: "PURCHASED", direction: OUT) +} + +type Order + @node + @authentication(operations: [UPDATE, DELETE], jwt: { roles: { includes: "admin" } }) + @authorization( + filter: [ <2> + { where: { node: { customer: { all: { customerID: { eq: "$jwt.customerID" } } } } } } + { where: { jwt: { roles: { includes: "admin" } } } } + ] + ) { + orderID: ID! @id + customer: [Customer!]! @relationship(type: "PURCHASED", direction: IN) + products: [Product!]! @relationship(type: "ORDERS", direction: OUT, properties: "ordersProperties") +} +---- + +For sensitive data, you can also use a validating authorization: + +[source, graphql, indent=0] +---- +type Customer + @node + @authentication(operations: [DELETE], jwt: { roles: { includes: "admin" } }) + @authorization( + filter: [ + { operations: [READ], where: { node: { customerID: { eq: "$jwt.customerID" } } } } + { where: { jwt: { roles: { includes: "admin" } } } } + ] + ) { + contactName: String! + adminNotes: [String!]! @authorization( + validate: [ <1> + { where: { jwt: { roles: { includes: "admin" } } } } + ] + ) + customerID: ID! @id + orders: [Order!]! @relationship(type: "PURCHASED", direction: OUT) +} +---- + +`adminNotes` can only be read by admins and trying to access this field causes an error if the user is not an admin. + +It is important to be aware that error messages generated through validation can be a security concern since they can report database internals to your users. + +Also see <> on this page. + + +=== `@selectable` and `@settable` + +To restrict access through operations directly, you can use the xref:directives/schema-configuration/field-configuration.adoc#_selectable[`@selectable`] and xref:directives/schema-configuration/field-configuration.adoc#_settable[`@settable`] directives, for example: + +[source, graphql, indent=0] +---- +type Customer + @node + @authentication(operations: [DELETE], jwt: { roles: { includes: "admin" } }) + @authorization( + filter: [ + { operations: [READ], where: { node: { customerID: { eq: "$jwt.customerID" } } } } + { where: { jwt: { roles: { includes: "admin" } } } } + ] + ) { + contactName: String! + sensitiveData: String! @selectable(onRead: false, onAggregate: false) + createdAt: DateTime! @settable(onCreate: true, onUpdate: false) + adminNotes: [String!]! @authorization(validate: [{ where: { jwt: { roles: { includes: "admin" } } } }]) + customerID: ID! @id + orders: [Order!]! @relationship(type: "PURCHASED", direction: OUT) +} +---- + +The `sensitiveData` field is neither available for queries nor for subscriptions nor for aggregations. +The `createdAt` field can be set when a new customer is created, but it cannot be updated. + + +=== Full example + +Here is the full set of type definitions extended with security-related directives: + +[source, graphql, indent=0] +---- +type JWT @jwt { + roles: [String!]! + customerID: String! @jwtClaim(path: "sub") +} + +type Customer + @node + @authentication(operations: [DELETE], jwt: { roles: { includes: "admin" } }) + @authorization( + filter: [ + { operations: [READ], where: { node: { customerID: { eq: "$jwt.customerID" } } } } + { where: { jwt: { roles: { includes: "admin" } } } } + ] + ) { + contactName: String! + sensitiveData: String! @selectable(onRead: false, onAggregate: false) + createdAt: DateTime! @settable(onCreate: true, onUpdate: false) + adminNotes: [String!]! @authorization(validate: [{ where: { jwt: { roles: { includes: "admin" } } } }]) + customerID: ID! @id + orders: [Order!]! @relationship(type: "PURCHASED", direction: OUT) +} + +type Order + @node + @authentication(operations: [UPDATE, DELETE], jwt: { roles: { includes: "admin" } }) + @authorization( + filter: [ + { where: { node: { customer: { all: { customerID: { eq: "$jwt.customerID" } } } } } } + { where: { jwt: { roles: { includes: "admin" } } } } + ] + ) { + orderID: ID! @id + customer: [Customer!]! @relationship(type: "PURCHASED", direction: IN) + products: [Product!]! @relationship(type: "ORDERS", direction: OUT, properties: "ordersProperties") +} + +type Product @node @authentication(operations: [CREATE, UPDATE, DELETE], jwt: { roles: { includes: "admin" } }) { + productName: String! + category: [Category!]! @relationship(type: "PART_OF", direction: OUT) + orders: [Product!]! @relationship(type: "ORDERS", direction: IN, properties: "ordersProperties") + supplier: [Supplier!]! @relationship(type: "SUPPLIES", direction: IN) +} + +type Category @node @authentication(operations: [CREATE, UPDATE, DELETE], jwt: { roles: { includes: "admin" } }) { + categoryName: String! + products: [Product!]! @relationship(type: "PART_OF", direction: IN) +} + +type Supplier @node @authentication(operations: [CREATE, UPDATE, DELETE], jwt: { roles: { includes: "admin" } }) { + supplierID: ID! @id + companyName: String! + products: [Product!]! @relationship(type: "SUPPLIES", direction: OUT) +} + +type ordersProperties @relationshipProperties { + unitPrice: Float! + quantity: Int! +} +---- + + +== Best practice checklist + +Besides authentication and authorization considerations, there are a couple of worthwhile best practices to increase your API's security. + + +=== Avoid introspection and data field suggestions + +While the xref:getting-started/graphql-aura.adoc[Getting started page for GraphQL and Aura Console] advocates to both **Enable introspection** as well as **Enable field suggestions**, this is not recommended when considering security. + +Both potentially expose information that can be used to gain insight on specifics of your GraphQL schema and execute targeted malicious opLorem ipsumerations. +Be sure to deactivate both in a customer-facing real-life scenario. + +[[best-practice-internal-errors]] +=== Catch internal errors + +For the same reason it is advisable to avoid introspection and data field suggestions, it can make your API more secure to catch internal errors and redact which information you want to pass on to the end user. + +For example, the following error reveals information XY: + +[source, json, indent=0] +---- +"data": { + "field": "value" +} +---- + +You can use Apollo Server's link:https://www.apollographql.com/docs/apollo-server/data/errors[Error Handling] to catch such internal errors and then decide how to pass this on to your users: + +[source, typescript, indent=0] +---- +import { ApolloServerErrorCode } from '@apollo/server/errors'; + +if (error.extensions?.code === ApolloServerErrorCode.GRAPHQL_PARSE_FAILED) { + // respond to the syntax error + +} else if (error.extensions?.code === "MY_CUSTOM_CODE") { + // do something else + +} +---- + + +=== Limit query depth + +Limiting query depth disallows potentially harmful queries such as the following recursive query: + +[source, graphql, indent=0] +---- +query { + order(id: 42) { + products { + order { + products { + order { + products { + order { + # and so on... + } + } + } + } + } + } + } +} +---- + +This can be achieved with a package such as link:https://www.npmjs.com/package/graphql-depth-limit[graphql-depth-limit]: + +[source, typescript, indent=0] +---- +import depthLimit from 'graphql-depth-limit' +import express from 'express' +import graphqlHTTP from 'express-graphql' +import schema from './schema' + +const app = express() + +app.use('/graphql', graphqlHTTP((req, res) => ({ + schema, + validationRules: [ depthLimit(10) ] +}))) +---- + + +=== Paginate list fields + +Returning large query result lists can negatively affect server performance. +For example, a query like the following would return a siginificant number of nodes: + +[source, graphql, indent=0] +---- +query { + order(first: 1000) { + orderID + products(last: 100) { + productName + productCategory + } + } +} +---- + +To avoid this, you can cap the input number directly in your resolvers, for example like this: + +[source, graphql, indent=0] +---- +// example +---- + +Alternatively, use a library such as link:https://github.com/joonhocho/graphql-input-number[graphql-input-number]: + +[source, typescript, indent=0] +---- +import { + GraphQLInputInt, + GraphQLInputFloat, +} from 'graphql-input-number'; + +const argType = GraphQLInputInt({ + name: 'OneToNineInt', + min: 1, + max: 9, +}); + +new GraphQLObjectType({ + name: 'Query', + fields: { + input: { + type: GraphQLInt, + args: { + number: { + type: argType, + }, + }, + resolve: (_, {number}) => { + + // 'number' IS AN INT BETWEEN 1 to 9. + + }; + }, + }, +}); +---- + + +=== Rate-limit your API + +Rate-limiting an API means setting an upper bound to how many requests a client can send in a certain amount of time or how costly those requests may be. +There is more than one approach. +Several are outlined in the following sections. + + +==== Rate limit scores + +Refer to GitHub's link:https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-authenticated-users[Rate limits for the REST API]. + + +==== Query cost points + +The link:https://shopify.dev/docs/api/usage/limits#the-leaky-bucket-algorithm[leaky bucket algorithm]. + +==== Query cost analysis + +link:https://github.com/pa-bru/graphql-cost-analysis[raphql-cost-analysis] + + +=== Use timeouts + +To prevent the API from not responding or falling victim to denial of service attacks, it is feasible to make use of timeouts. +This way, subsequent queries will not be blocked by a long-running previous query. + +There are many ways and places to use timeouts. +Here are a few examples. + +// examples + + +=== Validate user input + +User input may potentially be malicious, for example, it could contain code snippets which get executed when running queries against the database. + +Follow the input validation methods summarized in the link:https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html#input-validation[OWASP Cheat Sheet Series]. + +// Examples? + + + +== Further reading + +Neo4j has a link:https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-privileges/[Role-based access control] mechanism that can be leveraged to increase security even further. + + +