diff --git a/packages/wobe-graphql-apollo/src/index.test.ts b/packages/wobe-graphql-apollo/src/index.test.ts index 1e1bb3d..508ce2c 100644 --- a/packages/wobe-graphql-apollo/src/index.test.ts +++ b/packages/wobe-graphql-apollo/src/index.test.ts @@ -4,6 +4,181 @@ import getPort from 'get-port' import { WobeGraphqlApolloPlugin } from '.' describe('Wobe GraphQL Apollo plugin', () => { + it('should reject GET requests by default', async () => { + const port = await getPort() + + const wobe = new Wobe() + + await wobe.usePlugin( + await WobeGraphqlApolloPlugin({ + options: { + typeDefs: `#graphql + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello from Apollo!', + }, + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch( + `http://127.0.0.1:${port}/graphql?query=${encodeURIComponent(` + query { hello } + `)}`, + ) + + expect(res.status).toBeGreaterThanOrEqual(400) + + wobe.stop() + }) + + it('should allow GET requests when explicitly enabled', async () => { + const port = await getPort() + + const wobe = new Wobe() + + await wobe.usePlugin( + await WobeGraphqlApolloPlugin({ + allowGetRequests: true, + options: { + typeDefs: `#graphql + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello from Apollo!', + }, + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch( + `http://127.0.0.1:${port}/graphql?query=${encodeURIComponent(` + query { hello } + `)}`, + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + data: { hello: 'Hello from Apollo!' }, + }) + + wobe.stop() + }) + + it('should disable introspection and landing page in production by default', async () => { + const port = await getPort() + + const wobe = new Wobe() + + await wobe.usePlugin( + await WobeGraphqlApolloPlugin({ + isProduction: true, + allowGetRequests: true, + options: { + typeDefs: `#graphql + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello from Apollo!', + }, + }, + }, + }), + ) + + wobe.listen(port) + + const resLanding = await fetch(`http://127.0.0.1:${port}/graphql`) + expect(resLanding.status).toBeGreaterThanOrEqual(400) + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: ` + query IntrospectionQuery { + __schema { queryType { name } } + } + `, + }), + }) + + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.errors?.[0]?.message?.toLowerCase()).toContain( + 'introspection', + ) + + wobe.stop() + }) + + it('should allow introspection when explicitly enabled in production', async () => { + const port = await getPort() + + const wobe = new Wobe() + + await wobe.usePlugin( + await WobeGraphqlApolloPlugin({ + isProduction: true, + allowIntrospection: true, + options: { + typeDefs: `#graphql + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello from Apollo!', + }, + }, + }, + }), + ) + + 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 IntrospectionQuery { + __schema { queryType { name } } + } + `, + }), + }) + + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data?.__schema?.queryType?.name).toBeDefined() + + wobe.stop() + }) + it('should have custom wobe context in graphql context with record', async () => { const port = await getPort() diff --git a/packages/wobe-graphql-apollo/src/index.ts b/packages/wobe-graphql-apollo/src/index.ts index 7a98e61..21affc0 100644 --- a/packages/wobe-graphql-apollo/src/index.ts +++ b/packages/wobe-graphql-apollo/src/index.ts @@ -1,8 +1,5 @@ import { ApolloServer, type ApolloServerOptions } from '@apollo/server' -import { - ApolloServerPluginLandingPageLocalDefault, - ApolloServerPluginLandingPageProductionDefault, -} from '@apollo/server/plugin/landingPage/default' +import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default' import type { Wobe, MaybePromise, @@ -22,6 +19,9 @@ export interface GraphQLApolloPluginOptions { resolve: () => Promise, res: WobeResponse, ) => Promise + allowGetRequests?: boolean + isProduction?: boolean + allowIntrospection?: boolean } export const WobeGraphqlApolloPlugin = async ({ @@ -30,23 +30,30 @@ export const WobeGraphqlApolloPlugin = async ({ graphqlMiddleware, context: apolloContext, isProduction, + allowGetRequests = false, + allowIntrospection, }: { options: ApolloServerOptions graphqlEndpoint?: string context?: GraphQLApolloContext isProduction?: boolean } & GraphQLApolloPluginOptions): Promise => { + const introspection = + options.introspection ?? + (allowIntrospection === true ? true : isProduction ? false : true) + const server = new ApolloServer({ ...options, + introspection, plugins: [ ...(options?.plugins || []), - isProduction - ? ApolloServerPluginLandingPageProductionDefault({ - footer: false, - }) - : ApolloServerPluginLandingPageLocalDefault({ - footer: false, - }), + ...(isProduction + ? [] + : [ + ApolloServerPluginLandingPageLocalDefault({ + footer: false, + }), + ]), ], }) @@ -108,20 +115,22 @@ export const WobeGraphqlApolloPlugin = async ({ }, context.res) } - wobe.get(graphqlEndpoint, async (context) => { - const response = await getResponse(context) + if (allowGetRequests) { + wobe.get(graphqlEndpoint, async (context) => { + const response = await getResponse(context) - for (const [key, value] of context.res.headers.entries()) { - if (key === 'set-cookie') { - response.headers.append('set-cookie', value) - continue - } + for (const [key, value] of context.res.headers.entries()) { + if (key === 'set-cookie') { + response.headers.append('set-cookie', value) + continue + } - response.headers.set(key, value) - } + response.headers.set(key, value) + } - return response - }) + return response + }) + } wobe.post(graphqlEndpoint, async (context) => { const response = await getResponse(context) diff --git a/packages/wobe-graphql-yoga/src/index.test.ts b/packages/wobe-graphql-yoga/src/index.test.ts index 6f8ac7d..4064348 100644 --- a/packages/wobe-graphql-yoga/src/index.test.ts +++ b/packages/wobe-graphql-yoga/src/index.test.ts @@ -5,6 +5,165 @@ import getPort from 'get-port' import { WobeGraphqlYogaPlugin } from '.' describe('Wobe GraphQL Yoga plugin', () => { + it('should reject GET requests by default', async () => { + const port = await getPort() + const wobe = new Wobe() + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + typeDefs: ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello from Yoga!', + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch( + `http://127.0.0.1:${port}/graphql?query=${encodeURIComponent(` + query { hello } + `)}`, + ) + + expect(res.status).toBeGreaterThanOrEqual(400) + + wobe.stop() + }) + + it('should allow GET requests when explicitly enabled', async () => { + const port = await getPort() + const wobe = new Wobe() + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + allowGetRequests: true, + typeDefs: ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello from Yoga!', + }, + }, + }), + ) + + wobe.listen(port) + + const res = await fetch( + `http://127.0.0.1:${port}/graphql?query=${encodeURIComponent(` + query { hello } + `)}`, + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + data: { hello: 'Hello from Yoga!' }, + }) + + wobe.stop() + }) + + it('should disable introspection in production by default', async () => { + const port = await getPort() + const wobe = new Wobe() + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + isProduction: true, + typeDefs: ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello from Yoga!', + }, + }, + }), + ) + + 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 IntrospectionQuery { + __schema { queryType { name } } + } + `, + }), + }) + + const body = await res.json() + + expect(body.data).toBeUndefined() + expect(body.errors?.[0]?.message?.toLowerCase()).toContain( + 'introspection', + ) + + wobe.stop() + }) + + it('should allow introspection when explicitly enabled in production', async () => { + const port = await getPort() + const wobe = new Wobe() + + await wobe.usePlugin( + WobeGraphqlYogaPlugin({ + isProduction: true, + allowIntrospection: true, + typeDefs: ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'Hello from Yoga!', + }, + }, + }), + ) + + 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 IntrospectionQuery { + __schema { queryType { name } } + } + `, + }), + }) + + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data?.__schema?.queryType?.name).toBeDefined() + + 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) => { @@ -20,7 +179,7 @@ describe('Wobe GraphQL Yoga plugin', () => { `, resolvers: { Query: { - hello: (_, __, context: any) => { + hello: (_: unknown, __: unknown, context: any) => { context.res.setCookie('tata', 'tata') expect(context.test).toBeDefined() expect(context.res).toBeDefined() @@ -74,7 +233,7 @@ describe('Wobe GraphQL Yoga plugin', () => { `, resolvers: { Query: { - hello: (_, __, context: any) => { + hello: (_: unknown, __: unknown, context: any) => { context.res.setCookie('tata', 'tata') expect(context.test).toBeDefined() expect(context.res).toBeDefined() @@ -128,7 +287,7 @@ describe('Wobe GraphQL Yoga plugin', () => { `, resolvers: { Query: { - hello: (_, __, context: any) => { + hello: (_: unknown, __: unknown, context: any) => { context.res.setCookie('tata', 'tata') expect(context.test).toBeDefined() expect(context.res).toBeDefined() @@ -190,7 +349,7 @@ describe('Wobe GraphQL Yoga plugin', () => { `, resolvers: { Query: { - hello: (_, __, context) => { + hello: (_: unknown, __: unknown, context: any) => { expect(context.request.headers).toBeDefined() return 'Hello from Yoga!' }, @@ -239,7 +398,7 @@ describe('Wobe GraphQL Yoga plugin', () => { `, resolvers: { Query: { - hello: (_, __, context) => { + hello: (_: unknown, __: unknown, context: any) => { expect(context.request.headers).toBeDefined() return 'Hello from Yoga!' }, @@ -331,7 +490,7 @@ describe('Wobe GraphQL Yoga plugin', () => { `, resolvers: { Query: { - hello: (_, __, context) => { + hello: (_: unknown, __: unknown, context: any) => { expect(context.request.method).toBe('POST') expect(context.tata).toBe('test') diff --git a/packages/wobe-graphql-yoga/src/index.ts b/packages/wobe-graphql-yoga/src/index.ts index 4edc0c8..748af71 100644 --- a/packages/wobe-graphql-yoga/src/index.ts +++ b/packages/wobe-graphql-yoga/src/index.ts @@ -2,8 +2,10 @@ import { createSchema, createYoga, type GraphQLSchemaWithContext, + type Plugin, type YogaServerOptions, } from 'graphql-yoga' +import { NoSchemaIntrospectionCustomRule } from 'graphql' import type { Context, MaybePromise, @@ -21,10 +23,16 @@ export interface GraphqlYogaPluginOptions { resolve: () => Promise, res: WobeResponse, ) => Promise + allowGetRequests?: boolean + isProduction?: boolean + allowIntrospection?: boolean } export const WobeGraphqlYogaPlugin = ({ graphqlMiddleware, + allowGetRequests = false, + isProduction = false, + allowIntrospection, ...options }: { schema?: GraphQLSchemaWithContext> @@ -33,11 +41,27 @@ export const WobeGraphqlYogaPlugin = ({ resolvers?: Record } & Omit, 'context'> & GraphqlYogaPluginOptions): WobePlugin => { + const graphqlEndpoint = options?.graphqlEndpoint || '/graphql' + const plugins: Plugin[] = [...(options.plugins || [])] + + const shouldDisableIntrospection = + isProduction && allowIntrospection !== true + + if (shouldDisableIntrospection) { + plugins.push({ + onValidate({ addValidationRule }) { + addValidationRule(NoSchemaIntrospectionCustomRule) + }, + }) + } + const yoga = createYoga<{ request: Request response: WobeResponse }>({ ...options, + plugins, + graphiql: options.graphiql ?? !isProduction, schema: options.schema || createSchema({ @@ -71,10 +95,13 @@ export const WobeGraphqlYogaPlugin = ({ } return (wobe: Wobe) => { - wobe.get(options?.graphqlEndpoint || '/graphql', async (context) => - handleGraphQLRequest(context), - ) - wobe.post(options?.graphqlEndpoint || '/graphql', async (context) => + if (allowGetRequests) { + wobe.get(graphqlEndpoint, async (context) => + handleGraphQLRequest(context), + ) + } + + wobe.post(graphqlEndpoint, async (context) => handleGraphQLRequest(context), ) }