Browse Source

chore: Create HardenPlugin

Michael Bromley 3 years ago
parent
commit
9df8164929

+ 4 - 0
packages/harden-plugin/.gitignore

@@ -0,0 +1,4 @@
+preview/output
+yarn-error.log
+lib
+e2e/__data__/*.sqlite

+ 7 - 0
packages/harden-plugin/README.md

@@ -0,0 +1,7 @@
+# Vendure Harden Plugin
+
+Hardens your Vendure GraphQL APIs against attacks.
+
+`npm install @vendure/harden-plugin`
+
+For documentation, see [www.vendure.io/docs/typescript-api/harden-plugin/](https://www.vendure.io/docs/typescript-api/harden-plugin/)

+ 3 - 0
packages/harden-plugin/index.ts

@@ -0,0 +1,3 @@
+export * from './src/harden.plugin';
+export * from './src/types';
+export * from './src/middleware/query-complexity-plugin';

+ 23 - 0
packages/harden-plugin/package.json

@@ -0,0 +1,23 @@
+{
+  "name": "@vendure/harden-plugin",
+  "version": "1.9.1",
+  "license": "MIT",
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "files": [
+    "lib/**/*"
+  ],
+  "scripts": {
+    "watch": "tsc -p ./tsconfig.build.json --watch",
+    "build": "rimraf lib && tsc -p ./tsconfig.build.json",
+    "lint": "tslint --fix --project ./"
+  },
+  "homepage": "https://www.vendure.io/",
+  "funding": "https://github.com/sponsors/michaelbromley",
+  "publishConfig": {
+    "access": "public"
+  },
+  "dependencies": {
+    "graphql-query-complexity": "^0.12.0"
+  }
+}

+ 2 - 0
packages/harden-plugin/src/constants.ts

@@ -0,0 +1,2 @@
+export const loggerCtx = 'HardenPlugin';
+export const HARDEN_PLUGIN_OPTIONS = Symbol('HARDEN_PLUGIN_OPTIONS');

+ 170 - 0
packages/harden-plugin/src/harden.plugin.ts

