diff --git a/packages/wobe-graphql-yoga/src/index.test.ts b/packages/wobe-graphql-yoga/src/index.test.ts index 4064348..537761a 100644 --- a/packages/wobe-graphql-yoga/src/index.test.ts +++ b/packages/wobe-graphql-yoga/src/index.test.ts @@ -4,6 +4,8 @@ import { createSchema } from 'graphql-yoga' import getPort from 'get-port' import { WobeGraphqlYogaPlugin } from '.' +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + describe('Wobe GraphQL Yoga plugin', () => { it('should reject GET requests by default', async () => { const port = await getPort() @@ -164,6 +166,341 @@ describe('Wobe GraphQL Yoga plugin', () => { wobe.stop() }) + it('should block queries that exceed max depth', async () => { + const port = await getPort() + const wobe = new Wobe() + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + maxDepth: 2, + typeDefs: ` + type Query { + hello: Hello + } + + type Hello { + nested: Nested + } + + type Nested { + value: String + } + `, + resolvers: { + Query: { + hello: () => ({ nested: { value: 'ok' } }), + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query TooDeep { + hello { nested { value } } + } + `, + }), + }) + + const body = await res.json() + expect(body.data).toBeUndefined() + expect(body.errors?.[0]?.message).toContain('max depth') + + wobe.stop() + }) + + it('should block queries that exceed max cost', async () => { + const port = await getPort() + const wobe = new Wobe() + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + maxCost: 2, + typeDefs: ` + type Query { + a: String + b: String + c: String + } + `, + resolvers: { + Query: { + a: () => 'a', + b: () => 'b', + c: () => 'c', + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query TooExpensive { + a + b + c + } + `, + }), + }) + + const body = await res.json() + expect(body.data).toBeUndefined() + expect(body.errors?.[0]?.message).toContain('too expensive') + + wobe.stop() + }) + + it('should reject multiple operations by default', async () => { + const port = await getPort() + const wobe = new Wobe() + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + typeDefs: ` + type Query { + a: String + b: String + } + `, + resolvers: { + Query: { + a: () => 'a', + b: () => 'b', + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query One { a } + query Two { b } + `, + }), + }) + + const body = await res.json() + expect(body.data).toBeUndefined() + expect(body.errors?.[0]?.message).toMatch( + /Multiple operations|Could not determine/i, + ) + + wobe.stop() + }) + + it('should allow only whitelisted operation names when provided', async () => { + const port = await getPort() + const wobe = new Wobe() + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + allowedOperationNames: ['AllowedOp'], + allowMultipleOperations: false, + typeDefs: ` + type Query { + a: String + } + `, + resolvers: { + Query: { + a: () => 'a', + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query NotAllowed { a } + `, + }), + }) + + const body = await res.json() + expect(body.data).toBeUndefined() + expect(body.errors?.[0]?.message).toContain('not allowed') + + wobe.stop() + }) + + it('should reject requests that exceed maxRequestSizeBytes', async () => { + const port = await getPort() + const wobe = new Wobe() + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + maxRequestSizeBytes: 10, + typeDefs: ` + type Query { + a: String + } + `, + resolvers: { + Query: { + a: () => 'a', + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query LargePayload { a } + `, + }), + }) + + expect(res.status).toBe(413) + wobe.stop() + }) + + it('should timeout when resolver exceeds timeoutMs', async () => { + const port = await getPort() + const wobe = new Wobe() + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + timeoutMs: 10, + typeDefs: ` + type Query { + slow: String + } + `, + resolvers: { + Query: { + slow: async () => { + await sleep(50) + return 'slow' + }, + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query Slow { slow } + `, + }), + }) + + expect(res.status).toBe(504) + wobe.stop() + }) + + it('should allow rateLimiter to block requests', async () => { + const port = await getPort() + const wobe = new Wobe() + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + rateLimiter: async () => + new Response('Too Many Requests', { status: 429 }), + typeDefs: ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello', + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query Test { hello } + `, + }), + }) + + expect(res.status).toBe(429) + wobe.stop() + }) + + it('should call onRequestResolved hook with timing info', async () => { + const port = await getPort() + const wobe = new Wobe() + let called = false + let status: number | undefined + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + onRequestResolved: (input) => { + called = true + status = input.status + }, + typeDefs: ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello', + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query Hook { hello } + `, + }), + }) + + expect(res.status).toBe(200) + expect(called).toBe(true) + expect(status).toBe(200) + + wobe.stop() + }) + it('should set the wobe response in the graphql context with record', async () => { const port = await getPort() const wobe = new Wobe<{ customType: string }>().beforeHandler((ctx) => { diff --git a/packages/wobe-graphql-yoga/src/index.ts b/packages/wobe-graphql-yoga/src/index.ts index 748af71..e499e37 100644 --- a/packages/wobe-graphql-yoga/src/index.ts +++ b/packages/wobe-graphql-yoga/src/index.ts @@ -5,7 +5,11 @@ import { type Plugin, type YogaServerOptions, } from 'graphql-yoga' -import { NoSchemaIntrospectionCustomRule } from 'graphql' +import { + GraphQLError, + NoSchemaIntrospectionCustomRule, + type ValidationRule, +} from 'graphql' import type { Context, MaybePromise, @@ -26,6 +30,19 @@ export interface GraphqlYogaPluginOptions { allowGetRequests?: boolean isProduction?: boolean allowIntrospection?: boolean + maxDepth?: number + maxCost?: number + maxRequestSizeBytes?: number + timeoutMs?: number + allowedOperationNames?: string[] + allowMultipleOperations?: boolean + onRequestResolved?: (input: { + operationName?: string | null + success: boolean + status: number + durationMs: number + }) => void + rateLimiter?: (context: Context) => MaybePromise } export const WobeGraphqlYogaPlugin = ({ @@ -33,6 +50,14 @@ export const WobeGraphqlYogaPlugin = ({ allowGetRequests = false, isProduction = false, allowIntrospection, + maxDepth, + maxCost, + maxRequestSizeBytes, + timeoutMs, + allowedOperationNames, + allowMultipleOperations = false, + onRequestResolved, + rateLimiter, ...options }: { schema?: GraphQLSchemaWithContext> @@ -47,6 +72,37 @@ export const WobeGraphqlYogaPlugin = ({ const shouldDisableIntrospection = isProduction && allowIntrospection !== true + const validationPlugins: Plugin[] = [] + + if (maxDepth) { + validationPlugins.push({ + onValidate({ addValidationRule }) { + addValidationRule(createDepthLimitRule(maxDepth)) + }, + }) + } + + if (maxCost) { + validationPlugins.push({ + onValidate({ addValidationRule }) { + addValidationRule(createCostLimitRule(maxCost)) + }, + }) + } + + if (!allowMultipleOperations || (allowedOperationNames?.length || 0) > 0) { + validationPlugins.push({ + onValidate({ addValidationRule }) { + addValidationRule( + createOperationConstraintsRule({ + allowedOperationNames, + allowMultipleOperations, + }), + ) + }, + }) + } + if (shouldDisableIntrospection) { plugins.push({ onValidate({ addValidationRule }) { @@ -60,8 +116,9 @@ export const WobeGraphqlYogaPlugin = ({ response: WobeResponse }>({ ...options, - plugins, + plugins: [...plugins, ...validationPlugins], graphiql: options.graphiql ?? !isProduction, + maskedErrors: options.maskedErrors ?? isProduction, schema: options.schema || createSchema({ @@ -71,6 +128,20 @@ export const WobeGraphqlYogaPlugin = ({ }) const handleGraphQLRequest = async (context: Context) => { + if (maxRequestSizeBytes) { + const contentLength = context.request.headers.get('content-length') + if (contentLength && Number(contentLength) > maxRequestSizeBytes) { + return new Response('Request Entity Too Large', { status: 413 }) + } + } + + if (rateLimiter) { + const rateLimiterResult = await rateLimiter(context) + if (rateLimiterResult instanceof Response) return rateLimiterResult + } + + const start = performance.now() + const getResponse = async () => { if (!graphqlMiddleware) return yoga.handle(context.request, context) @@ -80,7 +151,7 @@ export const WobeGraphqlYogaPlugin = ({ ) } - const response = await getResponse() + const response = await resolveWithTimeout(getResponse, timeoutMs) for (const [key, value] of context.res.headers.entries()) { if (key === 'set-cookie') { @@ -91,6 +162,13 @@ export const WobeGraphqlYogaPlugin = ({ response.headers.set(key, value) } + onRequestResolved?.({ + operationName: context.params?.operationName, + success: response.ok, + status: response.status, + durationMs: performance.now() - start, + }) + return response } @@ -106,3 +184,181 @@ export const WobeGraphqlYogaPlugin = ({ ) } } + +const createDepthLimitRule = (maxDepth: number): ValidationRule => { + return (context) => { + const checkDepth = (depth: number) => { + if (depth > maxDepth) { + context.reportError( + new GraphQLError( + `Query is too deep: ${depth} > max depth ${maxDepth}`, + ), + ) + } + } + + const traverse = ( + selectionSet: any, + depth: number, + visitedFragments: Set, + ) => { + checkDepth(depth) + + for (const selection of selectionSet.selections || []) { + if (selection.selectionSet) { + traverse( + selection.selectionSet, + depth + 1, + visitedFragments, + ) + continue + } + + if (selection.kind === 'FragmentSpread') { + const name = selection.name.value + if (visitedFragments.has(name)) continue + visitedFragments.add(name) + const fragment = context.getFragment(name) + if (fragment) { + traverse( + fragment.selectionSet, + depth + 1, + visitedFragments, + ) + } + } + } + } + + return { + OperationDefinition(node) { + traverse(node.selectionSet, 1, new Set()) + }, + } + } +} + +const createCostLimitRule = (maxCost: number): ValidationRule => { + return (context) => { + let totalCost = 0 + + const countSelections = ( + selectionSet: any, + visitedFragments: Set, + ): number => { + let cost = 0 + for (const selection of selectionSet.selections || []) { + cost += 1 + if (selection.selectionSet) { + cost += countSelections( + selection.selectionSet, + visitedFragments, + ) + continue + } + if (selection.kind === 'FragmentSpread') { + const name = selection.name.value + if (visitedFragments.has(name)) continue + visitedFragments.add(name) + const fragment = context.getFragment(name) + if (fragment) { + cost += countSelections( + fragment.selectionSet, + visitedFragments, + ) + } + } + } + return cost + } + + return { + OperationDefinition(node) { + const visitedFragments = new Set() + totalCost += countSelections( + node.selectionSet, + visitedFragments, + ) + }, + Document: { + leave() { + if (totalCost > maxCost) { + context.reportError( + new GraphQLError( + `Query is too expensive: ${totalCost} > max cost ${maxCost}`, + ), + ) + } + }, + }, + } + } +} + +const createOperationConstraintsRule = ({ + allowedOperationNames, + allowMultipleOperations, +}: { + allowedOperationNames?: string[] + allowMultipleOperations: boolean +}): ValidationRule => { + return (context) => { + const seenOperations: string[] = [] + + return { + OperationDefinition(node) { + const name = node.name?.value + if (name) { + seenOperations.push(name) + if ( + allowedOperationNames && + allowedOperationNames.length > 0 && + !allowedOperationNames.includes(name) + ) { + context.reportError( + new GraphQLError( + `Operation "${name}" is not allowed in this endpoint.`, + ), + ) + } + } + }, + Document: { + leave() { + if (!allowMultipleOperations && seenOperations.length > 1) { + context.reportError( + new GraphQLError( + 'Multiple operations are not allowed in this endpoint.', + ), + ) + } + }, + }, + } + } +} + +const resolveWithTimeout = async ( + resolve: () => Promise, + timeoutMs: number | undefined, +) => { + if (!timeoutMs || timeoutMs <= 0) return resolve() + + let timeoutId: ReturnType | undefined + + const timeoutPromise = new Promise((resolveTimeout) => { + timeoutId = setTimeout( + () => + resolveTimeout( + new Response('Request Timeout', { status: 504 }), + ), + timeoutMs, + ) + }) + + const response = await Promise.race([resolve(), timeoutPromise]) + + if (timeoutId) clearTimeout(timeoutId) + + return response +}