├── .eslintignore ├── .npmignore ├── .gitignore ├── test ├── .eslintrc ├── app │ └── index.js ├── store │ ├── memory-store.js │ └── redis-store.js ├── utils │ └── query-repository.js ├── api.js └── middleware.js ├── .babelrc ├── lib ├── store │ ├── index.js │ ├── memory-store.js │ └── redis-store.js ├── utils │ ├── index.js │ ├── parse-query.js │ ├── store-introspection-queries.js │ ├── store-queries-from-dir.js │ ├── queries │ │ ├── graphiql-introspection-query-0.4.1.graphql │ │ ├── graphql-introspection-query-0.4.8.graphql │ │ ├── graphql-introspection-query-0.5.0.graphql │ │ ├── graphql-introspection-query-0.4.10.graphql │ │ ├── graphiql-introspection-query-0.7.3.graphql │ │ └── graphql-introspection-query-0.6.0.graphql │ ├── query-repository.js │ └── gql-whitelist.js ├── api │ └── index.js └── index.js ├── .travis.yml ├── .eslintrc ├── LICENSE ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/** 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib 2 | test 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"], 3 | "plugins": ["transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /lib/store/index.js: -------------------------------------------------------------------------------- 1 | export MemoryStore from './memory-store' 2 | export RedisStore from './redis-store' 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Node 2 | language: node_js 3 | node_js: 4 | - "4.6.0" 5 | services: 6 | - redis-server 7 | cache: 8 | directories: 9 | - node_modules 10 | script: 11 | - npm test 12 | -------------------------------------------------------------------------------- /lib/utils/index.js: -------------------------------------------------------------------------------- 1 | export storeQueriesFromDir, { getQueriesFromDir } from './store-queries-from-dir' 2 | export parseQuery from './parse-query' 3 | export QueryRepository, { QueryNotFoundError } from './query-repository' 4 | export storeIntrospectionQueries from './store-introspection-queries' 5 | -------------------------------------------------------------------------------- /lib/store/memory-store.js: -------------------------------------------------------------------------------- 1 | class MemoryStore { 2 | constructor() { 3 | this.queries = new Map() 4 | } 5 | 6 | async get(key) { 7 | return this.queries.get(key) 8 | } 9 | 10 | async set(key, val) { 11 | this.queries.set(key, val) 12 | } 13 | 14 | async entries() { 15 | return Array.from(this.queries) 16 | } 17 | 18 | async delete(key) { 19 | this.queries.delete(key) 20 | } 21 | 22 | async clear() { 23 | this.queries = new Map() 24 | } 25 | } 26 | 27 | export default MemoryStore 28 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["standard"], 4 | "plugins": ["mocha"], 5 | "ecmaFeatures": { 6 | "modules": true, 7 | "experimentalObjectRestSpread": true 8 | }, 9 | "env": { 10 | "node": true, 11 | "es6": true 12 | }, 13 | "rules": { 14 | "space-before-function-paren": [2, "never"], 15 | "object-curly-spacing": [2, "always"], 16 | "array-bracket-spacing": [2, "never"], 17 | "mocha/no-exclusive-tests": 2, 18 | "mocha/no-global-tests": 2, 19 | "mocha/no-hooks-for-single-case": 2, 20 | "mocha/no-identical-title": 2, 21 | "mocha/no-pending-tests": 2, 22 | "mocha/no-skipped-tests": 2 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/store/redis-store.js: -------------------------------------------------------------------------------- 1 | const redisKey = 'queries' 2 | 3 | class RedisStore { 4 | constructor(...redisOptions) { 5 | const Redis = require('ioredis') 6 | this.redisClient = new Redis(...redisOptions) 7 | } 8 | 9 | get(key) { 10 | return this.redisClient.hget(redisKey, key).then((val) => val === null ? undefined : JSON.parse(val)) 11 | } 12 | 13 | set(key, val) { 14 | return this.redisClient.hset(redisKey, key, JSON.stringify(val)) 15 | } 16 | 17 | entries() { 18 | return this.redisClient.hgetall(redisKey).then(queries => ( 19 | Object.keys(queries).map((key) => [key, JSON.parse(queries[key])]) 20 | )) 21 | } 22 | 23 | delete(key) { 24 | return this.redisClient.hdel(redisKey, key) 25 | } 26 | 27 | clear() { 28 | return this.redisClient.flushdb() 29 | } 30 | } 31 | 32 | export default RedisStore 33 | -------------------------------------------------------------------------------- /lib/utils/parse-query.js: -------------------------------------------------------------------------------- 1 | import { print } from 'graphql/language/printer' 2 | import { parse } from 'graphql/language/parser' 3 | import { getOperationAST } from 'graphql' 4 | import crypto from 'crypto' 5 | 6 | const hashQuery = (query) => crypto.createHash('sha256').update(query).digest('base64') 7 | 8 | export default (query, options = { requireOperationName: true }) => { 9 | const queryAST = parse(query) 10 | const operationAST = getOperationAST(queryAST) 11 | const normalizedQuery = print(queryAST) 12 | 13 | if (options.requireOperationName && !operationAST.name) { 14 | throw new Error(` 15 | Invalid Query: 'Query must have an operation name'. 16 | e.g. 17 | query MyQueryName { 18 | firstName, 19 | lastName 20 | } 21 | `) 22 | } 23 | 24 | return { 25 | queryId: hashQuery(normalizedQuery), 26 | operationName: operationAST.name && operationAST.name.value, 27 | normalizedQuery 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/app/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import graphqlHTTP from 'express-graphql' 3 | import bodyParser from 'body-parser' 4 | import graphqlWhitelist, { Api } from '../../lib' 5 | 6 | import { 7 | GraphQLSchema, 8 | GraphQLObjectType, 9 | GraphQLString 10 | } from 'graphql' 11 | 12 | const QueryType = new GraphQLObjectType({ 13 | name: 'Query', 14 | description: 'Test query', 15 | fields: () => ({ 16 | firstName: { 17 | type: GraphQLString, 18 | resolve: () => 'John' 19 | }, 20 | lastName: { 21 | type: GraphQLString, 22 | resolve: () => 'Cook' 23 | } 24 | }) 25 | }) 26 | 27 | const schema = new GraphQLSchema({ 28 | query: QueryType 29 | }) 30 | 31 | export default (options) => { 32 | const app = express() 33 | 34 | if (!options.noBodyParser) { 35 | app.use(bodyParser.json()) 36 | } 37 | 38 | app.use('/graphql', graphqlWhitelist(options), (req, res) => graphqlHTTP({ schema, graphiql: true })(req, res)) 39 | app.use('/api', Api(options.store)) 40 | 41 | return app 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Restorando 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 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/utils/store-introspection-queries.js: -------------------------------------------------------------------------------- 1 | import { basename, join } from 'path' 2 | import { introspectionQuery } from 'graphql' 3 | import { version as graphqlVersion } from 'graphql/package.json' 4 | import { storeQueriesFromDir } from './' 5 | 6 | const getOperationNameFn = (file) => { 7 | const filename = basename(file, '.graphql') 8 | let [, app, version] = filename.match(/^(graphi?ql).*?([\d.]+)$/) 9 | app = app.replace(/[gql]/g, letter => letter.toUpperCase()) 10 | 11 | return `${app} ${version} introspection query` 12 | } 13 | 14 | export default async function storeIntrospectionQueries(repository) { 15 | const operationName = getOperationNameFn(`graphql-introspection-query-${graphqlVersion}.graphql`) 16 | const introspectionQueriesPath = join(__dirname, 'queries') 17 | 18 | return Promise.all([ 19 | repository.put(introspectionQuery, { operationName }), 20 | storeQueriesFromDir(repository, introspectionQueriesPath, { getOperationNameFn }) 21 | ]).then(() => { 22 | console.log(`Storing bundled introspection query for version ${graphqlVersion}`) 23 | console.log('Introspection queries stored successfully') 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /test/store/memory-store.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { MemoryStore } from '../../lib/store' 3 | 4 | describe('MemoryStore', () => { 5 | const query = 'query TestQuery { firstName }' 6 | const queryHash = 'FoZSVHVMq0lErDt43A50mbb4MsYSM55MrEUTr53Xvv0=' 7 | 8 | let store = null 9 | 10 | beforeEach(() => { store = new MemoryStore() }) 11 | 12 | it('puts a query into the store', async () => { 13 | expect(store.queries.has(queryHash)).to.be.false 14 | 15 | await store.set(queryHash, query) 16 | 17 | expect(store.queries.has(queryHash)).to.be.true 18 | }) 19 | 20 | it('gets a query from the store', async () => { 21 | await store.set(queryHash, query) 22 | 23 | expect(await store.get(queryHash)).to.equal(query) 24 | }) 25 | 26 | it('gets all the entries', async () => { 27 | await Promise.all([store.set('foo', 'bar'), store.set('foo2', 'bar2')]) 28 | expect(await store.entries()).to.deep.include.members([['foo', 'bar'], ['foo2', 'bar2']]) 29 | }) 30 | 31 | it('deletes an entry', async () => { 32 | await store.set(queryHash, query) 33 | 34 | expect(await store.get(queryHash)).to.equal(query) 35 | 36 | await store.delete(queryHash) 37 | 38 | expect(await store.get(queryHash)).to.be.undefined 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /lib/utils/store-queries-from-dir.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | const findQueryFiles = dir => new Promise((resolve, reject) => { 5 | const resolvePath = (filename = '') => path.resolve(dir, filename) 6 | 7 | console.log(`Searching for queries in '${resolvePath()}'`) 8 | 9 | fs.readdir(dir, (err, files) => { 10 | if (err) return reject(err) 11 | resolve(files.filter(file => /\.graphql$/.test(file)).map(resolvePath)) 12 | }) 13 | }) 14 | 15 | const readQueryFromFile = file => new Promise((resolve, reject) => { 16 | fs.readFile(file, (err, data) => { 17 | if (err) return reject(err) 18 | resolve(data.toString()) 19 | }) 20 | }) 21 | 22 | export const getQueriesFromDir = async dir => { 23 | const files = await findQueryFiles(dir) 24 | return Promise.all(files.map(async filename => ({ query: await readQueryFromFile(filename), filename }))) 25 | } 26 | 27 | const identityFn = input => input 28 | 29 | export default async (repository, dir, options = {}) => { 30 | const queryFiles = await findQueryFiles(dir) 31 | const getOperationNameFn = options.getOperationNameFn || identityFn 32 | 33 | return Promise.all(queryFiles.map(async file => { 34 | console.log(`Storing query from ${file}`) 35 | return repository.put(await readQueryFromFile(file), { operationName: getOperationNameFn(file) }) 36 | })) 37 | } 38 | -------------------------------------------------------------------------------- /lib/utils/queries/graphiql-introspection-query-0.4.1.graphql: -------------------------------------------------------------------------------- 1 | query IntrospectionQuery { 2 | __schema { 3 | queryType { name } 4 | mutationType { name } 5 | types { 6 | ...FullType 7 | } 8 | directives { 9 | name 10 | description 11 | args { 12 | ...InputValue 13 | } 14 | onOperation 15 | onFragment 16 | onField 17 | } 18 | } 19 | } 20 | fragment FullType on __Type { 21 | kind 22 | name 23 | description 24 | fields(includeDeprecated: true) { 25 | name 26 | description 27 | args { 28 | ...InputValue 29 | } 30 | type { 31 | ...TypeRef 32 | } 33 | isDeprecated 34 | deprecationReason 35 | } 36 | inputFields { 37 | ...InputValue 38 | } 39 | interfaces { 40 | ...TypeRef 41 | } 42 | enumValues(includeDeprecated: true) { 43 | name 44 | description 45 | isDeprecated 46 | deprecationReason 47 | } 48 | possibleTypes { 49 | ...TypeRef 50 | } 51 | } 52 | fragment InputValue on __InputValue { 53 | name 54 | description 55 | type { ...TypeRef } 56 | defaultValue 57 | } 58 | fragment TypeRef on __Type { 59 | kind 60 | name 61 | ofType { 62 | kind 63 | name 64 | ofType { 65 | kind 66 | name 67 | ofType { 68 | kind 69 | name 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/utils/queries/graphql-introspection-query-0.4.8.graphql: -------------------------------------------------------------------------------- 1 | query IntrospectionQuery { 2 | __schema { 3 | queryType { name } 4 | mutationType { name } 5 | subscriptionType { name } 6 | types { 7 | ...FullType 8 | } 9 | directives { 10 | name 11 | description 12 | args { 13 | ...InputValue 14 | } 15 | onOperation 16 | onFragment 17 | onField 18 | } 19 | } 20 | } 21 | 22 | fragment FullType on __Type { 23 | kind 24 | name 25 | description 26 | fields { 27 | name 28 | description 29 | args { 30 | ...InputValue 31 | } 32 | type { 33 | ...TypeRef 34 | } 35 | isDeprecated 36 | deprecationReason 37 | } 38 | inputFields { 39 | ...InputValue 40 | } 41 | interfaces { 42 | ...TypeRef 43 | } 44 | enumValues { 45 | name 46 | description 47 | isDeprecated 48 | deprecationReason 49 | } 50 | possibleTypes { 51 | ...TypeRef 52 | } 53 | } 54 | 55 | fragment InputValue on __InputValue { 56 | name 57 | description 58 | type { ...TypeRef } 59 | defaultValue 60 | } 61 | 62 | fragment TypeRef on __Type { 63 | kind 64 | name 65 | ofType { 66 | kind 67 | name 68 | ofType { 69 | kind 70 | name 71 | ofType { 72 | kind 73 | name 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/utils/queries/graphql-introspection-query-0.5.0.graphql: -------------------------------------------------------------------------------- 1 | query IntrospectionQuery { 2 | __schema { 3 | queryType { name } 4 | mutationType { name } 5 | subscriptionType { name } 6 | types { 7 | ...FullType 8 | } 9 | directives { 10 | name 11 | description 12 | locations 13 | args { 14 | ...InputValue 15 | } 16 | } 17 | } 18 | } 19 | 20 | fragment FullType on __Type { 21 | kind 22 | name 23 | description 24 | fields(includeDeprecated: true) { 25 | name 26 | description 27 | args { 28 | ...InputValue 29 | } 30 | type { 31 | ...TypeRef 32 | } 33 | isDeprecated 34 | deprecationReason 35 | } 36 | inputFields { 37 | ...InputValue 38 | } 39 | interfaces { 40 | ...TypeRef 41 | } 42 | enumValues(includeDeprecated: true) { 43 | name 44 | description 45 | isDeprecated 46 | deprecationReason 47 | } 48 | possibleTypes { 49 | ...TypeRef 50 | } 51 | } 52 | 53 | fragment InputValue on __InputValue { 54 | name 55 | description 56 | type { ...TypeRef } 57 | defaultValue 58 | } 59 | 60 | fragment TypeRef on __Type { 61 | kind 62 | name 63 | ofType { 64 | kind 65 | name 66 | ofType { 67 | kind 68 | name 69 | ofType { 70 | kind 71 | name 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/utils/queries/graphql-introspection-query-0.4.10.graphql: -------------------------------------------------------------------------------- 1 | query IntrospectionQuery { 2 | __schema { 3 | queryType { name } 4 | mutationType { name } 5 | subscriptionType { name } 6 | types { 7 | ...FullType 8 | } 9 | directives { 10 | name 11 | description 12 | args { 13 | ...InputValue 14 | } 15 | onOperation 16 | onFragment 17 | onField 18 | } 19 | } 20 | } 21 | 22 | fragment FullType on __Type { 23 | kind 24 | name 25 | description 26 | fields(includeDeprecated: true) { 27 | name 28 | description 29 | args { 30 | ...InputValue 31 | } 32 | type { 33 | ...TypeRef 34 | } 35 | isDeprecated 36 | deprecationReason 37 | } 38 | inputFields { 39 | ...InputValue 40 | } 41 | interfaces { 42 | ...TypeRef 43 | } 44 | enumValues(includeDeprecated: true) { 45 | name 46 | description 47 | isDeprecated 48 | deprecationReason 49 | } 50 | possibleTypes { 51 | ...TypeRef 52 | } 53 | } 54 | 55 | fragment InputValue on __InputValue { 56 | name 57 | description 58 | type { ...TypeRef } 59 | defaultValue 60 | } 61 | 62 | fragment TypeRef on __Type { 63 | kind 64 | name 65 | ofType { 66 | kind 67 | name 68 | ofType { 69 | kind 70 | name 71 | ofType { 72 | kind 73 | name 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /test/store/redis-store.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { RedisStore } from '../../lib/store' 3 | 4 | describe('RedisStore', () => { 5 | const query = 'query TestQuery { firstName }' 6 | const queryHash = 'FoZSVHVMq0lErDt43A50mbb4MsYSM55MrEUTr53Xvv0=' 7 | 8 | let store = null 9 | 10 | beforeEach(async () => { 11 | store = new RedisStore({ keyPrefix: 'graphql-query-whitelist-test:' }) 12 | await store.clear() 13 | }) 14 | 15 | it('puts a query into the store', async () => { 16 | expect(await store.redisClient.hexists('queries', queryHash)).to.equal(0) 17 | 18 | await store.set(queryHash, query) 19 | 20 | expect(await store.redisClient.hexists('queries', queryHash)).to.equal(1) 21 | }) 22 | 23 | it('gets a query from the store', async () => { 24 | expect(await store.get(queryHash)).to.be.undefined 25 | 26 | await store.set(queryHash, query) 27 | 28 | expect(await store.get(queryHash)).to.equal(query) 29 | }) 30 | 31 | it('gets all the entries', async () => { 32 | await Promise.all([store.set('foo', 'bar'), store.set('foo2', 'bar2')]) 33 | expect(await store.entries()).to.deep.include.members([['foo', 'bar'], ['foo2', 'bar2']]) 34 | }) 35 | 36 | it('deletes an entry', async () => { 37 | await store.set(queryHash, query) 38 | 39 | expect(await store.get(queryHash)).to.equal(query) 40 | 41 | await store.delete(queryHash) 42 | 43 | expect(await store.get(queryHash)).to.be.undefined 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /lib/utils/queries/graphiql-introspection-query-0.7.3.graphql: -------------------------------------------------------------------------------- 1 | query IntrospectionQuery { 2 | __schema { 3 | queryType { name } 4 | mutationType { name } 5 | types { 6 | ...FullType 7 | } 8 | directives { 9 | name 10 | description 11 | locations 12 | args { 13 | ...InputValue 14 | } 15 | } 16 | } 17 | } 18 | fragment FullType on __Type { 19 | kind 20 | name 21 | description 22 | fields(includeDeprecated: true) { 23 | name 24 | description 25 | args { 26 | ...InputValue 27 | } 28 | type { 29 | ...TypeRef 30 | } 31 | isDeprecated 32 | deprecationReason 33 | } 34 | inputFields { 35 | ...InputValue 36 | } 37 | interfaces { 38 | ...TypeRef 39 | } 40 | enumValues(includeDeprecated: true) { 41 | name 42 | description 43 | isDeprecated 44 | deprecationReason 45 | } 46 | possibleTypes { 47 | ...TypeRef 48 | } 49 | } 50 | fragment InputValue on __InputValue { 51 | name 52 | description 53 | type { ...TypeRef } 54 | defaultValue 55 | } 56 | fragment TypeRef on __Type { 57 | kind 58 | name 59 | ofType { 60 | kind 61 | name 62 | ofType { 63 | kind 64 | name 65 | ofType { 66 | kind 67 | name 68 | ofType { 69 | kind 70 | name 71 | ofType { 72 | kind 73 | name 74 | ofType { 75 | kind 76 | name 77 | ofType { 78 | kind 79 | name 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/utils/queries/graphql-introspection-query-0.6.0.graphql: -------------------------------------------------------------------------------- 1 | query IntrospectionQuery { 2 | __schema { 3 | queryType { name } 4 | mutationType { name } 5 | subscriptionType { name } 6 | types { 7 | ...FullType 8 | } 9 | directives { 10 | name 11 | description 12 | locations 13 | args { 14 | ...InputValue 15 | } 16 | } 17 | } 18 | } 19 | 20 | fragment FullType on __Type { 21 | kind 22 | name 23 | description 24 | fields(includeDeprecated: true) { 25 | name 26 | description 27 | args { 28 | ...InputValue 29 | } 30 | type { 31 | ...TypeRef 32 | } 33 | isDeprecated 34 | deprecationReason 35 | } 36 | inputFields { 37 | ...InputValue 38 | } 39 | interfaces { 40 | ...TypeRef 41 | } 42 | enumValues(includeDeprecated: true) { 43 | name 44 | description 45 | isDeprecated 46 | deprecationReason 47 | } 48 | possibleTypes { 49 | ...TypeRef 50 | } 51 | } 52 | 53 | fragment InputValue on __InputValue { 54 | name 55 | description 56 | type { ...TypeRef } 57 | defaultValue 58 | } 59 | 60 | fragment TypeRef on __Type { 61 | kind 62 | name 63 | ofType { 64 | kind 65 | name 66 | ofType { 67 | kind 68 | name 69 | ofType { 70 | kind 71 | name 72 | ofType { 73 | kind 74 | name 75 | ofType { 76 | kind 77 | name 78 | ofType { 79 | kind 80 | name 81 | ofType { 82 | kind 83 | name 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /lib/utils/query-repository.js: -------------------------------------------------------------------------------- 1 | import parseQuery from './parse-query' 2 | 3 | export class QueryNotFoundError { 4 | constructor(message) { 5 | this.name = 'QueryNotFound' 6 | this.message = message || 'Query not found' 7 | this.stack = (new Error()).stack 8 | } 9 | } 10 | 11 | QueryNotFoundError.prototype = Object.create(Error.prototype) 12 | 13 | class QueryRepository { 14 | constructor(store) { 15 | this.store = store 16 | } 17 | 18 | async get(queryId) { 19 | const entry = await this.store.get(queryId) 20 | if (!entry) throw new QueryNotFoundError() 21 | 22 | return { id: queryId, ...entry } 23 | } 24 | 25 | async put(query, options = {}) { 26 | let { queryId, operationName, normalizedQuery } = parseQuery(query, { requireOperationName: false }) 27 | operationName = options.operationName || operationName || 'Unnamed query' 28 | const queryObj = { query: normalizedQuery, operationName, enabled: true } 29 | await this.store.set(queryId, queryObj) 30 | 31 | return { id: queryId, ...queryObj } 32 | } 33 | 34 | async update(queryId, properties = {}) { 35 | let query = await this.get(queryId) 36 | 37 | // don't allow to update the query 38 | delete properties.query 39 | 40 | query = { ...query, ...properties } 41 | await this.store.set(queryId, query) 42 | 43 | return { id: queryId, ...query } 44 | } 45 | 46 | async entries() { 47 | const entries = await this.store.entries() 48 | return entries.map(([queryId, properties]) => ({ ...properties, id: queryId })).reverse() 49 | } 50 | 51 | async delete(queryId) { 52 | // check if the query exists. raise an error if not 53 | await this.get(queryId) 54 | return this.store.delete(queryId) 55 | } 56 | } 57 | 58 | export default QueryRepository 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-query-whitelist", 3 | "version": "0.5.2", 4 | "description": "A very simple GraphQL query whitelist middleware for express", 5 | "main": "./dist/index.js", 6 | "bin": { 7 | "gql-whitelist": "./dist/utils/gql-whitelist.js" 8 | }, 9 | "scripts": { 10 | "lint": "eslint .", 11 | "test": "NODE_ENV=test NODE_PATH=src mocha --timeout 1000 --compilers js:babel-core/register --require babel-polyfill ./test/*.js ./test/store/*.js ./test/utils/*.js", 12 | "build": "babel lib --out-dir dist && mkdir -p dist/utils/queries && cp -vr lib/utils/queries dist/utils", 13 | "prepublish": "npm run build" 14 | }, 15 | "author": "Gabriel Schammah ", 16 | "license": "MIT", 17 | "peerDependencies": { 18 | "graphql": "0.x", 19 | "body-parser": "1.x" 20 | }, 21 | "optionalDependencies": { 22 | "ioredis": "2.x" 23 | }, 24 | "devDependencies": { 25 | "babel-cli": "^6.18.0", 26 | "babel-core": "^6.18.0", 27 | "babel-eslint": "^7.1.0", 28 | "babel-plugin-transform-runtime": "^6.15.0", 29 | "babel-polyfill": "^6.16.0", 30 | "babel-preset-es2015": "^6.18.0", 31 | "babel-preset-stage-0": "^6.16.0", 32 | "body-parser": "^1.15.2", 33 | "chai": "^3.5.0", 34 | "chai-spies": "^0.7.1", 35 | "eslint": "^3.8.1", 36 | "eslint-config-standard": "^6.2.1", 37 | "eslint-plugin-mocha": "^4.7.0", 38 | "eslint-plugin-promise": "^3.3.0", 39 | "eslint-plugin-standard": "^2.0.1", 40 | "express": "^4.14.0", 41 | "express-graphql": "^0.5.4", 42 | "graphql": "^0.7.2", 43 | "ioredis": "^2.4.0", 44 | "mocha": "^3.1.2", 45 | "supertest": "^2.0.1" 46 | }, 47 | "repository": "https://www.github.com/restorando/graphql-query-whitelist", 48 | "dependencies": { 49 | "axios": "^0.15.3", 50 | "babel-runtime": "^6.18.0", 51 | "optimist": "^0.6.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/api/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import QueryRepository, { QueryNotFoundError } from '../utils/query-repository' 3 | 4 | const handleError = (error, res) => { 5 | const statusCode = error instanceof QueryNotFoundError ? 404 : 422 6 | res.status(statusCode).json({ error: error.message }) 7 | } 8 | 9 | export default (store) => { 10 | const router = express.Router() 11 | const repository = new QueryRepository(store) 12 | 13 | router.get('/queries', async (req, res) => { 14 | const queries = await repository.entries() 15 | res.json(queries) 16 | }) 17 | 18 | router.get('/queries/:id(*)', async (req, res) => { 19 | try { 20 | const query = await repository.get(req.params.id) 21 | res.json(query) 22 | } catch (e) { 23 | handleError(e, res) 24 | } 25 | }) 26 | 27 | router.post('/queries', async (req, res) => { 28 | try { 29 | const result = await repository.put(req.body.query) 30 | res.status(201).json(result) 31 | } catch (e) { 32 | res.status(422).json({ error: e.message }) 33 | } 34 | }) 35 | 36 | router.put('/queries/:id(*)', async (req, res) => { 37 | const { enabled, operationName } = req.body 38 | const newProperties = { enabled, operationName } 39 | 40 | // Manually check each updatable property since they are only 2 41 | if (typeof enabled !== 'boolean') delete newProperties.enabled 42 | if (!operationName) delete newProperties.operationName 43 | 44 | try { 45 | const query = await repository.update(req.params.id, newProperties) 46 | res.json(query) 47 | } catch (e) { 48 | handleError(e, res) 49 | } 50 | }) 51 | 52 | router.delete('/queries/:id(*)', async (req, res) => { 53 | try { 54 | await repository.delete(req.params.id) 55 | res.status(200).end() 56 | } catch (e) { 57 | handleError(e, res) 58 | } 59 | }) 60 | 61 | return router 62 | } 63 | -------------------------------------------------------------------------------- /lib/utils/gql-whitelist.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import optimist from 'optimist' 4 | import { getQueriesFromDir } from './' 5 | import axios from 'axios' 6 | 7 | const { argv } = optimist 8 | .usage('Stores the queries residing in the specified directories.\nUsage: gql-whitelist dir1 [dir2] [dir3] ') 9 | .demand('endpoint') 10 | .alias('H', 'header') 11 | .describe('endpoint', 'Base URL of query whitelist API') 12 | .describe('header', 'Header to send to the API: e.g key=value') 13 | 14 | const { _: directories, endpoint, header } = argv 15 | 16 | const parseHeaders = headers => { 17 | headers = Array.isArray(headers) ? headers : [headers] 18 | 19 | return headers.reduce((result, header) => { 20 | const [key, val] = header.split(/=(.+)/) 21 | result[key] = val 22 | return result 23 | }, {}) 24 | } 25 | 26 | const client = axios.create({ 27 | baseURL: endpoint, 28 | timeout: 10000, 29 | maxRedirects: 0, 30 | headers: header && parseHeaders(header) 31 | }) 32 | 33 | const addQueryToWhitelist = async ({ query, filename }) => { 34 | try { 35 | console.log(`Adding query from ${filename}`) 36 | const { data: { operationName, id } } = await client.post('/queries', { query }) 37 | console.log(`${filename} => ${operationName} => ${id}`) 38 | } catch (e) { 39 | if (e.response) { 40 | console.error(e.response.data) 41 | } else { 42 | console.error(e) 43 | } 44 | throw e 45 | } 46 | } 47 | 48 | const execute = async () => { 49 | let store 50 | 51 | try { 52 | await Promise.all(directories.map(async dir => { 53 | const queries = await getQueriesFromDir(dir) 54 | return Promise.all(queries.map(addQueryToWhitelist)) 55 | })) 56 | console.log('All queries stored successfully') 57 | } catch (e) { 58 | console.trace(e) 59 | process.exitCode = 1 60 | } finally { 61 | store && store.redisClient.quit() 62 | } 63 | } 64 | 65 | execute() 66 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import { parseQuery, QueryRepository, QueryNotFoundError, storeIntrospectionQueries as storeQueries } from './utils' 2 | import { formatError } from 'graphql' 3 | 4 | const noop = () => { } 5 | 6 | export default ({ store, skipValidationFn = noop, validationErrorFn = noop, 7 | storeIntrospectionQueries = false, dryRun = false }) => { 8 | const repository = new QueryRepository(store) 9 | const storeIQPromise = storeIntrospectionQueries ? storeQueries(repository) : Promise.resolve() 10 | 11 | if (dryRun) { 12 | console.info('[graphql-query-whitelist] - running in dry run mode') 13 | } 14 | 15 | return async (req, res, next) => { 16 | const body = req.body 17 | 18 | if (typeof body !== 'object') { 19 | return next(new Error('body-parser middleware (https://github.com/expressjs/body-parser) must be ' + 20 | 'inserted before graphql-query-whitelist middleware')) 21 | } 22 | 23 | // Needed to render GraphiQL if using express-graphql 24 | if (req.method === 'GET' && !req.query.queryId && !req.query.query) return next() 25 | 26 | const unauthorized = (errorCode, error = { message: 'Unauthorized query' }, statusCode = 401) => { 27 | validationErrorFn(req, { errorCode }) 28 | 29 | if (dryRun) { 30 | next() 31 | } else { 32 | res.status(statusCode).json({ errors: [formatError(error)] }) 33 | } 34 | } 35 | 36 | if (skipValidationFn(req)) return next() 37 | 38 | try { 39 | const queryId = body.queryId || req.query.queryId 40 | let queryObj = {} 41 | 42 | await storeIQPromise 43 | 44 | if (queryId) { 45 | queryObj = { queryId } 46 | } else if (body.query) { 47 | queryObj = parseQuery(body.query, { requireOperationName: false }) 48 | } else { 49 | // No queryId or query was specified. Let express-graphql handle this 50 | next() 51 | } 52 | 53 | req.queryId = queryObj.queryId 54 | req.operationName = queryObj.operationName 55 | 56 | const { query, enabled } = await repository.get(queryObj.queryId) 57 | body.query = query 58 | 59 | enabled ? next() : unauthorized('QUERY_DISABLED') 60 | } catch (error) { 61 | if (error instanceof QueryNotFoundError) { 62 | unauthorized('QUERY_NOT_FOUND') 63 | } else { 64 | unauthorized('GRAPHQL_ERROR', error, 400) 65 | } 66 | } 67 | } 68 | } 69 | 70 | export Api from './api' 71 | export { MemoryStore, RedisStore } from './store' 72 | export { QueryRepository } 73 | -------------------------------------------------------------------------------- /test/utils/query-repository.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import QueryRepository from '../../lib/utils/query-repository' 3 | import MemoryStore from '../../lib/store/memory-store' 4 | 5 | describe('QueryRepository', () => { 6 | const query = 'query TestQuery { firstName }' 7 | const queryId = 'FoZSVHVMq0lErDt43A50mbb4MsYSM55MrEUTr53Xvv0=' 8 | 9 | const expectedQuery = { 10 | enabled: true, 11 | operationName: 'TestQuery', 12 | query: 'query TestQuery {\n firstName\n}\n' 13 | } 14 | const expectedFullQuery = { ...expectedQuery, id: queryId } 15 | const store = new MemoryStore() 16 | const repo = new QueryRepository(store) 17 | 18 | beforeEach(() => store.clear()) 19 | 20 | it('puts a query into the store', async () => { 21 | expect(store.queries.has(queryId)).to.be.false 22 | 23 | await repo.put(query) 24 | 25 | expect(store.queries.has(queryId)).to.be.true 26 | expect(store.queries.get(queryId)).to.deep.equal(expectedQuery) 27 | }) 28 | 29 | it('overrides the operation name', async () => { 30 | expect(store.queries.has(queryId)).to.be.false 31 | 32 | await repo.put(query, { operationName: 'foo' }) 33 | 34 | expect(store.queries.get(queryId)).to.deep.equal({ 35 | enabled: true, 36 | operationName: 'foo', 37 | query: 'query TestQuery {\n firstName\n}\n' 38 | }) 39 | }) 40 | 41 | it('gets a query from the store', async () => { 42 | await repo.put(query) 43 | 44 | expect(await repo.get(queryId)).to.deep.equal(expectedFullQuery) 45 | }) 46 | 47 | it('updates a query from the store', async () => { 48 | await repo.put(query) 49 | 50 | expect(await repo.get(queryId)).to.deep.equal(expectedFullQuery) 51 | 52 | await repo.update(queryId, { enabled: false, operationName: 'UpdatedQuery' }) 53 | 54 | expect(await repo.get(queryId)).to.deep.equal({ 55 | ...expectedFullQuery, 56 | operationName: 'UpdatedQuery', 57 | enabled: false 58 | }) 59 | }) 60 | 61 | it('gets all entries', async () => { 62 | const anotherQuery = 'query AnotherQuery { lastName }' 63 | const anotherExpectedQuery = { 64 | enabled: true, 65 | id: 'uaqhFBxe3pkrjGmEzbiZrAgaHD0G1ojQJJmGdPHgwS0=', 66 | operationName: 'AnotherQuery', 67 | query: 'query AnotherQuery {\n lastName\n}\n' 68 | } 69 | 70 | await Promise.all([repo.put(query), repo.put(anotherQuery)]) 71 | const entries = await repo.entries() 72 | 73 | expect(entries).to.deep.include.members([{ ...expectedFullQuery }, anotherExpectedQuery]) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /test/api.js: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest' 2 | import { expect } from 'chai' 3 | import app from './app' 4 | import { MemoryStore } from '../lib/store' 5 | import { QueryRepository } from '../lib/utils' 6 | 7 | describe('Api', () => { 8 | const existingQuery = 'query ExistingQuery { firstName }' 9 | const existingQueryId = '2d3bPA6TQe76B05EIgS8Vs7xHMjmW2m7g/INufnCWbA=' 10 | const newQuery = 'query ValidQuery { firstName }' 11 | const newQueryId = 'Hwf+pzIq09drbuQSzDSAXEwuk9HfwrGKw7yFzd1buNM=' 12 | const fullQuery = { 13 | id: existingQueryId, 14 | query: 'query ExistingQuery {\n firstName\n}\n', 15 | operationName: 'ExistingQuery', 16 | enabled: true 17 | } 18 | 19 | let request 20 | const store = new MemoryStore() 21 | 22 | beforeEach(async () => { 23 | await store.clear() 24 | const repository = new QueryRepository(store) 25 | await repository.put(existingQuery) 26 | 27 | request = supertest(app({ store })) 28 | }) 29 | 30 | describe('GET /queries', () => { 31 | it('lists the queries', done => { 32 | request 33 | .get(`/api/queries`) 34 | .expect([fullQuery], done) 35 | }) 36 | }) 37 | 38 | describe('GET /queries/:id', () => { 39 | it('gets an existing query', done => { 40 | request 41 | .get(`/api/queries/${existingQueryId}`) 42 | .expect(fullQuery, done) 43 | }) 44 | 45 | it('returns a 404 if the query does no exist', done => { 46 | request 47 | .get(`/api/queries/invalidQuery`) 48 | .expect(404, { error: 'Query not found' }, done) 49 | }) 50 | }) 51 | 52 | describe('POST /queries', () => { 53 | it('creates a new query', done => { 54 | request 55 | .post('/api/queries') 56 | .send({ query: newQuery }) 57 | .expect(201, { 58 | id: newQueryId, 59 | query: 'query ValidQuery {\n firstName\n}\n', 60 | operationName: 'ValidQuery', 61 | enabled: true 62 | }, done) 63 | }) 64 | }) 65 | 66 | describe('PUT /queries/:id', () => { 67 | it('updates an existing query', done => { 68 | request 69 | .put(`/api/queries/${existingQueryId}`) 70 | .send({ enabled: false, operationName: 'foo' }) 71 | .expect({ ...fullQuery, enabled: false, operationName: 'foo' }, done) 72 | }) 73 | 74 | it('returns a 404 if the query that needs to be updated does not exist', done => { 75 | request 76 | .put(`/api/queries/invalidQuery`) 77 | .expect(404, { error: 'Query not found' }, done) 78 | }) 79 | }) 80 | 81 | describe('DELETE /queries/:id', () => { 82 | it('deletes an existing query', done => { 83 | store.get(existingQueryId).then((query) => { 84 | expect(query).not.to.be.undefined 85 | 86 | request 87 | .delete(`/api/queries/${existingQueryId}`) 88 | .end(async (err, res) => { 89 | if (err) return done(err) 90 | expect(await store.get(existingQueryId)).to.be.undefined 91 | done() 92 | }) 93 | }) 94 | }) 95 | 96 | it('returns a 404 if the query does not exist', done => { 97 | request 98 | .delete(`/api/queries/invalidQuery`) 99 | .expect(404, done) 100 | }) 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /test/middleware.js: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest' 2 | import chai from 'chai' 3 | import spies from 'chai-spies' 4 | 5 | import app from './app' 6 | import graphqlWhitelist from '../lib' 7 | import { MemoryStore } from '../lib/store' 8 | import { QueryRepository } from '../lib/utils' 9 | 10 | chai.use(spies) 11 | 12 | const { expect } = chai 13 | 14 | describe('Query whitelisting middleware', () => { 15 | const validQuery = 'query ValidQuery { firstName }' 16 | const validQueryId = 'Hwf+pzIq09drbuQSzDSAXEwuk9HfwrGKw7yFzd1buNM=' 17 | const invalidQuery = 'query InvalidQuery { lastName }' 18 | const unauthorizedError = { errors: [{ message: 'Unauthorized query' }] } 19 | 20 | let store, repository, request 21 | 22 | beforeEach(async () => { 23 | store = new MemoryStore() 24 | repository = new QueryRepository(store) 25 | await repository.put(validQuery) 26 | request = supertest(app({ store })) 27 | }) 28 | 29 | describe('Query whitelisting', () => { 30 | it('throws an error if body is not being parsed before by a middleware', done => { 31 | supertest(app({ store, noBodyParser: true })) 32 | .get('/graphql') 33 | .query({ query: validQuery }) 34 | .expect(500, (err, res) => { 35 | if (err) return done(err) 36 | expect(res.error.text).to.include('body-parser middleware') 37 | done() 38 | }) 39 | }) 40 | 41 | it('allows a valid query', done => { 42 | request 43 | .post('/graphql') 44 | .send({ query: validQuery }) 45 | .expect({ data: { firstName: 'John' } }, done) 46 | }) 47 | 48 | it('doesn\'t allow an invalid query', done => { 49 | request 50 | .post('/graphql') 51 | .send({ query: invalidQuery }) 52 | .expect(401, unauthorizedError, done) 53 | }) 54 | 55 | it('allows to send only the queryId using a body parameter', done => { 56 | request 57 | .post('/graphql') 58 | .send({ queryId: validQueryId }) 59 | .expect({ data: { firstName: 'John' } }, done) 60 | }) 61 | 62 | it('allows to send only the queryId using a query parameter', done => { 63 | request 64 | .post('/graphql') 65 | .query({ queryId: validQueryId }) 66 | .expect({ data: { firstName: 'John' } }, done) 67 | }) 68 | 69 | it('skips the middleware for a GET if no query is being sent', done => { 70 | request 71 | .get('/graphql') 72 | .expect(400, { errors: [{ message: 'Must provide query string.' }] }, done) 73 | }) 74 | 75 | it('allows to query the schema via GET', done => { 76 | request 77 | .get('/graphql') 78 | .query({ query: invalidQuery }) 79 | .expect(401, done) 80 | }) 81 | 82 | it('allows to query the schema via GET using a queryId', done => { 83 | request 84 | .get('/graphql') 85 | .query({ queryId: validQueryId }) 86 | .expect(200, { data: { firstName: 'John' } }, done) 87 | }) 88 | }) 89 | 90 | describe('Query normalization', () => { 91 | [validQuery, 'query ValidQuery \n{\n\nfirstName\n\n }'].forEach((query) => { 92 | it(`adds the QueryId and the normalizedQuery attributes to the req object`, async () => { 93 | const req = { body: { query }, query: {} } 94 | const res = {} 95 | const next = () => {} 96 | 97 | const normalizedQuery = 'query ValidQuery {\n firstName\n}\n' 98 | 99 | await graphqlWhitelist({ store })(req, res, next) 100 | 101 | expect(req.queryId).to.equal(validQueryId) 102 | expect(req.operationName).to.equal('ValidQuery') 103 | expect(req.body.query).to.equal(normalizedQuery) 104 | }) 105 | }) 106 | }) 107 | 108 | describe('Skip validation function', () => { 109 | it('doesn\'t skip the middleware if the skip function is not provided', done => { 110 | const request = supertest(app({ store })) 111 | 112 | request 113 | .post('/graphql') 114 | .send({ query: invalidQuery }) 115 | .expect(401) 116 | .expect(unauthorizedError, done) 117 | }) 118 | 119 | it('skips the middleware if the skip function returns a truthy value', done => { 120 | const request = supertest(app({ store, skipValidationFn: () => true })) 121 | 122 | request 123 | .post('/graphql') 124 | .send({ query: invalidQuery }) 125 | .expect({ data: { lastName: 'Cook' } }, done) 126 | }) 127 | 128 | it('doesn\'t skip the middleware if the skip function returns a falsey value', done => { 129 | const request = supertest(app({ store, skipValidationFn: () => false })) 130 | 131 | request 132 | .post('/graphql') 133 | .send({ query: invalidQuery }) 134 | .expect(401) 135 | .expect(unauthorizedError, done) 136 | }) 137 | }) 138 | 139 | describe('Error validation function', () => { 140 | it('calls the error validation function if the query is invalid', done => { 141 | const spy = chai.spy() 142 | const request = supertest(app({ store, validationErrorFn: spy })) 143 | 144 | request 145 | .post('/graphql') 146 | .send({ query: invalidQuery }) 147 | .expect(401, (err, res) => { 148 | if (err) return done(err) 149 | expect(spy).to.have.been.called() 150 | done() 151 | }) 152 | }) 153 | 154 | it('doesn\'t call the error validation function if the query is valid', done => { 155 | const spy = chai.spy() 156 | const request = supertest(app({ store, validationErrorFn: spy })) 157 | 158 | request 159 | .post('/graphql') 160 | .send({ query: validQuery }) 161 | .expect(200, (err, res) => { 162 | if (err) return done(err) 163 | expect(spy).to.not.have.been.called() 164 | done() 165 | }) 166 | }) 167 | 168 | describe('Dry run', () => { 169 | it('calls the error validation function but skips the whitelisting', done => { 170 | const spy = chai.spy() 171 | const request = supertest(app({ store, validationErrorFn: spy, dryRun: true })) 172 | 173 | request 174 | .post('/graphql') 175 | .send({ query: invalidQuery }) 176 | .expect(200, (err, res) => { 177 | if (err) return done(err) 178 | expect(spy).to.have.been.called() 179 | done() 180 | }) 181 | }) 182 | }) 183 | }) 184 | }) 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-query-whitelist 2 | A simple GraphQL query whitelist toolkit for `express`. 3 | 4 | It includes: 5 | 6 | * An `express` middleware that prevents queries not in the whitelist to be executed. It also allows to execute queries just passing a previously stored `queryId` instead of the full query. 7 | * A REST API to create/get/list/enable/disable/delete queries from the whitelist 8 | * A `MemoryStore` and `RedisStore` to store the queries 9 | * An utility class (`QueryRepository`) to perform CRUD operations programmatically 10 | * A binary (`gql-whitelist`) that whitelist all the files with `.graphql` extension in a specified directory (useful to automatically whitelist queries on build time) 11 | 12 | A UI to manage the whitelisted queries is [available here](https://github.com/restorando/graphql-query-whitelist-ui) 13 | 14 | # Rationale 15 | 16 | One of the security concerns for a typical GraphQL app is that it lacks of a security mechanism out of the box. 17 | 18 | By default, anyone can query any field of your GraphQL app, and if your schema supports nested queries, a malicious attacker could make a query that consumes all the resources of the server. 19 | 20 | Example: 21 | 22 | ``` 23 | query RecursiveQuery { 24 | friends { 25 | username 26 | 27 | friends { 28 | username 29 | 30 | friends { 31 | username 32 | 33 | friends { ... } 34 | } 35 | } 36 | } 37 | } 38 | ``` 39 | 40 | This middleware avoids this type of queries checking if the incoming query is whitelisted or not. 41 | 42 | (more info: [source 1](https://edgecoders.com/graphql-deep-dive-the-cost-of-flexibility-ee50f131a83d#.6okcpvtri), [source 2](https://dev-blog.apollodata.com/5-benefits-of-static-graphql-queries-b7fa90b0b69a)) 43 | 44 | # Installation 45 | 46 | `npm install --save graphql-query-whitelist graphql body-parser` 47 | 48 | In your app: 49 | 50 | ```js 51 | import express from 'express' 52 | import bodyParser from 'body-parser' 53 | import graphqlWhitelist, { MemoryStore } from 'graphql-query-whitelist' 54 | 55 | const app = express() 56 | const store = new MemoryStore() 57 | // body-parser must be included before including the query whitelist middleware 58 | app.use(bodyParser.json()) 59 | app.post('/graphql', graphqlWhitelist({ store })) 60 | ``` 61 | 62 | Before each request is processed by GraphQL, it will check if the inbound query is in the whitelist or not. 63 | If it's not in the whitelist, it will respond with a 401 status code. 64 | 65 | # Running queries only sending the `queryId` 66 | 67 | Since the server has access to the query store, and the store has access to the full queries, it's possible to run a query just by sending the queryId. 68 | 69 | E.g: `POST /graphql?queryId=dSPDigYWUw2w9wTI9g0RrbakmsJiRFIvTUa59jnZsV4=` 70 | 71 | # Storing and retrieving queries 72 | 73 | There are 2 ways of storing and retrieving queries: 74 | 75 | ### Rest API 76 | 77 | Normally you would want to automate the process of storing queries at the build time. 78 | 79 | This library includes a Rest API that you can mount in any `express` app to list, create, get, enable/disable and delete queries. 80 | 81 | Example: 82 | 83 | ```js 84 | import { Api as whitelistAPI, RedisStore } from 'graphql-query-whitelist' 85 | app.use('/whitelist', whitelistAPI(new RedisStore())) 86 | ``` 87 | 88 | It will mount these routes: 89 | 90 | ``` 91 | GET /whitelist/queries 92 | GET /whitelist/queries/:id 93 | POST /whitelist/queries 94 | PUT /whitelist/queries/:id 95 | DELETE /whitelist/queries/:id 96 | ``` 97 | 98 | ## Programmatically using the `QueryRepository` 99 | 100 | Example: 101 | 102 | ```js 103 | import { QueryRepository, MemoryStore } from 'graphql-query-whitelist' 104 | 105 | const store = new MemoryStore() 106 | const repository = new QueryRepository(store) 107 | 108 | const query = ` 109 | query MyQuery { 110 | users { 111 | firstName 112 | } 113 | } 114 | ` 115 | 116 | repository.put(query).then(console.log) 117 | 118 | /* 119 | * Prints: 120 | * { 121 | * id: 'dSPDigYWUw2w9wTI9g0RrbakmsJiRFIvTUa59jnZsV4=', 122 | * query: 'query MyQuery {\n users {\n firstName\n }\n}\n', 123 | * operationName: 'MyQuery', 124 | * enabled: true 125 | * } 126 | */ 127 | ``` 128 | 129 | The `QueryRepository` class exposes the following methods: 130 | 131 | * `get(queryId)` 132 | * `put(query)` 133 | * `update(queryId, properties)` 134 | * `entries()` 135 | * `delete(queryId)` 136 | 137 | # Stores 138 | 139 | A store is the medium to list, get, store and delete queries. 140 | 141 | It must implement the following methods: 142 | 143 | ##### `get(key)` 144 | It returns a `Promise` that resolves to the value for that key 145 | 146 | #### `set(key, value)` 147 | Returns a `Promise` that is resolved after the value is saved in the store 148 | 149 | #### `entries()` 150 | Returns a `Promise` that resolves to an array of all the entries stored, having the following format: 151 | `[[key1, val1], [key2, val2], ...]` 152 | 153 | #### `delete(key)` 154 | Returns a `Promise` that is resolved after the element is deleted from the store 155 | 156 | #### `clear()` 157 | Returns a `Promise` that is resolved after all the elements are deleted from the store 158 | 159 | Including in this library are 2 stores: 160 | 161 | * `MemoryStore` 162 | * `RedisStore` (needs to have [ioredis](https://github.com/luin/ioredis) installed) 163 | 164 | The `RedisStore` receives the [same constructor arguments as ioredis](https://github.com/luin/ioredis#connect-to-redis). 165 | 166 | # Middleware Options 167 | 168 | ### store 169 | 170 | This property is mandatory and must be a valid query store. 171 | 172 | ### skipValidationFn 173 | 174 | This property is optional and must be a function that receives the `express` request object and returns a boolean value. If a truthy value is returned, the whitelist check is skipped and the query is executed. 175 | 176 | This option is very useful to skip the whitelist check for certain apps that are already sending dynamic queries that are impossible to add to the whitelist. 177 | 178 | Example: 179 | 180 | ```js 181 | const skipValidationFn = (req) => req.get('X-App-Version') !== 'legacy-app-1.0' 182 | 183 | app.post('/graphql', graphqlWhitelist({ store, skipValidationFn })) 184 | ``` 185 | 186 | ### validationErrorFn 187 | 188 | This property is optional and must be a function that receives the `express` request object and will be called for every query that is prevented to be executed by this middleware. 189 | 190 | Example: 191 | 192 | ```js 193 | import { verbose, warn } from 'utils/log' 194 | 195 | const validationErrorFn = (req) => { 196 | warn(`Query '${req.operationName} (${req.queryId})' is not in the whitelist`) 197 | verbose(`Unauthorized query: ${req.body.query}`) 198 | } 199 | 200 | app.post('/graphql', graphqlWhitelist({ store, validationErrorFn })) 201 | ``` 202 | 203 | ### storeIntrospectionQueries 204 | 205 | If this option is set to true, `graphql-query-whitelist` will add to the whitelist all GraphQL and GraphiQL introspection queries. 206 | 207 | This option is disabled by default, but is needed if you are using GraphiQL and need to have the introspection queries whitelisted in order to have the autocompletion feature working. 208 | 209 | Example: 210 | 211 | ```js 212 | app.post('/graphql', graphqlWhitelist({ store, storeIntrospectionQueries: true })) 213 | ``` 214 | 215 | ### dryRun 216 | 217 | If this option is set to true, `graphql-query-whitelist` will validate the query against the whitelist and the `validationErrorFn` will be called, but the query will be executed as if the middleware is disabled. 218 | 219 | This is useful if you are starting to whitelist the queries long after your GraphQL server was first launched, and you need to log all the queries that are not yet whitelisted. 220 | 221 | Example: 222 | 223 | ```js 224 | app.post('/graphql', graphqlWhitelist({ store, dryRun: true })) 225 | ``` 226 | 227 | # Whitelisting queries automatically 228 | 229 | You may want to whitelist new queries everytime a query is added/changed in your project. This depends on `graphql` so make sure `graphql` is installed as well. 230 | 231 | ```bash 232 | $ npm install -g graphql-query-whitelist graphql 233 | 234 | $ gql-whitelist --endpoint http://your.graphql-endpoint.com/graphql /path/to/directory/containing/.graphql/files 235 | ``` 236 | 237 | Additionally, you can specify headers using the option `--header` 238 | 239 | ```bash 240 | $ gql-whitelist --endpoint http://your.graphql-endpoint.com/graphql --header key=value --header key2=value2 /path/to/directory/containing/.graphql/files 241 | ``` 242 | 243 | ## License 244 | 245 | Copyright (c) 2016 Restorando 246 | 247 | MIT License 248 | 249 | Permission is hereby granted, free of charge, to any person obtaining 250 | a copy of this software and associated documentation files (the 251 | "Software"), to deal in the Software without restriction, including 252 | without limitation the rights to use, copy, modify, merge, publish, 253 | distribute, sublicense, and/or sell copies of the Software, and to 254 | permit persons to whom the Software is furnished to do so, subject to 255 | the following conditions: 256 | 257 | The above copyright notice and this permission notice shall be 258 | included in all copies or substantial portions of the Software. 259 | 260 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 261 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 262 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 263 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 264 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 265 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 266 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 267 | --------------------------------------------------------------------------------