From 58e121fdb1bcf993f5d7fd10fae6c7b86bcc4cd2 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:18:06 +0100 Subject: [PATCH] fix(wabe): graphiql access and introspection mechanism --- packages/wabe/src/security.test.ts | 7 +- packages/wabe/src/server/index.test.ts | 216 +++++++++++++++++++++++-- packages/wabe/src/server/index.ts | 10 +- packages/wabe/src/utils/testHelper.ts | 4 + 4 files changed, 217 insertions(+), 20 deletions(-) diff --git a/packages/wabe/src/security.test.ts b/packages/wabe/src/security.test.ts index 26dabd4b..8f3fbb85 100644 --- a/packages/wabe/src/security.test.ts +++ b/packages/wabe/src/security.test.ts @@ -497,8 +497,11 @@ describe('Security tests', () => { await closeTests(wabe) }) - it('should block GraphQL introspection queries for anonymous and authenticated users for isProduction server', async () => { - const setup = await setupTests([], { isProduction: true }) + it('should block GraphQL introspection queries for anonymous and authenticated users when disableIntrospection is true', async () => { + const setup = await setupTests([], { + isProduction: true, + disableIntrospection: true, + }) const wabe = setup.wabe const port = setup.port const client = getAnonymousClient(port) diff --git a/packages/wabe/src/server/index.test.ts b/packages/wabe/src/server/index.test.ts index 5b228c22..bbae1f9c 100644 --- a/packages/wabe/src/server/index.test.ts +++ b/packages/wabe/src/server/index.test.ts @@ -87,6 +87,120 @@ describe('Server', () => { expect(wabe.start()).rejects.toThrow('Authentication session requires jwt secret') }) + it('should return GraphiQL for GET /graphql in development', async () => { + const databaseId = uuid() + const port = await getPort() + const wabe = new Wabe({ + isProduction: false, + rootKey: 'eIUbb9abFa8PJGRfRwgiGSCU0fGnLErph2QYjigDRjLsbyNA3fZJ8Npd0FJNzxAc', + database: { + // @ts-expect-error + adapter: await getDatabaseAdapter(databaseId), + }, + port, + security: { + disableCSRFProtection: true, + }, + schema: { + classes: [ + { + name: 'Collection1', + fields: { name: { type: 'String' } }, + }, + ], + }, + }) + + await wabe.start() + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'GET', + headers: { Accept: 'text/html' }, + }) + + const text = await res.text() + expect(res.status).toBe(200) + expect(text).toContain('GraphiQL') + + await wabe.close() + }) + + it('should return GraphiQL in production by default', async () => { + const databaseId = uuid() + const port = await getPort() + const wabe = new Wabe({ + isProduction: true, + rootKey: 'eIUbb9abFa8PJGRfRwgiGSCU0fGnLErph2QYjigDRjLsbyNA3fZJ8Npd0FJNzxAc', + database: { + // @ts-expect-error + adapter: await getDatabaseAdapter(databaseId), + }, + port, + security: { + disableCSRFProtection: true, + }, + schema: { + classes: [ + { + name: 'Collection1', + fields: { name: { type: 'String' } }, + }, + ], + }, + }) + + await wabe.start() + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'GET', + headers: { Accept: 'text/html' }, + }) + + const text = await res.text() + expect(res.status).toBe(200) + expect(text).toContain('GraphiQL') + + await wabe.close() + }) + + it('should not return GraphiQL when disableGraphQLDashboard is true', async () => { + const databaseId = uuid() + const port = await getPort() + const wabe = new Wabe({ + isProduction: false, + rootKey: 'eIUbb9abFa8PJGRfRwgiGSCU0fGnLErph2QYjigDRjLsbyNA3fZJ8Npd0FJNzxAc', + database: { + // @ts-expect-error + adapter: await getDatabaseAdapter(databaseId), + }, + port, + security: { + disableCSRFProtection: true, + disableGraphQLDashboard: true, + }, + schema: { + classes: [ + { + name: 'Collection1', + fields: { name: { type: 'String' } }, + }, + ], + }, + }) + + await wabe.start() + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'GET', + headers: { Accept: 'text/html' }, + }) + + const text = await res.text() + expect(text).not.toContain('GraphiQL') + + await wabe.close() + }) + it('should pass graphql options to yoga plugin', async () => { const databaseId = uuid() @@ -115,7 +229,6 @@ describe('Server', () => { }, security: { disableCSRFProtection: true, - allowIntrospectionInProduction: true, maxGraphqlDepth: 60, }, schema: { @@ -519,13 +632,12 @@ describe('Server', () => { await wabe.close() }) - it('should block introspection in production without root key', async () => { + it('should allow introspection in development by default', async () => { const databaseId = uuid() const port = await getPort() - const rootKey = 'eIUbb9abFa8PJGRfRwgiGSCU0fGnLErph2QYjigDRjLsbyNA3fZJ8Npd0FJNzxAc' const wabe = new Wabe({ - isProduction: true, - rootKey, + isProduction: false, + rootKey: 'eIUbb9abFa8PJGRfRwgiGSCU0fGnLErph2QYjigDRjLsbyNA3fZJ8Npd0FJNzxAc', database: { // @ts-expect-error adapter: await getDatabaseAdapter(databaseId), @@ -548,9 +660,49 @@ describe('Server', () => { const res = await fetch(`http://127.0.0.1:${port}/graphql`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: '{ __schema { types { name } } }', + }), + }) + + const json = (await res.json()) as { data?: { __schema?: { types?: { name: string }[] } } } + expect(json.data?.__schema?.types).toBeDefined() + expect(json.data?.__schema?.types?.length).toBeGreaterThan(0) + + await wabe.close() + }) + + it('should block introspection when disableIntrospection is true', async () => { + const databaseId = uuid() + const port = await getPort() + const wabe = new Wabe({ + isProduction: false, + rootKey: 'eIUbb9abFa8PJGRfRwgiGSCU0fGnLErph2QYjigDRjLsbyNA3fZJ8Npd0FJNzxAc', + database: { + // @ts-expect-error + adapter: await getDatabaseAdapter(databaseId), + }, + port, + security: { + disableCSRFProtection: true, + disableIntrospection: true, }, + schema: { + classes: [ + { + name: 'Collection1', + fields: { name: { type: 'String' } }, + }, + ], + }, + }) + + await wabe.start() + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: '{ __schema { types { name } } }', }), @@ -563,13 +715,12 @@ describe('Server', () => { await wabe.close() }) - it('should allow introspection in production with valid root key', async () => { + it('should block introspection in production when disableIntrospection is true', async () => { const databaseId = uuid() const port = await getPort() - const rootKey = 'eIUbb9abFa8PJGRfRwgiGSCU0fGnLErph2QYjigDRjLsbyNA3fZJ8Npd0FJNzxAc' const wabe = new Wabe({ isProduction: true, - rootKey, + rootKey: 'eIUbb9abFa8PJGRfRwgiGSCU0fGnLErph2QYjigDRjLsbyNA3fZJ8Npd0FJNzxAc', database: { // @ts-expect-error adapter: await getDatabaseAdapter(databaseId), @@ -577,6 +728,7 @@ describe('Server', () => { port, security: { disableCSRFProtection: true, + disableIntrospection: true, }, schema: { classes: [ @@ -592,10 +744,48 @@ describe('Server', () => { const res = await fetch(`http://127.0.0.1:${port}/graphql`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Wabe-Root-Key': rootKey, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: '{ __schema { types { name } } }', + }), + }) + + const json = (await res.json()) as { errors?: { message: string }[] } + expect(json.errors).toBeDefined() + expect(json.errors?.[0]?.message).toContain('introspection') + + await wabe.close() + }) + + it('should allow introspection in production by default', async () => { + const databaseId = uuid() + const port = await getPort() + const wabe = new Wabe({ + isProduction: true, + rootKey: 'eIUbb9abFa8PJGRfRwgiGSCU0fGnLErph2QYjigDRjLsbyNA3fZJ8Npd0FJNzxAc', + database: { + // @ts-expect-error + adapter: await getDatabaseAdapter(databaseId), + }, + port, + security: { + disableCSRFProtection: true, + }, + schema: { + classes: [ + { + name: 'Collection1', + fields: { name: { type: 'String' } }, + }, + ], }, + }) + + await wabe.start() + + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: '{ __schema { types { name } } }', }), diff --git a/packages/wabe/src/server/index.ts b/packages/wabe/src/server/index.ts index f89ecb52..e6dc0f2e 100644 --- a/packages/wabe/src/server/index.ts +++ b/packages/wabe/src/server/index.ts @@ -18,7 +18,6 @@ import { FileController } from '../file/FileController' import { defaultSessionHandler } from './defaultSessionHandler' import type { CronConfig } from '../cron' import type { FileConfig } from '../file' -import { isValidRootKey } from '../utils' import { WobeGraphqlYogaPlugin } from 'wobe-graphql-yoga' type SecurityConfig = { @@ -26,7 +25,8 @@ type SecurityConfig = { rateLimit?: RateLimitOptions hideSensitiveErrorMessage?: boolean disableCSRFProtection?: boolean - allowIntrospectionInProduction?: boolean + disableGraphQLDashboard?: boolean + disableIntrospection?: boolean maxGraphqlDepth?: number } @@ -287,8 +287,9 @@ export class Wabe { await this.server.usePlugin( WobeGraphqlYogaPlugin({ schema: this.config.graphqlSchema, + allowGetRequests: !(this.config.security?.disableGraphQLDashboard ?? false), maskedErrors: this.config.security?.hideSensitiveErrorMessage || this.config.isProduction, - allowIntrospection: !!this.config.security?.allowIntrospectionInProduction, + allowIntrospection: !(this.config.security?.disableIntrospection ?? false), maxDepth, allowMultipleOperations: true, graphqlEndpoint: '/graphql', @@ -297,8 +298,7 @@ export class Wabe { const introspectionDisabled = new WeakSet() return { onRequestParse: ({ request }: { request: Request }) => { - if (this.config.security?.allowIntrospectionInProduction) return - if (isValidRootKey(request.headers, this.config.rootKey)) return + if (!(this.config.security?.disableIntrospection ?? false)) return introspectionDisabled.add(request) }, onValidate: ({ diff --git a/packages/wabe/src/utils/testHelper.ts b/packages/wabe/src/utils/testHelper.ts index f6a5dd71..24517f12 100644 --- a/packages/wabe/src/utils/testHelper.ts +++ b/packages/wabe/src/utils/testHelper.ts @@ -21,6 +21,7 @@ export const setupTests = async ( options: { isProduction?: boolean disableCSRFProtection?: boolean + disableIntrospection?: boolean rootKey?: string rateLimit?: RateLimitOptions } = {}, @@ -39,6 +40,9 @@ export const setupTests = async ( security: { // To make test easier keep default value to true disableCSRFProtection: options.disableCSRFProtection ?? true, + ...(options.disableIntrospection !== undefined && { + disableIntrospection: options.disableIntrospection, + }), ...(options.rateLimit && { rateLimit: options.rateLimit }), }, authentication: {