diff --git a/.changeset/five-suits-drum.md b/.changeset/five-suits-drum.md new file mode 100644 index 000000000..d93199b75 --- /dev/null +++ b/.changeset/five-suits-drum.md @@ -0,0 +1,10 @@ +--- +"@apollo/query-planner": patch +"@apollo/query-graphs": patch +"@apollo/federation-internals": patch +"@apollo/gateway": patch +--- + +Corrects a set of denial-of-service (DOS) vulnerabilities that made it possible for an attacker to render gateway inoperable with certain simple query patterns due to uncontrolled resource consumption. All prior-released versions and configurations are vulnerable. + +See the associated GitHub Advisories [GHSA-q2f9-x4p4-7xmh](https://github.com/apollographql/federation/security/advisories/GHSA-q2f9-x4p4-7xmh) and [GHSA-p2q6-pwh5-m6jr](https://github.com/apollographql/federation/security/advisories/GHSA-p2q6-pwh5-m6jr) for more information. diff --git a/.cspell/cspell-dict.txt b/.cspell/cspell-dict.txt index f0d5db4f1..05c50a5ff 100644 --- a/.cspell/cspell-dict.txt +++ b/.cspell/cspell-dict.txt @@ -178,6 +178,7 @@ quer Queryf reacheable reasonse +rebaseable recusive redeclaration refered diff --git a/gateway-js/src/index.ts b/gateway-js/src/index.ts index 786614356..c8141733a 100644 --- a/gateway-js/src/index.ts +++ b/gateway-js/src/index.ts @@ -155,6 +155,16 @@ export class ApolloGateway implements GatewayInterface { private experimental_didResolveQueryPlan?: Experimental_DidResolveQueryPlanCallback; // Used to communicate supergraph updates private experimental_didUpdateSupergraph?: Experimental_DidUpdateSupergraphCallback; + // Used to disable the recursive selections limit in query planner. Setting + // this to `true` is not advised if gateway is being used to serve queries + // outside your control, as doing so will leave query planner susceptible to + // denial-of-service attacks. + private recursiveSelectionsLimitDisabled: boolean; + // Used to disable the non-local selections limit in query planner. Setting + // this to `true` is not advised if gateway is being used to serve queries + // outside your control, as doing so will leave query planner susceptible to + // denial-of-service attacks. + private nonLocalSelectionsLimitDisabled: boolean; // how often service defs should be loaded/updated private pollIntervalInMs?: number; // Functions to call during gateway cleanup (when stop() is called) @@ -180,6 +190,22 @@ export class ApolloGateway implements GatewayInterface { this.experimental_didUpdateSupergraph = config?.experimental_didUpdateSupergraph; + // Check environment variables to see whether the query planner's recursive + // selections limit should be disabled. Setting this variable to `true` is + // not advised if gateway is being used to serve queries outside your + // control, as doing so will leave query planner susceptible to + // denial-of-service attacks. + this.recursiveSelectionsLimitDisabled = + process.env.APOLLO_DISABLE_SECURITY_RECURSIVE_SELECTIONS_CHECK === 'true'; + + // Check environment variables to see whether the query planner's non-local + // selections limit should be disabled. Setting this variable to `true` is + // not advised if gateway is being used to serve queries outside your + // control, as doing so will leave query planner susceptible to + // denial-of-service attacks. + this.nonLocalSelectionsLimitDisabled = + process.env.APOLLO_DISABLE_SECURITY_NON_LOCAL_SELECTIONS_CHECK === 'true'; + if (isManagedConfig(this.config)) { this.pollIntervalInMs = this.config.fallbackPollIntervalInMs ?? this.config.pollIntervalInMs; @@ -806,7 +832,12 @@ export class ApolloGateway implements GatewayInterface { { operationName: request.operationName }, ); // TODO(#631): Can we be sure the query planner has been initialized here? - return this.queryPlanner!.buildQueryPlan(operation); + return this.queryPlanner!.buildQueryPlan(operation, { + recursiveSelectionsLimitDisabled: + this.recursiveSelectionsLimitDisabled, + nonLocalSelectionsLimitDisabled: + this.nonLocalSelectionsLimitDisabled, + }); } catch (err) { recordExceptions(span, [err], this.config.telemetry); span.setStatus({ code: SpanStatusCode.ERROR }); diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index a88bd1865..71660e93a 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -3650,7 +3650,7 @@ class InlineFragmentSelection extends FragmentSelection { } } -class FragmentSpreadSelection extends FragmentSelection { +export class FragmentSpreadSelection extends FragmentSelection { private computedKey: string | undefined; constructor( diff --git a/query-graphs-js/src/index.ts b/query-graphs-js/src/index.ts index e4ee41a36..d1ffc621c 100644 --- a/query-graphs-js/src/index.ts +++ b/query-graphs-js/src/index.ts @@ -7,3 +7,4 @@ export * from './pathContext'; export * from './conditionsCaching'; export * from './conditionsValidation'; export * from './mermaid'; +export * from './nonLocalSelectionsEstimation'; diff --git a/query-graphs-js/src/nonLocalSelectionsEstimation.ts b/query-graphs-js/src/nonLocalSelectionsEstimation.ts new file mode 100644 index 000000000..a7f7e2d20 --- /dev/null +++ b/query-graphs-js/src/nonLocalSelectionsEstimation.ts @@ -0,0 +1,1171 @@ +import { + assert, + assertUnreachable, + federationMetadata, + isCompositeType, + isObjectType, + OperationElement, + possibleRuntimeTypes, + Schema, + Selection, + SelectionSet, + typenameFieldName, +} from '@apollo/federation-internals'; +import { + checkOverrideCondition, + FEDERATED_GRAPH_ROOT_SOURCE, + OverrideCondition, + QueryGraph, + Vertex, +} from './querygraph'; +import { SimultaneousPathsWithLazyIndirectPaths } from './graphPath'; + +/** + * Indirect option metadata for the complete digraph for type T. See + * {@link NonLocalSelectionsMetadata} for more information about how we group + * indirect options into complete digraphs. + */ +interface IndirectOptionsMetadata { + /** + * The members of the complete digraph for type T. + */ + sameTypeOptions: Set; + /** + * Any interface object types I that are reachable for at least one vertex in + * the complete digraph for type T. + */ + interfaceObjectOptions: Set; +} + +interface FieldTail { + /** + * The tail vertex of this field edge. + */ + tail: Vertex; + /** + * The override condition of the field, if it has one. + */ + overrideCondition?: OverrideCondition; +} + +/** + * Downcasts from normal non-interface-object types, which have regular + * downcasts to their object type vertices. + */ +interface NonInterfaceObjectDowncasts { + kind: 'NonInterfaceObject'; + downcasts: Map; +}; + +/** + * "Fake" downcasts from interface object types to object types that don't + * really exist in the subgraph schema (and hence have no vertex). + */ +interface InterfaceObjectDowncasts { + kind: 'InterfaceObject'; + downcasts: Set; +} + +/** + * Downcasts edges to the possible runtime object types of a composite type. + */ +type ObjectTypeDowncasts = + | NonInterfaceObjectDowncasts + | InterfaceObjectDowncasts; + +export class NonLocalSelectionsMetadata { + static readonly MAX_NON_LOCAL_SELECTIONS = 100_000; + /** + * When a (resolvable) @key exists on a type T in a subgraph, a key resolution + * edge is created from every subgraph's type T to that subgraph's type T. + * This similarly holds for root type resolution edges. This means that the + * vertices of type T with such a @key (or are operation root types) form a + * complete digraph in the query graph. These indirect options effectively + * occur as a group in our estimation process, so we track group members here + * per type name, and precompute units of work relative to these groups. + * + * Interface object types I in a subgraph will only sometimes create a key + * resolution edge between an implementing type T in a subgraph and that + * subgraph's type I. This means the vertices of the complete digraph for I + * are indirect options for such vertices of type T. We track any such types I + * that are reachable for at least one vertex in the complete digraph for type + * T here as well. + */ + private readonly typesToIndirectOptions = + new Map(); + /** + * For vertices of a type T that aren't in their complete digraph (due to not + * having a @key), these remaining vertices will have the complete digraph of + * T (and any interface object complete digraphs) as indirect options, but + * these remaining vertices may separately have more indirect options that are + * not options for the complete digraph of T, specifically if the complete + * digraph for T has no key resolution edges to an interface object I, but + * this remaining vertex does. We keep track of such interface object types + * for those remaining vertices here. + */ + private readonly remainingVerticesToInterfaceObjectOptions = + new Map>; + /** + * A map of field names to the endpoints of field query graph edges with that + * field name. Note we additionally store the progressive overrides label, if + * the edge is conditioned on it. + */ + private readonly fieldsToEndpoints = + new Map>(); + /** + * A map of type condition names to endpoints of downcast query graph edges + * with that type condition name, including fake downcasts for interface + * objects, and a non-existent edge that represents a type condition name + * equal to the parent type. + */ + private readonly inlineFragmentsToEndpoints = + new Map>(); + /** + * A map of composite type vertices to their downcast edges that lead + * specifically to an object type (i.e., the possible runtime types of the + * vertex's type). + */ + private readonly verticesToObjectTypeDowncasts = + new Map(); + /** + * A map of field names to parent vertices whose corresponding type and schema + * can be rebased on by the field. + */ + private readonly fieldsToRebaseableParentVertices = + new Map>; + /** + * A map of type condition names to parent vertices whose corresponding type + * and schema can be rebased on by an inline fragment with that type + * condition. + */ + private readonly inlineFragmentsToRebaseableParentVertices = + new Map>; + + constructor(graph: QueryGraph) { + this.precomputeNonLocalSelectionMetadata(graph); + } + + /** + * Precompute relevant metadata about the query graph for speeding up the + * estimation of the count of non-local selections. Note that none of the + * algorithms used in this function should take any longer algorithmically as + * the rest of query graph creation (and similarly for query graph memory). + */ + private precomputeNonLocalSelectionMetadata(graph: QueryGraph) { + this.precomputeNextVertexMetadata(graph); + this.precomputeRebasingMetadata(graph); + } + + private precomputeNextVertexMetadata(graph: QueryGraph) { + const verticesToInterfaceObjectOptions = new Map>(); + for (const edge of graph.allEdges()) { + switch (edge.transition.kind) { + case 'FieldCollection': { + // We skip selections where the tail is a non-composite type, as we'll + // never need to estimate the next vertices for such selections. + if (!isCompositeType(edge.tail.type)) { + continue; + } + const fieldName = edge.transition.definition.name; + let endpointsEntry = this.fieldsToEndpoints.get(fieldName); + if (!endpointsEntry) { + endpointsEntry = new Map(); + this.fieldsToEndpoints.set(fieldName, endpointsEntry); + } + endpointsEntry.set(edge.head, { + tail: edge.tail, + overrideCondition: edge.overrideCondition + }); + break; + } + case 'DownCast': { + if (isObjectType(edge.transition.castedType)) { + let downcastsEntry = + this.verticesToObjectTypeDowncasts.get(edge.head); + if (!downcastsEntry) { + downcastsEntry = { + kind: 'NonInterfaceObject', + downcasts: new Map(), + }; + this.verticesToObjectTypeDowncasts.set(edge.head, downcastsEntry); + } + assert( + downcastsEntry.kind === 'NonInterfaceObject', + () => 'Unexpectedly found interface object with regular object downcasts', + ); + downcastsEntry.downcasts.set( + edge.transition.castedType.name, + edge.tail + ); + } + const typeConditionName = edge.transition.castedType.name; + let endpointsEntry = this.inlineFragmentsToEndpoints + .get(typeConditionName); + if (!endpointsEntry) { + endpointsEntry = new Map(); + this.inlineFragmentsToEndpoints.set( + typeConditionName, + endpointsEntry + ); + } + endpointsEntry.set(edge.head, edge.tail); + break; + } + case 'InterfaceObjectFakeDownCast': { + // Note that fake downcasts for interface objects are only created to + // "fake" object types. + let downcastsEntry = + this.verticesToObjectTypeDowncasts.get(edge.head); + if (!downcastsEntry) { + downcastsEntry = { + kind: 'InterfaceObject', + downcasts: new Set(), + }; + this.verticesToObjectTypeDowncasts.set(edge.head, downcastsEntry); + } + assert( + downcastsEntry.kind === 'InterfaceObject', + () => 'Unexpectedly found abstract type with interface object downcasts', + ); + downcastsEntry.downcasts.add(edge.transition.castedTypeName); + const typeConditionName = edge.transition.castedTypeName; + let endpointsEntry = this.inlineFragmentsToEndpoints + .get(typeConditionName); + if (!endpointsEntry) { + endpointsEntry = new Map(); + this.inlineFragmentsToEndpoints.set( + typeConditionName, + endpointsEntry + ); + } + endpointsEntry.set(edge.head, edge.tail); + break; + } + case 'KeyResolution': + case 'RootTypeResolution': { + const headTypeName = edge.head.type.name; + const tailTypeName = edge.tail.type.name; + if (headTypeName === tailTypeName) { + // In this case, we have a non-interface-object key resolution edge + // or a root type resolution edge. The tail must be part of the + // complete digraph for the tail's type, so we record the member. + let indirectOptionsEntry = this.typesToIndirectOptions + .get(tailTypeName); + if (!indirectOptionsEntry) { + indirectOptionsEntry = { + sameTypeOptions: new Set(), + interfaceObjectOptions: new Set(), + }; + this.typesToIndirectOptions.set( + tailTypeName, + indirectOptionsEntry, + ); + } + indirectOptionsEntry.sameTypeOptions.add(edge.tail); + } else { + // Otherwise, this must be an interface object key resolution edge. + // We don't know the members of the complete digraph for the head's + // type yet, so we can't set the metadata yet, and instead store the + // head to interface object type mapping in a temporary map. + let interfaceObjectOptionsEntry = verticesToInterfaceObjectOptions + .get(edge.head); + if (!interfaceObjectOptionsEntry) { + interfaceObjectOptionsEntry = new Set(); + verticesToInterfaceObjectOptions.set( + edge.head, + interfaceObjectOptionsEntry, + ); + } + interfaceObjectOptionsEntry.add(tailTypeName); + } + break; + } + case 'SubgraphEnteringTransition': + break; + default: + assertUnreachable(edge.transition); + } + } + + // Now that we've finished computing members of the complete digraphs, we + // can properly track interface object options. + for (const [vertex, options] of verticesToInterfaceObjectOptions) { + const optionsMetadata = this.typesToIndirectOptions.get(vertex.type.name); + if (optionsMetadata) { + if (optionsMetadata.sameTypeOptions.has(vertex)) { + for (const option of options) { + optionsMetadata.interfaceObjectOptions.add(option); + } + continue; + } + } + this.remainingVerticesToInterfaceObjectOptions.set(vertex, options); + } + + // The interface object options for the complete digraphs are now correct, + // but we need to subtract these from any interface object options for + // remaining vertices. + for (const [vertex, options] of this.remainingVerticesToInterfaceObjectOptions) { + const indirectOptionsMetadata = this.typesToIndirectOptions + .get(vertex.type.name); + if (!indirectOptionsMetadata) { + continue; + } + for (const option of options) { + if (indirectOptionsMetadata.interfaceObjectOptions.has(option)) { + options.delete(option); + } + } + // If this subtraction left any interface object option sets empty, we + // remove them. + if (options.size === 0) { + this.remainingVerticesToInterfaceObjectOptions.delete(vertex); + } + } + + // For all composite type vertices, we pretend that there's a self-downcast + // edge for that type, as this simplifies next vertex calculation. + for (const vertex of graph.allVertices()) { + if ( + vertex.source === FEDERATED_GRAPH_ROOT_SOURCE + || !isCompositeType(vertex.type) + ) { + continue; + } + const typeConditionName = vertex.type.name; + let endpointsEntry = this.inlineFragmentsToEndpoints + .get(typeConditionName); + if (!endpointsEntry) { + endpointsEntry = new Map(); + this.inlineFragmentsToEndpoints.set( + typeConditionName, + endpointsEntry + ); + } + endpointsEntry.set(vertex, vertex); + if (!isObjectType(vertex.type)) { + continue; + } + const metadata = federationMetadata(vertex.type.schema()); + assert( + metadata, + () => 'Subgraph schema unexpectedly did not have subgraph metadata', + ); + if (metadata.isInterfaceObjectType(vertex.type)) { + continue; + } + let downcastsEntry = this.verticesToObjectTypeDowncasts.get(vertex); + if (!downcastsEntry) { + downcastsEntry = { + kind: 'NonInterfaceObject', + downcasts: new Map(), + }; + this.verticesToObjectTypeDowncasts.set(vertex, downcastsEntry); + } + assert( + downcastsEntry.kind === 'NonInterfaceObject', + () => 'Unexpectedly found object type with interface object downcasts in supergraph', + ); + downcastsEntry.downcasts.set(typeConditionName, vertex); + } + } + + private precomputeRebasingMetadata(graph: QueryGraph) { + // We need composite-types-to-vertices map by source for the federated query + // graph, so we compute that here. + const compositeTypesToVerticesBySource = + new Map>>(); + for (const vertex of graph.allVertices()) { + if ( + vertex.source === FEDERATED_GRAPH_ROOT_SOURCE + || !isCompositeType(vertex.type) + ) { + continue; + } + let typesToVerticesEntry = compositeTypesToVerticesBySource + .get(vertex.source); + if (!typesToVerticesEntry) { + typesToVerticesEntry = new Map(); + compositeTypesToVerticesBySource.set( + vertex.source, + typesToVerticesEntry + ); + } + let verticesEntry = typesToVerticesEntry.get(vertex.type.name); + if (!verticesEntry) { + verticesEntry = new Set(); + typesToVerticesEntry.set(vertex.type.name, verticesEntry); + } + verticesEntry.add(vertex); + } + + // For each subgraph schema, we iterate through its composite types, so that + // we can collect metadata relevant to rebasing. + for (const [source, schema] of graph.sources) { + if (source === FEDERATED_GRAPH_ROOT_SOURCE) { + continue; + } + // We pass through each composite type, recording whether the field can be + // rebased on it along with interface implements/union membership + // relationships. + const fieldsToRebaseableTypes = new Map>(); + const objectTypesToImplementingCompositeTypes = + new Map>(); + const metadata = federationMetadata(schema); + assert( + metadata, + () => 'Subgraph schema unexpectedly did not have subgraph metadata', + ); + const fromContextDirectiveName = metadata.fromContextDirective().name; + for (const type of schema.types()) { + switch (type.kind) { + case 'ObjectType': { + // Record fields that don't contain @fromContext as being rebaseable + // (also including __typename). + for (const field of type.fields()) { + if (field.arguments().some((arg) => + arg.hasAppliedDirective(fromContextDirectiveName) + )) { + continue; + } + let rebaseableTypesEntry = + fieldsToRebaseableTypes.get(field.name); + if (!rebaseableTypesEntry) { + rebaseableTypesEntry = new Set(); + fieldsToRebaseableTypes.set(field.name, rebaseableTypesEntry); + } + rebaseableTypesEntry.add(type.name); + } + let rebaseableTypesEntry = + fieldsToRebaseableTypes.get(typenameFieldName); + if (!rebaseableTypesEntry) { + rebaseableTypesEntry = new Set(); + fieldsToRebaseableTypes.set( + typenameFieldName, + rebaseableTypesEntry + ); + } + rebaseableTypesEntry.add(type.name); + // Record the object type as implementing itself. + let implementingObjectTypesEntry = + objectTypesToImplementingCompositeTypes.get(type.name); + if (!implementingObjectTypesEntry) { + implementingObjectTypesEntry = new Set(); + objectTypesToImplementingCompositeTypes.set( + type.name, + implementingObjectTypesEntry, + ); + } + implementingObjectTypesEntry.add(type.name); + // For each implements, record the interface type as an implementing + // type. + for (const interfaceImplementation of type.interfaceImplementations()) { + implementingObjectTypesEntry.add( + interfaceImplementation.interface.name + ); + } + break; + } + case 'InterfaceType': { + // Record fields that don't contain @fromContext as being rebaseable + // (also including __typename). + for (const field of type.fields()) { + if (field.arguments().some((arg) => + arg.hasAppliedDirective(fromContextDirectiveName) + )) { + continue; + } + let rebaseableTypesEntry = + fieldsToRebaseableTypes.get(field.name); + if (!rebaseableTypesEntry) { + rebaseableTypesEntry = new Set(); + fieldsToRebaseableTypes.set(field.name, rebaseableTypesEntry); + } + rebaseableTypesEntry.add(type.name); + } + let rebaseableTypesEntry = + fieldsToRebaseableTypes.get(typenameFieldName); + if (!rebaseableTypesEntry) { + rebaseableTypesEntry = new Set(); + fieldsToRebaseableTypes.set( + typenameFieldName, + rebaseableTypesEntry + ); + } + rebaseableTypesEntry.add(type.name); + break; + } + case 'UnionType': { + // Just record the __typename field as being rebaseable. + let rebaseableTypesEntry = + fieldsToRebaseableTypes.get(typenameFieldName); + if (!rebaseableTypesEntry) { + rebaseableTypesEntry = new Set(); + fieldsToRebaseableTypes.set( + typenameFieldName, + rebaseableTypesEntry + ); + } + rebaseableTypesEntry.add(type.name); + // For each member, record the union type as an implementing type. + for (const member of type.members()) { + let implementingObjectTypesEntry = + objectTypesToImplementingCompositeTypes.get(member.type.name); + if (!implementingObjectTypesEntry) { + implementingObjectTypesEntry = new Set(); + objectTypesToImplementingCompositeTypes.set( + member.type.name, + implementingObjectTypesEntry, + ); + } + implementingObjectTypesEntry.add(type.name); + } + break; + } + case 'ScalarType': + case 'EnumType': + case 'InputObjectType': + break; + default: + assertUnreachable(type); + } + } + + // With the interface implements/union membership relationships, we can + // compute which pairs of types have at least one possible runtime type in + // their intersection, and are thus rebaseable. + const inlineFragmentsToRebaseableTypes = new Map>(); + for (const implementingTypes of objectTypesToImplementingCompositeTypes.values()) { + for (const typeName of implementingTypes) { + let rebaseableTypesEntry = + inlineFragmentsToRebaseableTypes.get(typeName); + if (!rebaseableTypesEntry) { + rebaseableTypesEntry = new Set(); + fieldsToRebaseableTypes.set(typeName, rebaseableTypesEntry); + } + for (const implementingType of implementingTypes) { + rebaseableTypesEntry.add(implementingType); + } + } + } + + // Finally, we can compute the vertices for the rebaseable types, as we'll + // be working with those instead of types when checking whether an + // operation element can be rebased. + const compositeTypesToVertices = + compositeTypesToVerticesBySource.get(source) + ?? new Map>(); + for (const [fieldName, types] of fieldsToRebaseableTypes) { + let rebaseableParentVerticesEntry = + this.fieldsToRebaseableParentVertices.get(fieldName); + if (!rebaseableParentVerticesEntry) { + rebaseableParentVerticesEntry = new Set(); + this.fieldsToRebaseableParentVertices.set( + fieldName, + rebaseableParentVerticesEntry, + ); + } + for (const type of types) { + const vertices = compositeTypesToVertices.get(type); + if (vertices) { + for (const vertex of vertices) { + rebaseableParentVerticesEntry.add(vertex); + } + } + } + } + for (const [typeConditionName, types] of inlineFragmentsToRebaseableTypes) { + let rebaseableParentVerticesEntry = + this.inlineFragmentsToRebaseableParentVertices.get(typeConditionName); + if (!rebaseableParentVerticesEntry) { + rebaseableParentVerticesEntry = new Set(); + this.inlineFragmentsToRebaseableParentVertices.set( + typeConditionName, + rebaseableParentVerticesEntry, + ); + } + for (const type of types) { + const vertices = compositeTypesToVertices.get(type); + if (vertices) { + for (const vertex of vertices) { + rebaseableParentVerticesEntry.add(vertex); + } + } + } + } + } + } + + /** + * This calls {@link checkNonLocalSelectionsLimitExceeded} for each of the + * selections in the open branches stack; see that function's doc comment for + * more information. + */ + checkNonLocalSelectionsLimitExceededAtRoot( + stack: [Selection, SimultaneousPathsWithLazyIndirectPaths[]][], + state: NonLocalSelectionsState, + supergraphSchema: Schema, + inconsistentAbstractTypesRuntimes: Set, + overrideConditions: Map, + ): boolean { + for (const [selection, simultaneousPaths] of stack) { + const tailVertices = new Set(); + for (const simultaneousPath of simultaneousPaths) { + for (const path of simultaneousPath.paths) { + tailVertices.add(path.tail); + } + } + const tailVerticesInfo = + this.estimateVerticesWithIndirectOptions(tailVertices); + + // Note that top-level selections aren't avoided via fully-local selection + // set optimization, so we always add them here. + if (this.updateCount(1, tailVertices.size, state)) { + return true; + } + + if (selection.selectionSet) { + const selectionHasDefer = selection.hasDefer(); + const nextVertices = this.estimateNextVerticesForSelection( + selection.element, + tailVerticesInfo, + state, + supergraphSchema, + overrideConditions, + ); + if (this.checkNonLocalSelectionsLimitExceeded( + selection.selectionSet, + nextVertices, + selectionHasDefer, + state, + supergraphSchema, + inconsistentAbstractTypesRuntimes, + overrideConditions, + )) { + return true; + } + } + } + return false; + } + + /** + * When recursing through a selection set to generate options from each + * element, there is an optimization that allows us to avoid option + * exploration if a selection set is "fully local" from all the possible + * vertices we could be at in the query graph. + * + * This function computes an approximate upper bound on the number of + * selections in a selection set that wouldn't be avoided by such an + * optimization (i.e. the "non-local" selections), and adds it to the given + * count in the state. Note that the count for a given selection set is scaled + * by an approximate upper bound on the possible number of tail vertices for + * paths ending at that selection set. If at any point, the count exceeds + * `MAX_NON_LOCAL_SELECTIONS`, then this function will return `true`. + * + * This function's code is closely related to + * `selectionIsFullyLocalFromAllVertices()` (which implements the + * aforementioned optimization). However, when it comes to traversing the + * query graph, we generally ignore the effects of edge pre-conditions and + * other optimizations to option generation for efficiency's sake, giving us + * an upper bound since the extra vertices may fail some of the checks (e.g. + * the selection set may not rebase on them). + * + * Note that this function takes in whether the parent selection of the + * selection set has @defer, as that affects whether the optimization is + * disabled for that selection set. + */ + private checkNonLocalSelectionsLimitExceeded( + selectionSet: SelectionSet, + parentVertices: NextVerticesInfo, + parentSelectionHasDefer: boolean, + state: NonLocalSelectionsState, + supergraphSchema: Schema, + inconsistentAbstractTypesRuntimes: Set, + overrideConditions: Map, + ): boolean { + // Compute whether the selection set is non-local, and if so, add its + // selections to the count. Any of the following causes the selection set to + // be non-local. + // 1. The selection set's vertices having at least one reachable + // cross-subgraph edge. + // 2. The parent selection having @defer. + // 3. Any selection in the selection set having @defer. + // 4. Any selection in the selection set being an inline fragment whose type + // condition has inconsistent runtime types across subgraphs. + // 5. Any selection in the selection set being unable to be rebased on the + // selection set's vertices. + // 6. Any nested selection sets causing the count to be incremented. + let selectionSetIsNonLocal = + parentVertices.nextVerticesHaveReachableCrossSubgraphEdges + || parentSelectionHasDefer; + for (const selection of selectionSet.selections()) { + const element = selection.element; + const selectionHasDefer = element.hasDefer(); + const selectionHasInconsistentRuntimeTypes = + element.kind === 'FragmentElement' + && element.typeCondition + && inconsistentAbstractTypesRuntimes.has(element.typeCondition.name); + + const oldCount = state.count; + if (selection.selectionSet) { + const nextVertices = this.estimateNextVerticesForSelection( + element, + parentVertices, + state, + supergraphSchema, + overrideConditions, + ); + if (this.checkNonLocalSelectionsLimitExceeded( + selection.selectionSet, + nextVertices, + selectionHasDefer, + state, + supergraphSchema, + inconsistentAbstractTypesRuntimes, + overrideConditions, + )) { + return true; + } + } + + selectionSetIsNonLocal ||= selectionHasDefer + || selectionHasInconsistentRuntimeTypes + || (oldCount != state.count); + } + // Determine whether the selection can be rebased on all selection set + // vertices (without indirect options). This is more expensive, so we do + // this last/only if needed. Note that we were originally calling a slightly + // modified `canAddTo()` to mimic the logic in + // `selectionIsFullyLocalFromAllVertices()`, but this ended up being rather + // expensive in practice, so an optimized version using precomputation is + // used below. + if (!selectionSetIsNonLocal && parentVertices.nextVertices.size > 0) { + outer: for (const selection of selectionSet.selections()) { + switch (selection.kind) { + case 'FieldSelection': { + // Note that while the precomputed metadata accounts for + // @fromContext, it doesn't account for checking whether the + // operation field's parent type either matches the subgraph + // schema's parent type name or is an interface type. Given current + // composition rules, this should always be the case when rebasing + // supergraph/API schema queries onto one of its subgraph schema, so + // we avoid the check here for performance. + const rebaseableParentVertices = + this.fieldsToRebaseableParentVertices + .get(selection.element.definition.name); + if (!rebaseableParentVertices) { + selectionSetIsNonLocal = true; + break outer; + } + for (const vertex of parentVertices.nextVertices) { + if (!rebaseableParentVertices.has(vertex)) { + selectionSetIsNonLocal = true; + break outer; + } + } + break; + } + case 'FragmentSelection': { + const typeConditionName = selection.element.typeCondition?.name; + if (!typeConditionName) { + // Inline fragments without type conditions can always be rebased. + continue; + } + const rebaseableParentVertices = + this.inlineFragmentsToRebaseableParentVertices + .get(typeConditionName); + if (!rebaseableParentVertices) { + selectionSetIsNonLocal = true; + break outer; + } + for (const vertex of parentVertices.nextVertices) { + if (!rebaseableParentVertices.has(vertex)) { + selectionSetIsNonLocal = true; + break outer; + } + } + break; + } + default: + assertUnreachable(selection); + } + } + } + return selectionSetIsNonLocal && this.updateCount( + selectionSet.selections().length, + parentVertices.nextVertices.size, + state, + ); + } + + /** + * Updates the non-local selection set count in the state, returning true if + * this causes the count to exceed `MAX_NON_LOCAL_SELECTIONS`. + */ + private updateCount( + numSelections: number, + numParentVertices: number, + state: NonLocalSelectionsState, + ): boolean { + const additional_count = numSelections * numParentVertices; + const new_count = state.count + additional_count; + if (new_count > NonLocalSelectionsMetadata.MAX_NON_LOCAL_SELECTIONS) { + return true; + } + state.count = new_count; + return false; + } + + /** + * In `checkNonLocalSelectionsLimitExceeded()`, when handling a given + * selection for a set of parent vertices (including indirect options), this + * function can be used to estimate an upper bound on the next vertices after + * taking the selection (also with indirect options). + */ + private estimateNextVerticesForSelection( + element: OperationElement, + parentVertices: NextVerticesInfo, + state: NonLocalSelectionsState, + supergraphSchema: Schema, + overrideConditions: Map, + ): NextVerticesInfo { + const selectionKey = element.kind === 'Field' + ? element.definition.name + : element.typeCondition?.name; + if (!selectionKey) { + // For empty type condition, the vertices don't change. + return parentVertices; + } + let cache = state.nextVerticesCache.get(selectionKey); + if (!cache) { + cache = { + typesToNextVertices: new Map(), + remainingVerticesToNextVertices: new Map(), + }; + state.nextVerticesCache.set(selectionKey, cache); + } + const nextVerticesInfo: NextVerticesInfo = { + nextVertices: new Set(), + nextVerticesHaveReachableCrossSubgraphEdges: false, + nextVerticesWithIndirectOptions: { + types: new Set(), + remainingVertices: new Set(), + } + } + for (const typeName of parentVertices.nextVerticesWithIndirectOptions.types) { + let cacheEntry = cache.typesToNextVertices.get(typeName); + if (!cacheEntry) { + const indirectOptions = this.typesToIndirectOptions.get(typeName); + assert( + indirectOptions, + () => 'Unexpectedly missing vertex information for cached type', + ); + cacheEntry = this.estimateNextVerticesForSelectionWithoutCaching( + element, + indirectOptions.sameTypeOptions, + supergraphSchema, + overrideConditions, + ); + cache.typesToNextVertices.set(typeName, cacheEntry); + } + this.mergeNextVerticesInfo(cacheEntry, nextVerticesInfo); + } + for (const vertex of parentVertices.nextVerticesWithIndirectOptions.remainingVertices) { + let cacheEntry = cache.remainingVerticesToNextVertices.get(vertex); + if (!cacheEntry) { + cacheEntry = this.estimateNextVerticesForSelectionWithoutCaching( + element, + [vertex], + supergraphSchema, + overrideConditions, + ); + cache.remainingVerticesToNextVertices.set(vertex, cacheEntry); + } + this.mergeNextVerticesInfo(cacheEntry, nextVerticesInfo); + } + return nextVerticesInfo; + } + + private mergeNextVerticesInfo( + source: NextVerticesInfo, + target: NextVerticesInfo + ) { + for (const vertex of source.nextVertices) { + target.nextVertices.add(vertex); + } + target.nextVerticesHaveReachableCrossSubgraphEdges ||= + source.nextVerticesHaveReachableCrossSubgraphEdges; + this.mergeVerticesWithIndirectOptionsInfo( + source.nextVerticesWithIndirectOptions, + target.nextVerticesWithIndirectOptions, + ); + } + + private mergeVerticesWithIndirectOptionsInfo( + source: VerticesWithIndirectOptionsInfo, + target: VerticesWithIndirectOptionsInfo, + ) { + for (const type of source.types) { + target.types.add(type); + } + for (const vertex of source.remainingVertices) { + target.remainingVertices.add(vertex); + } + } + + /** + * Estimate an upper bound on the next vertices after taking the selection on + * the given parent vertices. Because we're just trying for an upper bound, we + * assume we can always take type-preserving non-collecting transitions, we + * ignore any conditions on the selection edge, and we always type-explode. + * (We do account for override conditions, which are relatively + * straightforward.) + * + * Since we're iterating through next vertices in the process, for efficiency + * sake we also compute whether there are any reachable cross-subgraph edges + * from the next vertices (without indirect options). This method assumes that + * inline fragments have type conditions. + */ + private estimateNextVerticesForSelectionWithoutCaching( + element: OperationElement, + parentVertices: Iterable, + supergraphSchema: Schema, + overrideConditions: Map, + ): NextVerticesInfo { + const nextVertices = new Set(); + switch (element.kind) { + case 'Field': { + const fieldEndpoints = this.fieldsToEndpoints + .get(element.definition.name); + const processHeadVertex = (vertex: Vertex) => { + const fieldTail = fieldEndpoints?.get(vertex); + if (!fieldTail) { + return; + } + if (fieldTail.overrideCondition) { + if (checkOverrideCondition( + fieldTail.overrideCondition, + overrideConditions, + )) { + nextVertices.add(fieldTail.tail); + } + } else { + nextVertices.add(fieldTail.tail); + } + }; + for (const vertex of parentVertices) { + // As an upper bound for efficiency sake, we consider both + // non-type-exploded and type-exploded options. + processHeadVertex(vertex); + const downcasts = this.verticesToObjectTypeDowncasts.get(vertex); + if (!downcasts) { + continue; + } + // Interface object fake downcasts only go back to the self vertex, so + // we ignore them. + if (downcasts.kind === 'NonInterfaceObject') { + for (const vertex of downcasts.downcasts.values()) { + processHeadVertex(vertex); + } + } + } + break; + } + case 'FragmentElement': { + const typeConditionName = element.typeCondition?.name; + assert( + typeConditionName, + () => 'Inline fragment unexpectedly had no type condition', + ); + const inlineFragmentEndpoints = this.inlineFragmentsToEndpoints + .get(typeConditionName); + // If we end up computing runtime types for the type condition, only do + // it once. + let runtimeTypes: Set | null = null; + for (const vertex of parentVertices) { + // We check whether there's already a (maybe fake) downcast edge for + // the type condition (note that we've inserted fake downcasts for + // same-type type conditions into the metadata). + const nextVertex = inlineFragmentEndpoints?.get(vertex); + if (nextVertex) { + nextVertices.add(nextVertex); + continue; + } + + // If not, then we need to type explode across the possible runtime + // types (in the supergraph schema) for the type condition. + const downcasts = this.verticesToObjectTypeDowncasts.get(vertex); + if (!downcasts) { + continue; + } + if (!runtimeTypes) { + const typeInSupergraph = supergraphSchema.type(typeConditionName); + assert( + typeInSupergraph && isCompositeType(typeInSupergraph), + () => 'Type unexpectedly missing or non-composite in supergraph schema', + ); + runtimeTypes = new Set(); + for (const type of possibleRuntimeTypes(typeInSupergraph)) { + runtimeTypes.add(type.name); + } + } + + switch (downcasts.kind) { + case 'NonInterfaceObject': { + for (const [typeName, vertex] of downcasts.downcasts) { + if (runtimeTypes.has(typeName)) { + nextVertices.add(vertex); + } + } + break; + } + case 'InterfaceObject': { + for (const typeName of downcasts.downcasts) { + if (runtimeTypes.has(typeName)) { + // Note that interface object fake downcasts are self edges, + // so we're done once we find one. + nextVertices.add(vertex); + break; + } + } + break; + } + default: + assertUnreachable(downcasts); + } + } + break; + } + default: + assertUnreachable(element); + } + + return this.estimateVerticesWithIndirectOptions(nextVertices); + } + + /** + * Estimate the indirect options for the given next vertices, and add them to + * the given vertices. As an upper bound for efficiency's sake, we assume we + * can take any indirect option (i.e. ignore any edge conditions). + */ + private estimateVerticesWithIndirectOptions( + nextVertices: Set, + ): NextVerticesInfo { + const nextVerticesInfo: NextVerticesInfo = { + nextVertices, + nextVerticesHaveReachableCrossSubgraphEdges: false, + nextVerticesWithIndirectOptions: { + types: new Set(), + remainingVertices: new Set(), + } + }; + for (const nextVertex of nextVertices) { + nextVerticesInfo.nextVerticesHaveReachableCrossSubgraphEdges ||= + nextVertex.hasReachableCrossSubgraphEdges; + + const typeName = nextVertex.type.name + const optionsMetadata = this.typesToIndirectOptions.get(typeName); + if (optionsMetadata) { + // If there's an entry in `typesToIndirectOptions` for the type, then + // the complete digraph for T is non-empty, so we add its type. If it's + // our first time seeing this type, we also add any of the complete + // digraph's interface object options. + if ( + !nextVerticesInfo.nextVerticesWithIndirectOptions.types.has(typeName) + ) { + nextVerticesInfo.nextVerticesWithIndirectOptions.types.add(typeName); + for (const option of optionsMetadata.interfaceObjectOptions) { + nextVerticesInfo.nextVerticesWithIndirectOptions.types.add(option); + } + } + // If the vertex is a member of the complete digraph, then we don't need + // to separately add the remaining vertex. + if (optionsMetadata.sameTypeOptions.has(nextVertex)) { + continue; + } + } + // We need to add the remaining vertex, and if its our first time seeing + // it, we also add any of its interface object options. + if ( + !nextVerticesInfo.nextVerticesWithIndirectOptions.remainingVertices + .has(nextVertex) + ) { + nextVerticesInfo.nextVerticesWithIndirectOptions.remainingVertices + .add(nextVertex); + const options = this.remainingVerticesToInterfaceObjectOptions + .get(nextVertex); + if (options) { + for (const option of options) { + nextVerticesInfo.nextVerticesWithIndirectOptions.types.add(option); + } + } + } + } + + return nextVerticesInfo; + } +} + +interface NextVerticesCache { + /** + * This is the merged next vertex info for selections on the set of vertices + * in the complete digraph for the given type T. Note that this does not merge + * in the next vertex info for any interface object options reachable from + * vertices in that complete digraph for T. + */ + typesToNextVertices: Map, + /** + * This is the next vertex info for selections on the given vertex. Note that + * this does not merge in the next vertex info for any interface object + * options reachable from that vertex. + */ + remainingVerticesToNextVertices: Map, +} + +interface NextVerticesInfo { + /** + * The next vertices after taking the selection. + */ + nextVertices: Set, + /** + * Whether any cross-subgraph edges are reachable from any next vertices. + */ + nextVerticesHaveReachableCrossSubgraphEdges: boolean, + /** + * These are the next vertices along with indirect options, represented + * succinctly by the types of any complete digraphs along with remaining + * vertices. + */ + nextVerticesWithIndirectOptions: VerticesWithIndirectOptionsInfo, +} + +interface VerticesWithIndirectOptionsInfo { + /** + * For indirect options that are representable as complete digraphs for a type + * T, these are those types. + */ + types: Set, + /** + * For any vertices of type T that aren't in their complete digraphs for type + * T, these are those vertices. + */ + remainingVertices: Set, +} + +export class NonLocalSelectionsState { + /** + * An estimation of the number of non-local selections for the whole operation + * (where the count for a given selection set is scaled by the number of tail + * vertices at that selection set). Note this does not count selections from + * recursive query planning. + */ + count = 0; + /** + * Whenever we take a selection on a set of vertices with indirect options, we + * cache the resulting vertices here. The map key for field selections is the + * field's name and for inline fragment selections is the type condition's + * name. + */ + readonly nextVerticesCache = new Map; +} diff --git a/query-graphs-js/src/querygraph.ts b/query-graphs-js/src/querygraph.ts index ac2333357..a44ffc656 100644 --- a/query-graphs-js/src/querygraph.ts +++ b/query-graphs-js/src/querygraph.ts @@ -41,6 +41,7 @@ import { import { inspect } from 'util'; import { DownCast, FieldCollection, subgraphEnteringTransition, SubgraphEnteringTransition, Transition, KeyResolution, RootTypeResolution, InterfaceObjectFakeDownCast } from './transition'; import { preComputeNonTrivialFollowupEdges } from './nonTrivialEdgePrecomputing'; +import { NonLocalSelectionsMetadata } from './nonLocalSelectionsEstimation'; // We use our federation reserved subgraph name to avoid risk of conflict with other subgraph names (wouldn't be a huge // deal, but safer that way). Using something short like `_` is also on purpose: it makes it stand out in debug messages @@ -124,6 +125,14 @@ export interface OverrideCondition { condition: boolean; } +export function checkOverrideCondition( + overrideCondition: OverrideCondition, + conditionsToCheck: Map +): boolean { + const { label, condition } = overrideCondition; + return conditionsToCheck.has(label) ? conditionsToCheck.get(label) === condition : false; +} + export type ContextCondition = { context: string; subgraphName: string; @@ -271,8 +280,10 @@ export class Edge { satisfiesOverrideConditions(conditionsToCheck: Map) { if (!this.overrideCondition) return true; - const { label, condition } = this.overrideCondition; - return conditionsToCheck.has(label) ? conditionsToCheck.get(label) === condition : false; + return checkOverrideCondition( + this.overrideCondition, + conditionsToCheck, + ); } toString(): string { @@ -329,6 +340,12 @@ export class QueryGraph { * composition (100+ subgraphs) from ~4 "minutes" to ~10 seconds. */ readonly nonTrivialFollowupEdges: (edge: Edge) => readonly Edge[]; + /** + * To speed up the estimation of counting non-local selections, we + * precompute specific metadata. We only computed this for federated query + * graphs used during query planning. + */ + readonly nonLocalSelectionsMetadata: NonLocalSelectionsMetadata | null; /** * Creates a new query graph. @@ -365,8 +382,13 @@ export class QueryGraph { readonly subgraphToArgIndices: Map>, readonly schema: Schema, + + isFederatedAndForQueryPlanning?: boolean, ) { this.nonTrivialFollowupEdges = preComputeNonTrivialFollowupEdges(this); + this.nonLocalSelectionsMetadata = isFederatedAndForQueryPlanning + ? new NonLocalSelectionsMetadata(this) + : null; } /** The number of vertices in this query graph. */ @@ -448,6 +470,18 @@ export class QueryGraph { return this._outEdges[vertex.index][edgeIndex]; } + allVertices(): Iterable { + return this.vertices; + } + + *allEdges(): Iterable { + for (const vertexOutEdges of this._outEdges) { + for (const outEdge of vertexOutEdges) { + yield outEdge; + } + } + } + /** * Whether the provided vertex is a terminal one (has no out edges). * @@ -671,7 +705,7 @@ export function buildFederatedQueryGraph(supergraph: Supergraph, forQueryPlannin for (const subgraph of subgraphs) { graphs.push(buildGraphInternal(subgraph.name, subgraph.schema, forQueryPlanning, supergraph.schema)); } - return federateSubgraphs(supergraph.schema, graphs); + return federateSubgraphs(supergraph.schema, graphs, forQueryPlanning); } function federatedProperties(subgraphs: QueryGraph[]) : [number, Set, Schema[]] { @@ -695,7 +729,11 @@ function resolvableKeyApplications( return applications.filter((application) => application.arguments().resolvable ?? true); } -function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGraph { +function federateSubgraphs( + supergraph: Schema, + subgraphs: QueryGraph[], + forQueryPlanning: boolean, +): QueryGraph { const [verticesCount, rootKinds, schemas] = federatedProperties(subgraphs); const builder = new GraphBuilder(supergraph, verticesCount); rootKinds.forEach(k => builder.createRootVertex( @@ -1061,7 +1099,7 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr } } - return builder.build(FEDERATED_GRAPH_ROOT_SOURCE); + return builder.build(FEDERATED_GRAPH_ROOT_SOURCE, forQueryPlanning); } function addProvidesEdges(schema: Schema, builder: GraphBuilder, from: Vertex, provided: SelectionSet, provideId: number) { @@ -1306,7 +1344,7 @@ class GraphBuilder { return v; } - build(name: string): QueryGraph { + build(name: string, isFederatedAndForQueryPlanning?: boolean): QueryGraph { return new QueryGraph( name, this.vertices, @@ -1317,6 +1355,7 @@ class GraphBuilder { this.subgraphToArgs, this.subgraphToArgIndices, this.schema, + isFederatedAndForQueryPlanning, ); } diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 9c51f5b63..0ba2b77d7 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -98,6 +98,8 @@ import { createInitialOptions, buildFederatedQueryGraph, FEDERATED_GRAPH_ROOT_SOURCE, + NonLocalSelectionsState, + NonLocalSelectionsMetadata, } from "@apollo/query-graphs"; import { stripIgnoredCharacters, print, OperationTypeNode, SelectionSetNode, Kind } from "graphql"; import { DeferredNode, FetchDataKeyRenamer, FetchDataRewrite } from "."; @@ -105,6 +107,7 @@ import { Conditions, conditionsOfSelectionSet, isConstantCondition, mergeConditi import { enforceQueryPlannerConfigDefaults, QueryPlannerConfig, validateQueryPlannerConfig } from "./config"; import { generateAllPlansAndFindBest } from "./generateAllPlans"; import { QueryPlan, ResponsePath, SequenceNode, PlanNode, ParallelNode, FetchNode, SubscriptionNode, trimSelectionNodes } from "./QueryPlan"; +import { validateRecursiveSelections } from './recursiveSelectionsLimit'; const debug = newDebugLogger('plan'); @@ -394,6 +397,7 @@ class QueryPlanningTraversal { readonly costFunction: CostFunction, initialContext: PathContext, typeConditionedFetching: boolean, + nonLocalSelectionsState: NonLocalSelectionsState | null, excludedDestinations: ExcludedDestinations = [], excludedConditions: ExcludedConditions = [], ) { @@ -416,6 +420,24 @@ class QueryPlanningTraversal { parameters.overrideConditions, ); this.stack = mapOptionsToSelections(selectionSet, initialOptions); + if ( + this.parameters.federatedQueryGraph.nonLocalSelectionsMetadata + && nonLocalSelectionsState + ) { + if (this.parameters.federatedQueryGraph.nonLocalSelectionsMetadata + .checkNonLocalSelectionsLimitExceededAtRoot( + this.stack, + nonLocalSelectionsState, + this.parameters.supergraphSchema, + this.parameters.inconsistentAbstractTypesRuntimes, + this.parameters.overrideConditions, + ) + ) { + throw Error(`Number of non-local selections exceeds limit of ${ + NonLocalSelectionsMetadata.MAX_NON_LOCAL_SELECTIONS + }`); + } + } } private debugStack() { @@ -771,6 +793,7 @@ class QueryPlanningTraversal { this.costFunction, context, this.typeConditionedFetching, + null, excludedDestinations, addConditionExclusion(excludedConditions, edge.conditions), ).findBestPlan(); @@ -3125,6 +3148,22 @@ interface BuildQueryPlanOptions { * progressive @override feature. */ overrideConditions?: Map, + /** + * Normally, we impose a limit on the number of recursive selections, which if + * high can cause poor query planner performance. Setting this flag to `true` + * will disable this limit check. This is not advised if gateway is being used + * to serve queries outside your control, as doing so will leave query planner + * susceptible to denial-of-service attacks. + */ + recursiveSelectionsLimitDisabled?: boolean, + /** + * Normally, we impose a limit on the number of non-local selections, which if + * high can cause poor query planner performance. Setting this flag to `true` + * will disable this limit check. This is not advised if gateway is being used + * to serve queries outside your control, as doing so will leave query planner + * susceptible to denial-of-service attacks. + */ + nonLocalSelectionsLimitDisabled?: boolean, } export class QueryPlanner { @@ -3225,6 +3264,19 @@ export class QueryPlanner { return { kind: 'QueryPlan' }; } + if (!options?.recursiveSelectionsLimitDisabled) { + // Before running anything that might expand named fragments recursively, + // we ensure that doing so won't generate too many selections. + // + // This is done before the single-subgraph bypass to avoid those subgraphs + // being sent such a query. For gateway, note that top-level introspection + // fields are not split off from the query given to query planning, and + // this allows the check below to also impose a limit on the introspection + // part of queries. (Which is important, since query plan execution in + // gateway expands introspection fragments at the moment.) + validateRecursiveSelections(operation); + } + const isSubscription = operation.rootKind === 'subscription'; const statistics: PlanningStatistics = { @@ -3322,16 +3374,21 @@ export class QueryPlanner { } let rootNode: PlanNode | SubscriptionNode | undefined; + let nonLocalSelectionsState = options?.nonLocalSelectionsLimitDisabled + ? null + : new NonLocalSelectionsState(); if (deferConditions && deferConditions.size > 0) { assert(hasDefers, 'Should not have defer conditions without @defer'); rootNode = computePlanForDeferConditionals({ parameters, deferConditions, + nonLocalSelectionsState, }) } else { rootNode = computePlanInternal({ parameters, hasDefers, + nonLocalSelectionsState, }); } @@ -3524,9 +3581,11 @@ export class QueryPlanner { function computePlanInternal({ parameters, hasDefers, + nonLocalSelectionsState, }: { parameters: PlanningParameters, hasDefers: boolean, + nonLocalSelectionsState: NonLocalSelectionsState | null, }): PlanNode | undefined { let main: PlanNode | undefined = undefined; let primarySelection: MutableSelectionSet | undefined = undefined; @@ -3534,7 +3593,11 @@ function computePlanInternal({ const { operation, processor } = parameters; if (operation.rootKind === 'mutation') { - const dependencyGraphs = computeRootSerialDependencyGraph(parameters, hasDefers); + const dependencyGraphs = computeRootSerialDependencyGraph( + parameters, + hasDefers, + nonLocalSelectionsState, + ); for (const dependencyGraph of dependencyGraphs) { const { main: localMain, deferred: localDeferred } = dependencyGraph.process(processor, operation.rootKind); // Note that `reduceSequence` "flatten" sequence if needs be. @@ -3554,6 +3617,7 @@ function computePlanInternal({ parameters, 0, hasDefers, + nonLocalSelectionsState, ); ({ main, deferred } = dependencyGraph.process(processor, operation.rootKind)); primarySelection = dependencyGraph.deferTracking.primarySelection; @@ -3568,9 +3632,11 @@ function computePlanInternal({ function computePlanForDeferConditionals({ parameters, deferConditions, + nonLocalSelectionsState, }: { parameters: PlanningParameters, deferConditions: SetMultiMap, + nonLocalSelectionsState: NonLocalSelectionsState | null, }): PlanNode | undefined { return generateConditionNodes( parameters.operation, @@ -3582,6 +3648,7 @@ function computePlanForDeferConditionals({ operation: op, }, hasDefers: true, + nonLocalSelectionsState, }), ); } @@ -3675,12 +3742,14 @@ function computeRootParallelDependencyGraph( parameters: PlanningParameters, startFetchIdGen: number, hasDefer: boolean, + nonLocalSelectionsState: NonLocalSelectionsState | null, ): FetchDependencyGraph { return computeRootParallelBestPlan( parameters, parameters.operation.selectionSet, startFetchIdGen, hasDefer, + nonLocalSelectionsState, )[0]; } @@ -3689,6 +3758,7 @@ function computeRootParallelBestPlan( selection: SelectionSet, startFetchIdGen: number, hasDefers: boolean, + nonLocalSelectionsState: NonLocalSelectionsState | null, ): [FetchDependencyGraph, OpPathTree, number] { const planningTraversal = new QueryPlanningTraversal( parameters, @@ -3699,6 +3769,7 @@ function computeRootParallelBestPlan( defaultCostFunction, emptyContext, parameters.config.typeConditionedFetching, + nonLocalSelectionsState, ); const plan = planningTraversal.findBestPlan(); // Getting no plan means the query is essentially unsatisfiable (it's a valid query, but we can prove it will never return a result), @@ -3726,6 +3797,7 @@ function onlyRootSubgraph(graph: FetchDependencyGraph): string { function computeRootSerialDependencyGraph( parameters: PlanningParameters, hasDefers: boolean, + nonLocalSelectionsState: NonLocalSelectionsState | null, ): FetchDependencyGraph[] { const { supergraphSchema, federatedQueryGraph, operation, root } = parameters; const rootType = hasDefers ? supergraphSchema.schemaDefinition.rootType(root.rootKind) : undefined; @@ -3733,10 +3805,22 @@ function computeRootSerialDependencyGraph( const splittedRoots = splitTopLevelFields(operation.selectionSet); const graphs: FetchDependencyGraph[] = []; let startingFetchId = 0; - let [prevDepGraph, prevPaths] = computeRootParallelBestPlan(parameters, splittedRoots[0], startingFetchId, hasDefers); + let [prevDepGraph, prevPaths] = computeRootParallelBestPlan( + parameters, + splittedRoots[0], + startingFetchId, + hasDefers, + nonLocalSelectionsState, + ); let prevSubgraph = onlyRootSubgraph(prevDepGraph); for (let i = 1; i < splittedRoots.length; i++) { - const [newDepGraph, newPaths] = computeRootParallelBestPlan(parameters, splittedRoots[i], prevDepGraph.nextFetchId(), hasDefers); + const [newDepGraph, newPaths] = computeRootParallelBestPlan( + parameters, + splittedRoots[i], + prevDepGraph.nextFetchId(), + hasDefers, + nonLocalSelectionsState, + ); const newSubgraph = onlyRootSubgraph(newDepGraph); if (prevSubgraph === newSubgraph) { // The new operation (think 'mutation' operation) is on the same subgraph than the previous one, so we can concat them in a single fetch diff --git a/query-planner-js/src/recursiveSelectionsLimit.ts b/query-planner-js/src/recursiveSelectionsLimit.ts new file mode 100644 index 000000000..2e5ba9b14 --- /dev/null +++ b/query-planner-js/src/recursiveSelectionsLimit.ts @@ -0,0 +1,100 @@ +import { + assertUnreachable, + FragmentSpreadSelection, + Operation, + SelectionSet +} from '@apollo/federation-internals'; + +const MAX_RECURSIVE_SELECTIONS = 10_000_000; + +/** + * Measures the number of selections that would be encountered if we walked + * the given selection set while recursing into fragment spreads. Returns + * `null` if this number exceeds `MAX_RECURSIVE_SELECTIONS`. + * + * Assumes that fragments referenced by spreads exist and don't form cycles. If + * If a fragment spread appears multiple times for the same named fragment, it + * is counted multiple times. + */ +function countRecursiveSelections( + operation: Operation, + fragmentCache: Map, + selectionSet: SelectionSet, + count: number, +): number | null { + for (const selection of selectionSet.selections()) { + // Add 1 for the current selection and check bounds. + count++; + if (count > MAX_RECURSIVE_SELECTIONS) { + return null; + } + + switch (selection.kind) { + case 'FieldSelection': { + if (selection.selectionSet) { + const result = countRecursiveSelections( + operation, + fragmentCache, + selection.selectionSet, + count, + ); + if (result === null) return null; + count = result; + } + break; + } + case 'FragmentSelection': { + if (selection instanceof FragmentSpreadSelection) { + const name = selection.namedFragment.name; + const cached = fragmentCache.get(name); + + if (cached !== undefined) { + count = count + cached; + if (count > MAX_RECURSIVE_SELECTIONS) { + return null; + } + } else { + const oldCount = count; + const result = countRecursiveSelections( + operation, + fragmentCache, + selection.selectionSet, + count, + ); + if (result === null) return null; + count = result; + fragmentCache.set(name, count - oldCount); + } + } else { // Inline fragment + const result = countRecursiveSelections( + operation, + fragmentCache, + selection.selectionSet, + count, + ); + if (result === null) return null; + count = result; + } + break; + } + default: + assertUnreachable(selection); + } + } + + return count; +} + +export function validateRecursiveSelections( + operation: Operation, +) { + const fragmentCache = new Map(); + const result = countRecursiveSelections( + operation, + fragmentCache, + operation.selectionSet, + 0); + if (result === null) { + throw new Error('Exceeded maximum recursive selections in this operation'); + } +};