@@ -0,0 +1,170 @@
+import { Logger, VendurePlugin } from '@vendure/core';
+
+import { HARDEN_PLUGIN_OPTIONS, loggerCtx } from './constants';
+import { HideValidationErrorsPlugin } from './middleware/hide-validation-errors-plugin';
+import { QueryComplexityPlugin } from './middleware/query-complexity-plugin';
+import { HardenPluginOptions } from './types';
+
+/**
+ * @description
+ * The HardenPlugin hardens the Shop and Admin GraphQL APIs against attacks and abuse.
+ *
+ * - It analyzes the complexity on incoming graphql queries and rejects queries that are too complex and
+ *   could be used to overload the resources of the server.
+ * - It disables dev-mode API features such as introspection and the GraphQL playground app.
+ * - It removes field name suggestions to prevent trial-and-error schema sniffing.
+ *
+ * It is a recommended plugin for all production configurations.
+ *
+ * ## Installation
+ *
+ * `yarn add \@vendure/harden-plugin`
+ *
+ * or
+ *
+ * `npm install \@vendure/harden-plugin`
+ *
+ * Then add the `HardenPlugin`, calling the `.init()` method with {@link HardenPluginOptions}:
+ *
+ * @example
+ * ```ts
+ * import { HardenPlugin } from '\@vendure/harden-plugin';
+ *
+ * const config: VendureConfig = {
+ *   // Add an instance of the plugin to the plugins array
+ *   plugins: [
+ *      HardenPlugin.init({
+ *        maxQueryComplexity: 650,
+ *        apiMode: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
+ *      }),
+ *   ],
+ * };
+ * ```
+ *
+ * ## Setting the max query complexity
+ *
+ * The `maxQueryComplexity` option determines how complex a query can be. The complexity of a query relates to how many, and how
+ * deeply-nested are the fields being selected, and is intended to roughly correspond to the amount of server resources that would
+ * be required to resolve that query.
+ *
+ * The goal of this setting is to prevent attacks in which a malicious actor crafts a very complex query in order to overwhelm your
+ * server resources. Here's an example of a request which would likely overwhelm a Vendure server:
+ *
+ * ```GraphQL
+ * query EvilQuery {
+ *   products {
+ *     items {
+ *       collections {
+ *         productVariants {
+ *           items {
+ *             product {
+ *               collections {
+ *                 productVariants {
+ *                   items {
+ *                     product {
+ *                       variants {
+ *                         name
+ *                       }
+ *                     }
+ *                   }
+ *                 }
+ *               }
+ *             }
+ *           }
+ *         }
+ *       }
+ *     }
+ *   }
+ * }
+ * ```
+ *
+ * This evil query has a complexity score of 2,443,203 - much greater than the default of 1,000!
+ *
+ * The complexity score is calculated by the [graphql-query-complexity library](https://www.npmjs.com/package/graphql-query-complexity),
+ * and by default uses the {@link defaultVendureComplexityEstimator}, which is tuned specifically to the Vendure Shop API.
+ *
+ * The optimal max complexity score will vary depending on:
+ *
+ * - The requirements of your storefront and other clients using the Shop API
+ * - The resources available to your server
+ *
+ * You should aim to set the maximum as low as possible while still being able to service all the requests required. This will take some manual tuning.
+ * While tuning the max, you can turn on the `logComplexityScore` to get a detailed breakdown of the complexity of each query, as well as how
+ * that total score is derived from its child fields:
+ *
+ * @example
+ * ```ts
+ * import { HardenPlugin } from '\@vendure/harden-plugin';
+ *
+ * const config: VendureConfig = {
+ *   // A detailed summary is logged at the "debug" level
+ *   logger: new DefaultLogger({ level: LogLevel.Debug }),
+ *   plugins: [
+ *      HardenPlugin.init({
+ *        maxQueryComplexity: 650,
+ *        logComplexityScore: true,
+ *      }),
+ *   ],
+ * };
+ * ```
+ *
+ * With logging configured as above, the following query:
+ *
+ * ```GraphQL
+ * query ProductList {
+ *   products(options: { take: 5 }) {
+ *     items {
+ *       id
+ *       name
+ *       featuredAsset {
+ *         preview
+ *       }
+ *     }
+ *   }
+ * }
+ * ```
+ * will log the following breakdown:
+ *
+ * ```sh
+ * debug 16/12/22, 14:12 - [HardenPlugin] Calculating complexity of [ProductList]
+ * debug 16/12/22, 14:12 - [HardenPlugin] Product.id: ID!     childComplexity: 0, score: 1
+ * debug 16/12/22, 14:12 - [HardenPlugin] Product.name: String!       childComplexity: 0, score: 1
+ * debug 16/12/22, 14:12 - [HardenPlugin] Asset.preview: String!      childComplexity: 0, score: 1
+ * debug 16/12/22, 14:12 - [HardenPlugin] Product.featuredAsset: Asset        childComplexity: 1, score: 2
+ * debug 16/12/22, 14:12 - [HardenPlugin] ProductList.items: [Product!]!      childComplexity: 4, score: 20
+ * debug 16/12/22, 14:12 - [HardenPlugin] Query.products: ProductList!        childComplexity: 20, score: 35
+ * verbose 16/12/22, 14:12 - [HardenPlugin] Query complexity [ProductList]: 35
+ * ```
+ *
+ * @docsCategory HardenPlugin
+ */
+@VendurePlugin({
+    providers: [
+        {
+            provide: HARDEN_PLUGIN_OPTIONS,
+            useFactory: () => HardenPlugin.options,
+        },
+    ],
+    configuration: config => {
+        if (HardenPlugin.options.hideFieldSuggestions !== false) {
+            Logger.verbose(`Configuring HideValidationErrorsPlugin`, loggerCtx);
+            config.apiOptions.apolloServerPlugins.push(new HideValidationErrorsPlugin());
+        }
+        config.apiOptions.apolloServerPlugins.push(new QueryComplexityPlugin(HardenPlugin.options));
+        if (HardenPlugin.options.apiMode !== 'dev') {
+            config.apiOptions.adminApiDebug = false;
+            config.apiOptions.shopApiDebug = false;
+            config.apiOptions.introspection = false;
+        }
+
+        return config;
+    },
+})
+export class HardenPlugin {
+    static options: HardenPluginOptions;
+
+    static init(options: HardenPluginOptions) {
+        this.options = options;
+        return HardenPlugin;
+    }
+}

+ 26 - 0
packages/harden-plugin/src/middleware/hide-validation-errors-plugin.ts

@@ -0,0 +1,26 @@
+import { ValidationError } from 'apollo-server-core';
+import { ApolloServerPlugin, GraphQLRequestListener, GraphQLServiceContext } from 'apollo-server-plugin-base';
+
+/**
+ * @description
+ * Hides graphql-js suggestions when invalid field names are given.
+ * Based on ideas discussed in https://github.com/apollographql/apollo-server/issues/3919
+ */
+export class HideValidationErrorsPlugin implements ApolloServerPlugin {
+    requestDidStart(): GraphQLRequestListener {
+        return {
+            willSendResponse: async requestContext => {
+                const { errors, context } = requestContext;
+                if (errors) {
+                    (requestContext.response as any).errors = errors.map(err => {
+                        if (err.message.includes('Did you mean')) {
+                            return new ValidationError('Invalid request');
+                        } else {
+                            return err;
+                        }
+                    });
+                }
+            },
+        };
+    }
+}

+ 127 - 0
packages/harden-plugin/src/middleware/query-complexity-plugin.ts

