diff --git a/README.md b/README.md index cb47950..18d67c2 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ app.get('/login', (req, res) => res.send("OK")); /** * Graphql Express * @function GraphqlExpress - * @modules [graphql graphql-yoga@^4 ws@^8 graphql-ws@^5] + * @modules [graphql graphql-yoga@^4 ws@^8 graphql-ws@^5 @escape.tech/graphql-armor] * @envs [] * @param {object} the express app * @param {array} [{ @@ -153,6 +153,7 @@ app.get('/login', (req, res) => res.send("OK")); * @param {object} the options { * serverWS, // the express server * yogaOptions, // see: https://the-guild.dev/graphql/yoga-server/docs + * armorOptions, // GraphQL Armor security configuration * } * @return {object} express app.next() * @@ -163,6 +164,29 @@ app.get('/login', (req, res) => res.send("OK")); const server = app.listen(5000); GraphqlExpress(app, [{ typeDefs: '', resolvers: {} }], { serverWS: server, yogaOptions: {} }); * + * @example setup Graphql with Armor security: + ------------------------------------------- + import express from 'express'; + const app = express(); + const server = app.listen(5000); + + // Basic security configuration + const armorOptions = { + maxAliases: 0, // Disable aliases completely + maxDepth: 10, // Limit query depth + maxCost: 1000, // Cost-based limiting + maxDirectives: 5, // Limit directive usage + maxArguments: 10, // Limit arguments per field + blockFieldSuggestion: true, // Block field suggestions + disableIntrospection: false // Keep introspection for development + }; + * + * GraphqlExpress(app, [{ typeDefs: '', resolvers: {} }], { + * serverWS: server, + * yogaOptions: {}, + * armorOptions + * }); + * * @example server WebSocket: --------------------------- const { createPubSub } = await import('graphql-yoga'); @@ -196,6 +220,17 @@ GraphqlExpress(app, [{ typeDefs: '', resolvers: {} }], { serverWS: server, yogaO AutoLoad(["typeDefs", "directives", "resolvers"]).then(schemas => { GraphqlExpress(app, schemas, { serverWS: server, yogaOptions: {} }); }); + +// using AutoLoad with Armor security +AutoLoad(["typeDefs", "directives", "resolvers"]).then(schemas => { + const armorOptions = { + maxAliases: 0, + maxDepth: 10, + maxCost: 1000, + blockFieldSuggestion: true + }; + GraphqlExpress(app, schemas, { serverWS: server, yogaOptions: {}, armorOptions }); +}); ``` #### Elastic Indexer Express @@ -616,7 +651,7 @@ logger.info('...', '...'); - 9200:9200 - 9300:9300 kibana: - image: kibana + image: kibana@escape.tech/graphql-armor ports: - 5601:5601 environment: diff --git a/infrastructures/graphql-express.js b/infrastructures/graphql-express.js index 2760733..76aab39 100644 --- a/infrastructures/graphql-express.js +++ b/infrastructures/graphql-express.js @@ -1,4 +1,5 @@ import { DynamicImport } from '../utils/dynamic-import.js'; +import { Logger } from '../utils/logger.js'; /** * Graphql Express @@ -18,6 +19,7 @@ import { DynamicImport } from '../utils/dynamic-import.js'; * @param {object} the options { * serverWS, // the express server * yogaOptions, // see: https://the-guild.dev/graphql/yoga-server/docs + * armorOptions, // New parameter for Armor configuration * } * @return {promise} is done * @@ -26,7 +28,7 @@ import { DynamicImport } from '../utils/dynamic-import.js'; import express from 'express'; const app = express(); const server = app.listen(5000); - GraphqlExpress(app, [{ typeDefs: '', resolvers: {} }], { serverWS: server, yogaOptions: {} }); + GraphqlExpress(app, [{ typeDefs: '', resolvers: {} }], { serverWS: server, yogaOptions: {}, armorOptions: {} }); * * @example server WebSocket: --------------------------- @@ -57,7 +59,11 @@ import { DynamicImport } from '../utils/dynamic-import.js'; }); */ -export async function GraphqlExpress(app, schemas, { serverWS, yogaOptions } = {}) { +export async function GraphqlExpress(app, schemas, { + serverWS, + yogaOptions, + armorOptions = {} // New parameter for Armor configuration +} = {}) { /* * Imports @@ -66,6 +72,8 @@ export async function GraphqlExpress(app, schemas, { serverWS, yogaOptions } = { const { WebSocketServer } = await DynamicImport('ws@^8'); const { useServer } = await DynamicImport('graphql-ws/lib/use/ws'); await DynamicImport('graphql@^16'); + const { GraphQLArmor } = await DynamicImport('@escape.tech/graphql-armor'); + const logger = await Logger(); /* @@ -136,7 +144,34 @@ export async function GraphqlExpress(app, schemas, { serverWS, yogaOptions } = { /* * Create graphql route */ - app.use('/graphql', createYoga({ schema, graphiql: true, ...yogaOptions })); + // Configure GraphQL Armor with provided options or defaults + const defaultArmorOptions = { + maxDepth: 10, + maxDirectives: 5, + maxArguments: 10, + maxCost: 1000, + maxAliases: 0, // Disable aliases by default + blockFieldSuggestion: true, + disableIntrospection: false, // Keep introspection for development + onError: (error) => { + logger.warn('GraphQL Armor blocked query', { + type: error.type, + message: error.message, + query: error.query + }); + } + }; + + const finalArmorOptions = { ...defaultArmorOptions, ...armorOptions }; + + const armor = new GraphQLArmor(finalArmorOptions); + + app.use('/graphql', createYoga({ + schema, + graphiql: true, + plugins: [armor.plugin()], + ...yogaOptions + })); /* diff --git a/package.json b/package.json index 5111a2a..1e01488 100644 --- a/package.json +++ b/package.json @@ -25,4 +25,4 @@ "eslint-config-google": "^0.14.0", "mocha": "^10.2.0" } -} +} \ No newline at end of file diff --git a/test/core/graphql-armor.test.js b/test/core/graphql-armor.test.js new file mode 100644 index 0000000..28d2fb2 --- /dev/null +++ b/test/core/graphql-armor.test.js @@ -0,0 +1,629 @@ +import assert from 'assert'; +import express from 'express'; +import { GraphqlExpress } from '../../index.js'; + +describe('GraphQL Armor Security Features', function () { + let app; + let server; + + beforeEach(function () { + app = express(); + server = app.listen(0); // Use port 0 for random available port + }); + + afterEach(function () { + if (server) { + server.close(); + } + }); + + describe('#Alias Query Protection', function () { + it('should block alias queries when maxAliases is 0', async function () { + const schemas = [{ + typeDefs: ` + type Query { + user: User + users: [User] + } + type User { + id: ID + name: String + email: String + } + `, + resolvers: { + Query: { + user: () => ({ id: '1', name: 'John', email: 'john@example.com' }), + users: () => [ + { id: '1', name: 'John', email: 'john@example.com' }, + { id: '2', name: 'Jane', email: 'jane@example.com' } + ] + } + } + }]; + + const armorOptions = { + maxAliases: 0, + onError: (error) => { + // Error handler for testing + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + + it('should allow limited aliases when maxAliases is set', async function () { + const schemas = [{ + typeDefs: ` + type Query { + user: User + users: [User] + } + type User { + id: ID + name: String + email: String + } + `, + resolvers: { + Query: { + user: () => ({ id: '1', name: 'John', email: 'john@example.com' }), + users: () => [ + { id: '1', name: 'John', email: 'john@example.com' }, + { id: '2', name: 'Jane', email: 'jane@example.com' } + ] + } + } + }]; + + const armorOptions = { + maxAliases: 3, + onError: (error) => { + // Error handler for testing + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + }); + + describe('#Query Depth Limiting', function () { + it('should limit query depth when maxDepth is set', async function () { + const schemas = [{ + typeDefs: ` + type Query { + user: User + } + type User { + id: ID + name: String + profile: Profile + } + type Profile { + bio: String + user: User + } + `, + resolvers: { + Query: { + user: () => ({ id: '1', name: 'John' }) + }, + User: { + profile: () => ({ bio: 'Developer' }) + }, + Profile: { + user: () => ({ id: '1', name: 'John' }) + } + } + }]; + + const armorOptions = { + maxDepth: 3, + onError: (error) => { + // Error handler for testing + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + + it('should handle deep nested queries with depth limits', async function () { + const schemas = [{ + typeDefs: ` + type Query { + user: User + } + type User { + id: ID + name: String + posts: [Post] + } + type Post { + id: ID + title: String + author: User + comments: [Comment] + } + type Comment { + id: ID + text: String + post: Post + } + `, + resolvers: { + Query: { + user: () => ({ id: '1', name: 'John' }) + }, + User: { + posts: () => [{ id: '1', title: 'First Post' }] + }, + Post: { + author: () => ({ id: '1', name: 'John' }), + comments: () => [{ id: '1', text: 'Great post!' }] + }, + Comment: { + post: () => ({ id: '1', title: 'First Post' }) + } + } + }]; + + const armorOptions = { + maxDepth: 5, + onError: (error) => { + // Error handler for testing + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + }); + + describe('#Query Cost Limiting', function () { + it('should limit query cost when maxCost is set', async function () { + const schemas = [{ + typeDefs: ` + type Query { + users: [User] + } + type User { + id: ID + name: String + posts: [Post] + } + type Post { + id: ID + title: String + content: String + } + `, + resolvers: { + Query: { + users: () => Array(10).fill(null).map((_, i) => ({ + id: String(i), + name: `User ${i}` + })) + }, + User: { + posts: () => Array(5).fill(null).map((_, i) => ({ + id: String(i), + title: `Post ${i}`, + content: 'Content' + })) + } + } + }]; + + const armorOptions = { + maxCost: 100, + onError: (error) => { + // Error handler for testing + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + + it('should handle complex queries with cost limits', async function () { + const schemas = [{ + typeDefs: ` + type Query { + user: User + } + type User { + id: ID + name: String + profile: Profile + posts: [Post] + friends: [User] + } + type Profile { + bio: String + avatar: String + } + type Post { + id: ID + title: String + content: String + likes: Int + } + `, + resolvers: { + Query: { + user: () => ({ id: '1', name: 'John' }) + }, + User: { + profile: () => ({ bio: 'Developer', avatar: 'avatar.jpg' }), + posts: () => Array(3).fill(null).map((_, i) => ({ + id: String(i), + title: `Post ${i}`, + content: 'Content', + likes: Math.floor(Math.random() * 100) + })), + friends: () => Array(5).fill(null).map((_, i) => ({ + id: String(i + 2), + name: `Friend ${i}` + })) + } + } + }]; + + const armorOptions = { + maxCost: 200, + onError: (error) => { + // Error handler for testing + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + }); + + describe('#Directive Limiting', function () { + it('should limit directives when maxDirectives is set', async function () { + const schemas = [{ + typeDefs: ` + type Query { + user: User + } + type User { + id: ID + name: String + email: String + } + `, + resolvers: { + Query: { + user: () => ({ id: '1', name: 'John', email: 'john@example.com' }) + } + } + }]; + + const armorOptions = { + maxDirectives: 2, + onError: (error) => { + // Error handler for testing + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + }); + + describe('#Argument Limiting', function () { + it('should limit arguments when maxArguments is set', async function () { + const schemas = [{ + typeDefs: ` + type Query { + user(id: ID, name: String, email: String, role: String, status: String): User + } + type User { + id: ID + name: String + } + `, + resolvers: { + Query: { + user: (_, args) => ({ + id: args.id || '1', + name: args.name || 'John' + }) + } + } + }]; + + const armorOptions = { + maxArguments: 2, + onError: (error) => { + // Error handler for testing + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + }); + + describe('#Production Security Configuration', function () { + it('should handle strict production security settings', async function () { + const schemas = [{ + typeDefs: ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello World' + } + } + }]; + + const productionArmorOptions = { + maxAliases: 0, + maxDepth: 8, + maxCost: 500, + maxDirectives: 3, + maxArguments: 8, + maxTokens: 500, + blockFieldSuggestion: true, + disableIntrospection: true, + maxQueryLength: 5000, + maxQueryComplexity: 50, + onError: (error) => { + // Production error handling + } + }; + + const result = await GraphqlExpress(app, schemas, { + serverWS: server, + armorOptions: productionArmorOptions + }); + assert.strictEqual(result, true); + }); + }); + + describe('#Development Security Configuration', function () { + it('should handle relaxed development security settings', async function () { + const schemas = [{ + typeDefs: ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello World' + } + } + }]; + + const developmentArmorOptions = { + maxAliases: 5, + maxDepth: 15, + maxCost: 2000, + maxDirectives: 10, + maxArguments: 20, + maxTokens: 1000, + blockFieldSuggestion: false, + disableIntrospection: false, + maxQueryLength: 15000, + maxQueryComplexity: 200 + }; + + const result = await GraphqlExpress(app, schemas, { + serverWS: server, + armorOptions: developmentArmorOptions + }); + assert.strictEqual(result, true); + }); + }); + + describe('#Custom Error Handling', function () { + it('should handle custom error callbacks', async function () { + const schemas = [{ + typeDefs: ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello World' + } + } + }]; + + let errorCaught = false; + let errorType = null; + + const armorOptions = { + maxAliases: 0, + maxDepth: 10, + onError: (error) => { + errorCaught = true; + errorType = error.type; + assert.ok(error.message); + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + + it('should handle security violation logging', async function () { + const schemas = [{ + typeDefs: ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello World' + } + } + }]; + + let securityLog = []; + + const armorOptions = { + maxAliases: 0, + maxDepth: 5, + onError: (error) => { + securityLog.push({ + type: error.type, + message: error.message, + timestamp: new Date().toISOString() + }); + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + }); + + describe('#Multiple Query Limiting', function () { + it('should limit multiple queries in single request', async function () { + const schemas = [{ + typeDefs: ` + type Query { + user: User + users: [User] + posts: [Post] + } + type User { + id: ID + name: String + } + type Post { + id: ID + title: String + } + `, + resolvers: { + Query: { + user: () => ({ id: '1', name: 'John' }), + users: () => [{ id: '1', name: 'John' }, { id: '2', name: 'Jane' }], + posts: () => [{ id: '1', title: 'Post 1' }, { id: '2', title: 'Post 2' }] + } + } + }]; + + const armorOptions = { + maxCost: 50, + maxDepth: 3, + maxDirectives: 2, + onError: (error) => { + // Error handler for testing + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + + it('should handle complex multiple query scenarios', async function () { + const schemas = [{ + typeDefs: ` + type Query { + user: User + users: [User] + posts: [Post] + comments: [Comment] + } + type User { + id: ID + name: String + posts: [Post] + comments: [Comment] + } + type Post { + id: ID + title: String + author: User + comments: [Comment] + } + type Comment { + id: ID + text: String + author: User + post: Post + } + `, + resolvers: { + Query: { + user: () => ({ id: '1', name: 'John' }), + users: () => Array(5).fill(null).map((_, i) => ({ id: String(i), name: `User ${i}` })), + posts: () => Array(3).fill(null).map((_, i) => ({ id: String(i), title: `Post ${i}` })), + comments: () => Array(10).fill(null).map((_, i) => ({ id: String(i), text: `Comment ${i}` })) + }, + User: { + posts: () => Array(2).fill(null).map((_, i) => ({ id: String(i), title: `User Post ${i}` })), + comments: () => Array(3).fill(null).map((_, i) => ({ id: String(i), text: `User Comment ${i}` })) + }, + Post: { + author: () => ({ id: '1', name: 'John' }), + comments: () => Array(2).fill(null).map((_, i) => ({ id: String(i), text: `Post Comment ${i}` })) + }, + Comment: { + author: () => ({ id: '1', name: 'John' }), + post: () => ({ id: '1', title: 'Post 1' }) + } + } + }]; + + const armorOptions = { + maxCost: 300, + maxDepth: 4, + maxDirectives: 5, + maxArguments: 10, + maxTokens: 800, + onError: (error) => { + // Error handler for testing + } + }; + + await GraphqlExpress(app, schemas, { serverWS: server, armorOptions }); + assert.ok(app._router); + }); + }); + + describe('#Integration with AutoLoad', function () { + it('should work with AutoLoad and Armor security', async function () { + // Mock AutoLoad functionality + const mockSchemas = [{ + typeDefs: ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello World' + } + } + }]; + + const armorOptions = { + maxAliases: 0, + maxDepth: 10, + maxCost: 1000, + blockFieldSuggestion: true + }; + + const result = await GraphqlExpress(app, mockSchemas, { + serverWS: server, + armorOptions + }); + assert.strictEqual(result, true); + }); + }); +}); \ No newline at end of file diff --git a/test/run-armor-tests.js b/test/run-armor-tests.js new file mode 100755 index 0000000..7719a90 --- /dev/null +++ b/test/run-armor-tests.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +/** + * Test runner for GraphQL Armor security features + * Run with: node test/run-armor-tests.js + */ + +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +console.log('🧪 Running GraphQL Armor Security Tests...\n'); + +// Run the GraphQL Armor tests +const testProcess = spawn('npx', ['mocha', 'test/core/graphql-armor.test.js'], { + stdio: 'inherit', + cwd: join(__dirname, '..') +}); + +testProcess.on('close', (code) => { + console.log(`\n✅ GraphQL Armor tests completed with exit code: ${code}`); + process.exit(code); +}); + +testProcess.on('error', (error) => { + console.error('❌ Error running GraphQL Armor tests:', error); + process.exit(1); +}); \ No newline at end of file