From c6f3b423b89b6044e312f2063c9dda3774966d6b Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:06:59 +0100 Subject: [PATCH 1/4] fix(graphql): filter schema metadata requests --- .../unraid-api/graph/introspection-plugin.ts | 149 ++++++++++++++---- 1 file changed, 121 insertions(+), 28 deletions(-) diff --git a/api/src/unraid-api/graph/introspection-plugin.ts b/api/src/unraid-api/graph/introspection-plugin.ts index c178fc858d..8ab4124227 100644 --- a/api/src/unraid-api/graph/introspection-plugin.ts +++ b/api/src/unraid-api/graph/introspection-plugin.ts @@ -1,41 +1,134 @@ 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 +): 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 + )) + ); + } + + if (selection.kind === Kind.INLINE_FRAGMENT) { + return hasBlockedIntrospectionField( + selection.selectionSet, + fragments, + visitedFragments, + atQueryRoot + ); + } + + 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 + ); + visitedFragments.delete(selection.name.value); + 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(); + response.http.status = 400; + } + } catch { + return; } } }, From e0b12a760f29db518b3ea342001c42de382ff295 Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:45:18 +0100 Subject: [PATCH 2/4] fix(graphql): allow type metadata queries --- api/src/unraid-api/graph/introspection-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/unraid-api/graph/introspection-plugin.ts b/api/src/unraid-api/graph/introspection-plugin.ts index 8ab4124227..74f90e6d8b 100644 --- a/api/src/unraid-api/graph/introspection-plugin.ts +++ b/api/src/unraid-api/graph/introspection-plugin.ts @@ -2,7 +2,7 @@ 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 BLOCKED_INTROSPECTION_FIELDS = new Set(['__schema']); const hasBlockedIntrospectionField = ( selectionSet: SelectionSetNode, From d1122f1fde992f251865ed69f1459bf02360b60e Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:34:22 +0100 Subject: [PATCH 3/4] fix(graphql): guard response status update OS-900 --- api/src/unraid-api/graph/introspection-plugin.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/src/unraid-api/graph/introspection-plugin.ts b/api/src/unraid-api/graph/introspection-plugin.ts index 74f90e6d8b..59a745cdc4 100644 --- a/api/src/unraid-api/graph/introspection-plugin.ts +++ b/api/src/unraid-api/graph/introspection-plugin.ts @@ -125,7 +125,9 @@ export const createDynamicIntrospectionPlugin = ( if (operation && isBlockedIntrospectionOperation(operation, document)) { response.body = blockedIntrospectionBody(); - response.http.status = 400; + if (response.http) { + response.http.status = 400; + } } } catch { return; From 048de6e3144916642e71f3d7205730a8fe59b8d5 Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:11:25 +0100 Subject: [PATCH 4/4] fix: enforce metadata query policy --- .../graph/introspection-plugin.spec.ts | 8 ++++--- .../unraid-api/graph/introspection-plugin.ts | 21 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) 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 59a745cdc4..d3c62bfb0a 100644 --- a/api/src/unraid-api/graph/introspection-plugin.ts +++ b/api/src/unraid-api/graph/introspection-plugin.ts @@ -2,13 +2,14 @@ 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']); +const BLOCKED_INTROSPECTION_FIELDS = new Set(['__schema', '__type']); const hasBlockedIntrospectionField = ( selectionSet: SelectionSetNode, fragments: Map, visitedFragments = new Set(), - atQueryRoot = true + atQueryRoot = true, + fragmentResults = new Map() ): boolean => selectionSet.selections.some((selection) => { if (selection.kind === Kind.FIELD) { @@ -19,7 +20,8 @@ const hasBlockedIntrospectionField = ( selection.selectionSet, fragments, visitedFragments, - false + false, + fragmentResults )) ); } @@ -29,10 +31,17 @@ const hasBlockedIntrospectionField = ( selection.selectionSet, fragments, visitedFragments, - atQueryRoot + 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; } @@ -47,9 +56,11 @@ const hasBlockedIntrospectionField = ( fragmentSelectionSet, fragments, visitedFragments, - atQueryRoot + atQueryRoot, + fragmentResults ); visitedFragments.delete(selection.name.value); + fragmentResults.set(fragmentKey, blocked); return blocked; });