diff --git a/api/src/unraid-api/graph/introspection-plugin.spec.ts b/api/src/unraid-api/graph/introspection-plugin.spec.ts index 3def3549d1..3739a9a1c3 100644 --- a/api/src/unraid-api/graph/introspection-plugin.spec.ts +++ b/api/src/unraid-api/graph/introspection-plugin.spec.ts @@ -127,15 +127,17 @@ describe('Dynamic Introspection Plugin', () => { expect(response.body).toBeNull(); }); - it('should allow queries with __type field (not full introspection)', async () => { + it('should block queries with __type field', async () => { const response = await runPlugin( 'query GetType { __type(name: "User") { name fields { name } } }', 'GetType', false ); - expect(response.http.status).toBe(200); - expect(response.body).toBeNull(); + expect(response.http.status).toBe(400); + expect(response.body?.singleResult?.errors?.[0]?.extensions?.code).toBe( + 'INTROSPECTION_DISABLED' + ); }); it('should allow queries with __typename field', async () => { diff --git a/api/src/unraid-api/graph/introspection-plugin.ts b/api/src/unraid-api/graph/introspection-plugin.ts index c178fc858d..d3c62bfb0a 100644 --- a/api/src/unraid-api/graph/introspection-plugin.ts +++ b/api/src/unraid-api/graph/introspection-plugin.ts @@ -1,41 +1,147 @@ import type { ApolloServerPlugin, GraphQLRequestListener } from '@apollo/server'; +import type { DocumentNode, OperationDefinitionNode, SelectionSetNode } from 'graphql'; +import { Kind, parse } from 'graphql'; + +const BLOCKED_INTROSPECTION_FIELDS = new Set(['__schema', '__type']); + +const hasBlockedIntrospectionField = ( + selectionSet: SelectionSetNode, + fragments: Map, + visitedFragments = new Set(), + atQueryRoot = true, + fragmentResults = new Map() +): boolean => + selectionSet.selections.some((selection) => { + if (selection.kind === Kind.FIELD) { + return ( + (atQueryRoot && BLOCKED_INTROSPECTION_FIELDS.has(selection.name.value)) || + (selection.selectionSet !== undefined && + hasBlockedIntrospectionField( + selection.selectionSet, + fragments, + visitedFragments, + false, + fragmentResults + )) + ); + } + + if (selection.kind === Kind.INLINE_FRAGMENT) { + return hasBlockedIntrospectionField( + selection.selectionSet, + fragments, + visitedFragments, + atQueryRoot, + fragmentResults + ); + } + + const fragmentKey = `${selection.name.value}:${atQueryRoot ? 'root' : 'nested'}`; + const cachedResult = fragmentResults.get(fragmentKey); + if (cachedResult !== undefined) { + return cachedResult; + } + + if (visitedFragments.has(selection.name.value)) { + return false; + } + + const fragmentSelectionSet = fragments.get(selection.name.value); + if (!fragmentSelectionSet) { + return false; + } + + visitedFragments.add(selection.name.value); + const blocked = hasBlockedIntrospectionField( + fragmentSelectionSet, + fragments, + visitedFragments, + atQueryRoot, + fragmentResults + ); + visitedFragments.delete(selection.name.value); + fragmentResults.set(fragmentKey, blocked); + return blocked; + }); + +const isBlockedIntrospectionOperation = ( + operation: OperationDefinitionNode, + document: DocumentNode +): boolean => { + const fragments = new Map( + document.definitions + .filter((definition) => definition.kind === Kind.FRAGMENT_DEFINITION) + .map((definition) => [definition.name.value, definition.selectionSet]) + ); + + return hasBlockedIntrospectionField(operation.selectionSet, fragments); +}; + +const getOperationFromDocument = (document: DocumentNode, operationName?: string) => { + const operations = document.definitions.filter( + (definition): definition is OperationDefinitionNode => + definition.kind === Kind.OPERATION_DEFINITION + ); + + return ( + operations.find((operation) => operation.name?.value === operationName) ?? + (operations.length === 1 ? operations[0] : undefined) + ); +}; + +const blockedIntrospectionBody = () => ({ + kind: 'single' as const, + singleResult: { + errors: [ + { + message: + 'GraphQL introspection is not allowed, but the current request is for introspection.', + extensions: { + code: 'INTROSPECTION_DISABLED', + }, + }, + ], + }, +}); export const createDynamicIntrospectionPlugin = ( isSandboxEnabled: () => boolean ): ApolloServerPlugin => ({ requestDidStart: async () => ({ + responseForOperation: async ({ document, operation, response }) => { + if (!isSandboxEnabled() && isBlockedIntrospectionOperation(operation, document)) { + return { + http: { + status: 400, + headers: response.http.headers, + }, + body: blockedIntrospectionBody(), + }; + } + + return null; + }, willSendResponse: async (requestContext) => { const { request, response } = requestContext; - // Detect introspection queries: - // 1. Standard operation name "IntrospectionQuery" - // 2. Queries containing __schema at root level (main introspection entry point) - // Note: __type and __typename are also used in regular queries, so we don't block them - const isIntrospectionRequest = - request.operationName === 'IntrospectionQuery' || - (request.query && - // Check for __schema which is the main introspection entry point - // Match patterns like: { __schema { ... } } or query { __schema { ... } } - /\{\s*__schema\s*[{(]/.test(request.query)); - - if (isIntrospectionRequest && !isSandboxEnabled()) { - response.body = { - kind: 'single', - singleResult: { - errors: [ - { - message: - 'GraphQL introspection is not allowed, but the current request is for introspection.', - extensions: { - code: 'INTROSPECTION_DISABLED', - }, - }, - ], - }, - }; - if (response.http) { - response.http.status = 400; + if (requestContext.operation || requestContext.document) { + return; + } + + if (!isSandboxEnabled() && request.query) { + try { + const document = parse(request.query); + const operation = getOperationFromDocument(document, request.operationName); + + if (operation && isBlockedIntrospectionOperation(operation, document)) { + response.body = blockedIntrospectionBody(); + if (response.http) { + response.http.status = 400; + } + } + } catch { + return; } } },