@@ -0,0 +1,127 @@
+import { InternalServerError, Logger } from '@vendure/core';
+import { ApolloServerPlugin, GraphQLRequestListener } from 'apollo-server-plugin-base';
+import { GraphQLRequestContext } from 'apollo-server-types';
+import {
+    getNamedType,
+    getNullableType,
+    GraphQLSchema,
+    isListType,
+    isObjectType,
+    separateOperations,
+} from 'graphql';
+import { ComplexityEstimatorArgs, getComplexity, simpleEstimator } from 'graphql-query-complexity';
+
+import { loggerCtx } from '../constants';
+import { HardenPluginOptions } from '../types';
+
+/**
+ * @description
+ * Implements query complexity analysis on Shop API requests.
+ */
+export class QueryComplexityPlugin implements ApolloServerPlugin {
+    constructor(private options: HardenPluginOptions) {}
+
+    requestDidStart({ schema }: GraphQLRequestContext): GraphQLRequestListener {
+        const maxQueryComplexity = this.options.maxQueryComplexity ?? 1000;
+        return {
+            didResolveOperation: async ({ request, document }) => {
+                if (isAdminApi(schema)) {
+                    // We don't want to apply the cost analysis on the
+                    // Admin API, since any expensive operations would require
+                    // an authenticated session.
+                    return;
+                }
+                const query = request.operationName
+                    ? separateOperations(document)[request.operationName]
+                    : document;
+
+                if (this.options.logComplexityScore === true) {
+                    Logger.debug(
+                        `Calculating complexity of "${request.operationName ?? 'anonymous'}"`,
+                        loggerCtx,
+                    );
+                }
+                const complexity = getComplexity({
+                    schema,
+                    query,
+                    variables: request.variables,
+                    estimators: this.options.queryComplexityEstimators ?? [
+                        defaultVendureComplexityEstimator(
+                            this.options.customComplexityFactors ?? {},
+                            this.options.logComplexityScore ?? false,
+                        ),
+                        simpleEstimator({ defaultComplexity: 1 }),
+                    ],
+                });
+
+                if (this.options.logComplexityScore === true) {
+                    Logger.verbose(
+                        `Query complexity "${request.operationName ?? 'anonymous'}": ${complexity}`,
+                        loggerCtx,
+                    );
+                }
+                if (complexity >= maxQueryComplexity) {
+                    Logger.error(
+                        `Query complexity of "${
+                            request.operationName ?? 'anonymous'
+                        }" is ${complexity}, which exceeds the maximum of ${maxQueryComplexity}`,
+                        loggerCtx,
+                    );
+                    throw new InternalServerError(`Query is too complex`);
+                }
+            },
+        };
+    }
+}
+
+function isAdminApi(schema: GraphQLSchema): boolean {
+    const queryType = schema.getQueryType();
+    if (queryType) {
+        return !!queryType.getFields().administrators;
+    }
+    return false;
+}
+
+/**
+ * @description
+ * A complexity estimator which takes into account List and PaginatedList types and can
+ * be further configured by providing a customComplexityFactors object.
+ *
+ * When selecting PaginatedList types, the "take" argument is used to estimate a complexity
+ * factor. If the "take" argument is omitted, a default factor of 1000 is applied.
+ *
+ * @docsCategory HardenPlugin
+ */
+export function defaultVendureComplexityEstimator(
+    customComplexityFactors: { [path: string]: number },
+    logFieldScores: boolean,
+) {
+    return (options: ComplexityEstimatorArgs): number | void => {
+        const { type, args, childComplexity, field } = options;
+        const namedType = getNamedType(field.type);
+        const path = `${type.name}.${field.name}`;
+        let result = childComplexity + 1;
+        const customFactor = customComplexityFactors[path];
+        if (customFactor != null) {
+            result = Math.max(childComplexity, 1) * customFactor;
+        } else {
+            if (isObjectType(namedType)) {
+                const isPaginatedList = !!namedType.getInterfaces().find(i => i.name === 'PaginatedList');
+                if (isPaginatedList) {
+                    const take = args.options?.take ?? 1000;
+                    result = childComplexity + Math.round(Math.log(childComplexity) * take);
+                }
+            }
+            if (isListType(getNullableType(field.type))) {
+                result = childComplexity * 5;
+            }
+        }
+        if (logFieldScores) {
+            Logger.debug(
+                `${path}: ${field.type.toString()}\tchildComplexity: ${childComplexity}, score: ${result}`,
+                loggerCtx,
+            );
+        }
+        return result;
+    };
+}

+ 82 - 0
packages/harden-plugin/src/types.ts

