Repository: apollographql/apollo-rs
Pull request #904 (2024-08-29 14:32:29)

Port MaxIntrospectionDepthRule from graphql-js
* https://github.com/graphql/graphql-js/blob/v17.0.0-alpha.7/src/validation/rules/MaxIntrospectionDepthRule.ts
* https://github.com/graphql/graphql-js/blob/v17.0.0-alpha.7/src/validation/__tests__/MaxIntrospectionDepthRule-test.ts

Without this rule, a malicious client could cause huge introspection responses that grow exponentially with the list nesting level in the query.

Instead of a validation rule, this check is performed as part of `SchemaIntrospectionSplit::split`. graphql-js allows users to disable or enable specific validation rules, with max introspection depth being part of "recommended" rules. apollo-compiler does not have this mechanism, so `document.validate()` always performs validation as per the GraphQL spec.

The depth limit is hard-coded to 3, and applied to specific intropsection fields that matches graphql-js. graphql-js code has a comment about counting all list fields. When I tried that, a "normal" introspection query was rejected because `__schema.types[list].field[list].args[list]` reaches depth 3.


Patch:
diff --git a/crates/apollo-compiler/CHANGELOG.md b/crates/apollo-compiler/CHANGELOG.md
index a28f9ea..42fc0f6 100644
--- a/crates/apollo-compiler/CHANGELOG.md
+++ b/crates/apollo-compiler/CHANGELOG.md
@@ -28,8 +28,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
   and matches their definition order in the schema.
   This helps fuzzing, where the same entropy source should generate the same test case.
 
+## Features
+
+- **Port MaxIntrospectionDepthRule from graphql-js - [SimonSapin] in [pull/904].**
+  This limits list nesting in introspection query which can cause very large responses.
+
 [SimonSapin]: https://github.com/SimonSapin
 [pull/898]: https://github.com/apollographql/apollo-rs/pull/898
+[pull/904]: https://github.com/apollographql/apollo-rs/pull/904
 
 
 # [1.0.0-beta.20](https://crates.io/crates/apollo-compiler/1.0.0-beta.20) - 2024-07-30
diff --git a/crates/apollo-compiler/src/execution/introspection_execute.rs b/crates/apollo-compiler/src/execution/introspection_execute.rs
index 5d8c00c..9450fdb 100644
--- a/crates/apollo-compiler/src/execution/introspection_execute.rs
+++ b/crates/apollo-compiler/src/execution/introspection_execute.rs
@@ -6,6 +6,7 @@ use crate::execution::engine::ExecutionMode;
 use crate::execution::resolver::ResolvedValue;
 use crate::execution::JsonMap;
 use crate::execution::Response;
+use crate::execution::SchemaIntrospectionError;
 use crate::execution::SchemaIntrospectionSplit;
 use crate::schema;
 use crate::schema::Implementers;
@@ -23,7 +24,7 @@ use std::sync::OnceLock;
 ///
 /// [schema introspection]: https://spec.graphql.org/October2021/#sec-Schema-Introspection
 #[derive(Clone, Debug)]