@@ -0,0 +1,82 @@
+import { ComplexityEstimator } from 'graphql-query-complexity/dist/cjs/QueryComplexity';
+
+/**
+ * @description
+ * Options that can be passed to the `.init()` static method of the HardenPlugin.
+ *
+ * @docsCategory HardenPlugin
+ */
+export interface HardenPluginOptions {
+    /**
+     * @description
+     * Defines the maximum permitted complexity score of a query. The complexity score is based
+     * on the number of fields being selected as well as other factors like whether there are nested
+     * lists.
+     *
+     * A query which exceeds the maximum score will result in an error.
+     *
+     * @default 1000
+     */
+    maxQueryComplexity?: number;
+    /**
+     * @description
+     * An array of custom estimator functions for calculating the complexity of a query. By default,
+     * the plugin will use the {@link defaultVendureComplexityEstimator} which is specifically
+     * tuned to accurately estimate Vendure queries.
+     */
+    queryComplexityEstimators?: ComplexityEstimator[];
+    /**
+     * @description
+     * When set to `true`, the complexity score of each query will be logged at the Verbose
+     * log level, and a breakdown of the calculation for each field will be logged at the Debug level.
+     *
+     * This is very useful for tuning your complexity scores.
+     *
+     * @default false
+     */
+    logComplexityScore?: boolean;
+
+    /**
+     * @description
+     * This object allows you to tune the complexity weight of specific fields. For example,
+     * if you have a custom `stockLocations` field defined on the `ProductVariant` type, and
+     * you know that it is a particularly expensive operation to execute, you can increase
+     * its complexity like this:
+     *
+     * @example
+     * ```TypeScript
+     * HardenPlugin.init({
+     *   maxQueryComplexity: 650,
+     *   customComplexityFactors: {
+     *     'ProductVariant.stockLocations': 10
+     *   }
+     * }),
+     * ```
+     */
+    customComplexityFactors?: {
+        [path: string]: number;
+    };
+
+    /**
+     * @description
+     * Graphql-js will make suggestions about the names of fields if an invalid field name is provided.
+     * This would allow an attacker to find out the available fields by brute force even if introspection
+     * is disabled.
+     *
+     * Setting this option to `true` will prevent these suggestion error messages from being returned,
+     * instead replacing the message with a generic "Invalid request" message.
+     *
+     * @default true
+     */
+    hideFieldSuggestions?: boolean;
+    /**
+     * @description
+     * When set to `'prod'`, the plugin will disable dev-mode features of the GraphQL APIs:
+     *
+     * - introspection
+     * - GraphQL playground
+     *
+     * @default 'prod'
+     */
+    apiMode?: 'dev' | 'prod';
+}

+ 9 - 0
packages/harden-plugin/tsconfig.build.json

@@ -0,0 +1,9 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "outDir": "./lib"
+  },
+  "files": [
+    "./index.ts"
+  ]
+}

+ 10 - 0
packages/harden-plugin/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "declaration": true,
+    "removeComments": false,
+    "noLib": false,
+    "skipLibCheck": true,
+    "sourceMap": true
+  }
+}

+ 1 - 0
scripts/docs/generate-typescript-docs.ts

@@ -26,6 +26,7 @@ const sections: DocsSectionConfig[] = [
             'packages/job-queue-plugin/src/',
             'packages/payments-plugin/src/',
             'packages/testing/src/',
+            'packages/harden-plugin/src/',
         ],
         exclude: [/generated-shop-types/],
         outputPath: 'typescript-api',

+ 8 - 1
yarn.lock

@@ -9710,6 +9710,13 @@ graphql-fields@^2.0.3:
   resolved "https://registry.npmjs.org/graphql-fields/-/graphql-fields-2.0.3.tgz#5e68dff7afbb202be4f4f40623e983b22c96ab8f"
   integrity sha512-x3VE5lUcR4XCOxPIqaO4CE+bTK8u6gVouOdpQX9+EKHr+scqtK5Pp/l8nIGqIpN1TUlkKE6jDCCycm/WtLRAwA==
 
+graphql-query-complexity@^0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/graphql-query-complexity/-/graphql-query-complexity-0.12.0.tgz#5f636ccc54da82225f31e898e7f27192fe074b4c"
+  integrity sha512-fWEyuSL6g/+nSiIRgIipfI6UXTI7bAxrpPlCY1c0+V3pAEUo1ybaKmSBgNr1ed2r+agm1plJww8Loig9y6s2dw==
+  dependencies:
+    lodash.get "^4.4.2"
+
 graphql-request@^3.3.0:
   version "3.5.0"
   resolved "https://registry.npmjs.org/graphql-request/-/graphql-request-3.5.0.tgz#7e69574e15875fb3f660a4b4be3996ecd0bbc8b7"
@@ -12466,7 +12473,7 @@ lodash.flatten@^4.4.0:
   resolved "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
   integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
 
-lodash.get@^4:
+lodash.get@^4, lodash.get@^4.4.2:
   version "4.4.2"
   resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
   integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=