-pub struct SchemaIntrospectionQuery(pub(crate) Valid<ExecutableDocument>);
+pub struct SchemaIntrospectionQuery(Valid<ExecutableDocument>);
 
 impl std::ops::Deref for SchemaIntrospectionQuery {
     type Target = Valid<ExecutableDocument>;
@@ -40,6 +41,22 @@ impl std::fmt::Display for SchemaIntrospectionQuery {
 }
 
 impl SchemaIntrospectionQuery {
+    /// Construct a `SchemaIntrospectionQuery` from a document
+    /// assumed to contain exactly one operation that only has [schema introspection] fields.
+    ///
+    /// Generally [`split`][SchemaIntrospectionSplit::split] should be used instead.
+    ///
+    /// [schema introspection]: https://spec.graphql.org/October2021/#sec-Schema-Introspection
+    #[doc(hidden)]
+    // Hidden to discourage usage, but pub to allow Router "both" mode to use legacy code
+    // for deciding what is or isn’t an introspection query.
+    pub fn assume_only_intropsection_fields(
+        document: Valid<ExecutableDocument>,
+    ) -> Result<Self, SchemaIntrospectionError> {
+        super::introspection_max_depth::check_document(&document)?;
+        Ok(Self(document))
+    }
+
     /// Execute the [schema introspection] parts of an operation
     /// and wrap a callback to execute the rest (if any).
     ///
diff --git a/crates/apollo-compiler/src/execution/introspection_max_depth.rs b/crates/apollo-compiler/src/execution/introspection_max_depth.rs
new file mode 100644
index 0000000..0261f4b
--- /dev/null
+++ b/crates/apollo-compiler/src/execution/introspection_max_depth.rs
@@ -0,0 +1,53 @@
+use crate::executable::Selection;
+use crate::executable::SelectionSet;
+use crate::execution::introspection_split::get_fragment;
+use crate::execution::SchemaIntrospectionError;
+use crate::validation::Valid;
+use crate::ExecutableDocument;
+
+const MAX_LISTS_DEPTH: u32 = 3;
+
+pub(crate) fn check_document(
+    document: &Valid<ExecutableDocument>,
+) -> Result<(), SchemaIntrospectionError> {
+    for operation in document.operations.iter() {
+        let initial_depth = 0;
+        check_selection_set(document, initial_depth, &operation.selection_set)?;
+    }
+    Ok(())
+}
+
+fn check_selection_set(
+    document: &Valid<ExecutableDocument>,
+    depth_so_far: u32,
+    selection_set: &SelectionSet,
+) -> Result<(), SchemaIntrospectionError> {
+    for selection in &selection_set.selections {
+        match selection {
+            Selection::InlineFragment(inline) => {
+                check_selection_set(document, depth_so_far, &inline.selection_set)?
+            }
+            Selection::FragmentSpread(spread) => {
+                // Validation ensures that `Valid<ExecutableDocument>` does not contain fragment cycles
+                let def = get_fragment(document, &spread.fragment_name)?;
+                check_selection_set(document, depth_so_far, &def.selection_set)?
+            }
+            Selection::Field(field) => {
+                let mut depth = depth_so_far;
+                if matches!(
+                    field.name.as_str(),
+                    "fields" | "interfaces" | "possibleTypes" | "inputFields"
+                ) {
+                    depth += 1;
+                    if depth >= MAX_LISTS_DEPTH {
+                        return Err(SchemaIntrospectionError::DeeplyNestedIntrospectionList(
+                            field.name.location(),
+                        ));
+                    }
+                }
+                check_selection_set(document, depth, &field.selection_set)?
+            }
+        }
+    }
+    Ok(())
+}
diff --git a/crates/apollo-compiler/src/execution/introspection_split.rs b/crates/apollo-compiler/src/execution/introspection_split.rs
index fc2caa4..f3f990b 100644
--- a/crates/apollo-compiler/src/execution/introspection_split.rs
+++ b/crates/apollo-compiler/src/execution/introspection_split.rs
@@ -65,6 +65,7 @@ pub enum SchemaIntrospectionSplit {
 #[derive(Debug)]
 pub enum SchemaIntrospectionError {
     SuspectedValidationBug(SuspectedValidationBug),
+    DeeplyNestedIntrospectionList(Option<SourceSpan>),
     Unsupported {
         message: String,
         location: Option<SourceSpan>,
@@ -123,7 +124,9 @@ impl SchemaIntrospectionSplit {
                 .collect();
             let introspection_document =
                 make_single_operation_document(schema, document, new_operation, fragments);
-            Ok(Self::Only(SchemaIntrospectionQuery(introspection_document)))
+            Ok(Self::Only(
+                SchemaIntrospectionQuery::assume_only_intropsection_fields(introspection_document)?,
+            ))
         } else {
             let mut fragments_done = HashSet::with_hasher(Default::default());
             let mut new_documents = Split {
@@ -136,11 +139,13 @@ impl SchemaIntrospectionSplit {
                 &operation.selection_set,
             );
             Ok(Self::Both {
-                introspection_query: SchemaIntrospectionQuery(new_documents.introspection.build(
-                    schema,
-                    document,
-                    operation_selection_set.introspection,
-                )),
+                introspection_query: SchemaIntrospectionQuery::assume_only_intropsection_fields(
+                    new_documents.introspection.build(
+                        schema,
+                        document,
+                        operation_selection_set.introspection,
+                    ),
+                )?,
                 filtered_document: new_documents.other.build(
                     schema,
                     document,
@@ -244,7 +249,7 @@ fn make_single_operation_document(
         .expect("filtering a valid document should result in a valid document")
 }
 
-fn get_fragment<'doc>(
+pub(crate) fn get_fragment<'doc>(
     document: &'doc Valid<ExecutableDocument>,
     name: &Name,
 ) -> Result<&'doc Node<Fragment>, SchemaIntrospectionError> {
@@ -592,6 +597,9 @@ impl SchemaIntrospectionError {
     pub fn into_graphql_error(self, sources: &SourceMap) -> GraphQLError {
         match self {
             Self::SuspectedValidationBug(s) => s.into_graphql_error(sources),
+            Self::DeeplyNestedIntrospectionList(location) => {
+                GraphQLError::new("Maximum introspection depth exceeded", location, sources)
+            }
             Self::Unsupported { message, location } => {
                 GraphQLError::new(message, location, sources)
             }
diff --git a/crates/apollo-compiler/src/execution/mod.rs b/crates/apollo-compiler/src/execution/mod.rs
index 088e2d7..1d2d96a 100644
--- a/crates/apollo-compiler/src/execution/mod.rs
+++ b/crates/apollo-compiler/src/execution/mod.rs
@@ -9,6 +9,7 @@ mod resolver;
 mod engine;
 mod input_coercion;
 mod introspection_execute;
+mod introspection_max_depth;
 mod introspection_split;
 mod response;
 mod result_coercion;


Test patch:
diff --git a/crates/apollo-compiler/tests/introspection_max_depth.rs b/crates/apollo-compiler/tests/introspection_max_depth.rs
new file mode 100644
index 0000000..6ca785a
--- /dev/null
+++ b/crates/apollo-compiler/tests/introspection_max_depth.rs
@@ -0,0 +1,393 @@
+use apollo_compiler::execution::SchemaIntrospectionSplit;
+use apollo_compiler::ExecutableDocument;
+use apollo_compiler::Schema;
+use expect_test::expect;
+
+#[test]
+fn test_3_sibling_fields_list() {
+    let doc = r#"
+      {
+        __type(name: "Query") {
+          trueFields: fields(includeDeprecated: true) {
+            name
+          }
+          falseFields: fields(includeDeprecated: false) {
+            name
+          }
+          omittedFields: fields {
+            name
+          }
+        }
+      }
+    "#;
+    let expected = expect!["Ok"];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_2_nested_fields_lists() {
+    let doc = r#"
+      {
+        __type(name: "Query") {
+          fields {
+            type {
+              fields {
+                name
+              }
+            }
+          }
+        }
+      }
+    "#;
+    let expected = expect!["Ok"];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_3_nested_fields_lists() {
+    let doc = r#"
+      {
+        __type(name: "Query") {
+          fields {
+            type {
+              fields {
+                type {
+                  fields {
+                    name
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    "#;
+    let expected = expect![[r#"
+        GraphQLError {
+            message: "Maximum introspection depth exceeded",
+            locations: [
+                LineColumn {
+                    line: 8,
+                    column: 19,
+                },
+            ],
+            path: [],
+            extensions: {},
+        }
+    "#]];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_2_nested_input_fields_lists() {
+    let doc = r#"
+      {
+        __type(name: "Query") {
+          inputFields {
+            type {
+              inputFields {
+                name
+              }
+            }
+          }
+        }
+      }
+    "#;
+    let expected = expect!["Ok"];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_3_nested_input_fields_lists() {
+    let doc = r#"
+      {
+        __type(name: "Query") {
+          inputFields {
+            type {
+              inputFields {
+                type {
+                  inputFields {
+                    name
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    "#;
+    let expected = expect![[r#"
+        GraphQLError {
+            message: "Maximum introspection depth exceeded",
+            locations: [
+                LineColumn {
+                    line: 8,
+                    column: 19,
+                },
+            ],
+            path: [],
+            extensions: {},
+        }
+    "#]];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_2_nested_interfaces_lists() {
+    let doc = r#"
+      {
+        __schema {
+          types {
+            interfaces {
+              interfaces {
+                name
+              }
+            }
+          }
+        }
+      }
+    "#;
+    let expected = expect!["Ok"];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_3_nested_interfaces_lists() {
+    let doc = r#"
+      {
+        __schema {
+          types {
+            interfaces {
+              interfaces {
+                interfaces {
+                  name
+                }
+              }
+            }
+          }
+        }
+      }
+    "#;
+    let expected = expect![[r#"
+        GraphQLError {
+            message: "Maximum introspection depth exceeded",
+            locations: [
+                LineColumn {
+                    line: 7,
+                    column: 17,
+                },
+            ],
+            path: [],
+            extensions: {},
+        }
+    "#]];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_2_nested_possible_types_lists() {
+    let doc = r#"
+      {
+        __schema {
+          types {
+            possibleTypes {
+              possibleTypes {
+                name
+              }
+            }
+          }
+        }
+      }
+    "#;
+    let expected = expect!["Ok"];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_3_nested_possible_types_lists() {
+    let doc = r#"
+      {
+        __schema {
+          types {
+            possibleTypes {
+              possibleTypes {
+                possibleTypes {
+                  name
+                }
+              }
+            }
+          }
+        }
+      }
+    "#;
+    let expected = expect![[r#"
+        GraphQLError {
+            message: "Maximum introspection depth exceeded",
+            locations: [
+                LineColumn {
+                    line: 7,
+                    column: 17,
+                },
+            ],
+            path: [],
+            extensions: {},
+        }
+    "#]];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_2_nested_possible_types_lists_with_inline_fragments() {
+    let doc = r#"
+      {
+        ... on Query {
+          __schema {
+            types {
+              ... on __Type {
+                possibleTypes {
+                  ... on __Type {
+                    possibleTypes {
+                      ... on __Type {
+                        name
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    "#;
+    let expected = expect!["Ok"];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_3_nested_possible_types_lists_with_inline_fragments() {
+    let doc = r#"
+      {
+        ... on Query {
+          __schema {
+            types {
+              ... on __Type {
+                possibleTypes {
+                  ... on __Type {
+                    possibleTypes {
+                      ... on __Type {
+                        possibleTypes {
+                          ... on __Type {
+                            name
+                          }
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    "#;
+    let expected = expect![[r#"
+        GraphQLError {
+            message: "Maximum introspection depth exceeded",
+            locations: [
+                LineColumn {
+                    line: 11,
+                    column: 25,
+                },
+            ],
+            path: [],
+            extensions: {},
+        }
+    "#]];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_2_nested_possible_types_lists_with_named_fragments() {
+    let doc = r#"
+      {
+        __schema {
+          types {
+            ...One
+          }
+        }
+      }
+      fragment One on __Type {
+        possibleTypes {
+          ...Two
+        }
+      }
+      fragment Two on __Type {
+        possibleTypes {
+          ...Three
+        }
+      }
+      fragment Three on __Type {
+        name
+      }
+    "#;
+    let expected = expect!["Ok"];
+    assert_split(doc, expected);
+}
+
+#[test]
+fn test_3_nested_possible_types_lists_with_named_fragments() {
+    let doc = r#"
+      {
+        __schema {
+          types {
+            ...One
+          }
+        }
+      }
+      fragment One on __Type {
+        possibleTypes {
+          ...Two
+        }
+      }
+      fragment Two on __Type {
+        possibleTypes {
+          ...Three
+        }
+      }
+      fragment Three on __Type {
+        possibleTypes {
+          ...Four
+        }
+      }
+      fragment Four on __Type {
+        name
+      }
+    "#;
+    let expected = expect![[r#"
+        GraphQLError {
+            message: "Maximum introspection depth exceeded",
+            locations: [
+                LineColumn {
+                    line: 20,
+                    column: 9,
+                },
+            ],
+            path: [],
+            extensions: {},
+        }
+    "#]];
+    assert_split(doc, expected);
+}
+
+#[track_caller]
+fn assert_split(doc: &str, expected: expect_test::Expect) {
+    let schema = "type Query { f: Int }";
+    let schema = Schema::parse_and_validate(schema, "schema.graphql").unwrap();
+    let doc = ExecutableDocument::parse_and_validate(&schema, doc, "doc.graphql").unwrap();
+    let operation = doc.operations.get(None).unwrap();
+
+    match SchemaIntrospectionSplit::split(&schema, &doc, operation) {
+        Ok(_) => expected.assert_eq("Ok"),
+        Err(err) => expected.assert_debug_eq(&err.into_graphql_error(&doc.sources)),
+    }
+}
diff --git a/crates/apollo-compiler/tests/main.rs b/crates/apollo-compiler/tests/main.rs
index 0fa954a..f9ada95 100644
--- a/crates/apollo-compiler/tests/main.rs
+++ b/crates/apollo-compiler/tests/main.rs
@@ -3,6 +3,7 @@ mod extensions;
 mod field_set;
 mod field_type;
 mod introspection;
+mod introspection_max_depth;
 mod introspection_split;
 mod merge_schemas;
 /// Formerly in src/lib.rs