diff --git a/juniper/CHANGELOG.md b/juniper/CHANGELOG.md index 90c01258..6a228e6b 100644 --- a/juniper/CHANGELOG.md +++ b/juniper/CHANGELOG.md @@ -70,6 +70,7 @@ All user visible changes to `juniper` crate will be documented in this file. Thi - `LookAheadMethods::applies_for()` method. ([#1138], [#1145]) - `LookAheadMethods::field_original_name()` and `LookAheadMethods::field_alias()` methods. ([#1199]) - [`anyhow` crate] integration behind `anyhow` and `backtrace` [Cargo feature]s. ([#1215], [#988]) +- `RootNode::disable_introspection()` applying additional `validation::rules::disable_introspection`, and `RootNode::enable_introspection()` reverting it. ([#1227], [#456]) ### Changed @@ -88,6 +89,7 @@ All user visible changes to `juniper` crate will be documented in this file. Thi - Stack overflow on nested GraphQL fragments. ([CVE-2022-31173]) [#113]: /../../issues/113 +[#456]: /../../issues/456 [#503]: /../../issues/503 [#528]: /../../issues/528 [#750]: /../../issues/750 @@ -140,6 +142,7 @@ All user visible changes to `juniper` crate will be documented in this file. Thi [#1209]: /../../pull/1209 [#1215]: /../../pull/1215 [#1221]: /../../pull/1221 +[#1227]: /../../pull/1227 [ba1ed85b]: /../../commit/ba1ed85b3c3dd77fbae7baf6bc4e693321a94083 [CVE-2022-31173]: /../../security/advisories/GHSA-4rx6-g5vg-5f3j diff --git a/juniper/src/lib.rs b/juniper/src/lib.rs index 43c28648..88825a28 100644 --- a/juniper/src/lib.rs +++ b/juniper/src/lib.rs @@ -58,7 +58,10 @@ use crate::{ executor::{execute_validated_query, get_operation}, introspection::{INTROSPECTION_QUERY, INTROSPECTION_QUERY_WITHOUT_DESCRIPTIONS}, parser::parse_document_source, - validation::{validate_input_values, visit_all_rules, ValidatorContext}, + validation::{ + rules, validate_input_values, visit as visit_rule, visit_all_rules, MultiVisitorNil, + ValidatorContext, + }, }; pub use crate::{ @@ -158,6 +161,13 @@ where { let mut ctx = ValidatorContext::new(&root_node.schema, &document); visit_all_rules(&mut ctx, &document); + if root_node.introspection_disabled { + visit_rule( + &mut MultiVisitorNil.with(rules::disable_introspection::factory()), + &mut ctx, + &document, + ); + } let errors = ctx.into_errors(); if !errors.is_empty() { @@ -201,6 +211,13 @@ where { let mut ctx = ValidatorContext::new(&root_node.schema, &document); visit_all_rules(&mut ctx, &document); + if root_node.introspection_disabled { + visit_rule( + &mut MultiVisitorNil.with(rules::disable_introspection::factory()), + &mut ctx, + &document, + ); + } let errors = ctx.into_errors(); if !errors.is_empty() { @@ -246,6 +263,13 @@ where { let mut ctx = ValidatorContext::new(&root_node.schema, &document); visit_all_rules(&mut ctx, &document); + if root_node.introspection_disabled { + visit_rule( + &mut MultiVisitorNil.with(rules::disable_introspection::factory()), + &mut ctx, + &document, + ); + } let errors = ctx.into_errors(); if !errors.is_empty() { diff --git a/juniper/src/schema/model.rs b/juniper/src/schema/model.rs index e7cba411..81c37636 100644 --- a/juniper/src/schema/model.rs +++ b/juniper/src/schema/model.rs @@ -44,6 +44,8 @@ pub struct RootNode< pub subscription_info: SubscriptionT::TypeInfo, #[doc(hidden)] pub schema: SchemaType<'a, S>, + #[doc(hidden)] + pub introspection_disabled: bool, } /// Metadata for a schema @@ -147,7 +149,7 @@ where mutation_info: MutationT::TypeInfo, subscription_info: SubscriptionT::TypeInfo, ) -> Self { - RootNode { + Self { query_type: query_obj, mutation_type: mutation_obj, subscription_type: subscription_obj, @@ -159,9 +161,65 @@ where query_info, mutation_info, subscription_info, + introspection_disabled: false, } } + /// Disables introspection for this [`RootNode`], making it to return a [`FieldError`] whenever + /// its `__schema` or `__type` field is resolved. + /// + /// By default, all introspection queries are allowed. + /// + /// # Example + /// + /// ```rust + /// # use juniper::{ + /// # graphql_object, graphql_vars, EmptyMutation, EmptySubscription, GraphQLError, + /// # RootNode, + /// # }; + /// # + /// pub struct Query; + /// + /// #[graphql_object] + /// impl Query { + /// fn some() -> bool { + /// true + /// } + /// } + /// + /// type Schema = RootNode<'static, Query, EmptyMutation<()>, EmptySubscription<()>>; + /// + /// let schema = Schema::new(Query, EmptyMutation::new(), EmptySubscription::new()) + /// .disable_introspection(); + /// + /// # // language=GraphQL + /// let query = "query { __schema { queryType { name } } }"; + /// + /// match juniper::execute_sync(query, None, &schema, &graphql_vars! {}, &()) { + /// Err(GraphQLError::ValidationError(errs)) => { + /// assert_eq!( + /// errs.first().unwrap().message(), + /// "GraphQL introspection is not allowed, but the operation contained `__schema`", + /// ); + /// } + /// res => panic!("expected `ValidationError`, returned: {res:#?}"), + /// } + /// ``` + pub fn disable_introspection(mut self) -> Self { + self.introspection_disabled = true; + self + } + + /// Enables introspection for this [`RootNode`], if it was previously [disabled][1]. + /// + /// By default, all introspection queries are allowed. + /// + /// [1]: RootNode::disable_introspection + pub fn enable_introspection(mut self) -> Self { + self.introspection_disabled = false; + self + } + #[cfg(feature = "schema-language")] /// The schema definition as a `String` in the /// [GraphQL Schema Language](https://graphql.org/learn/schema/#type-language) diff --git a/juniper/src/validation/mod.rs b/juniper/src/validation/mod.rs index c2759ec9..d8d7a817 100644 --- a/juniper/src/validation/mod.rs +++ b/juniper/src/validation/mod.rs @@ -3,7 +3,7 @@ mod context; mod input_value; mod multi_visitor; -mod rules; +pub mod rules; mod traits; mod visitor; diff --git a/juniper/src/validation/rules/disable_introspection.rs b/juniper/src/validation/rules/disable_introspection.rs new file mode 100644 index 00000000..f9588d1e --- /dev/null +++ b/juniper/src/validation/rules/disable_introspection.rs @@ -0,0 +1,203 @@ +//! Validation rule checking whether a GraphQL operation contains introspection (`__schema` or +//! `__type` fields). + +use crate::{ + ast::Field, + parser::Spanning, + validation::{ValidatorContext, Visitor}, + value::ScalarValue, +}; + +/// Validation rule checking whether a GraphQL operation contains introspection (`__schema` or +/// `__type` fields). +pub struct DisableIntrospection; + +/// Produces a new [`DisableIntrospection`] validation rule. +#[inline] +#[must_use] +pub fn factory() -> DisableIntrospection { + DisableIntrospection +} + +impl<'a, S> Visitor<'a, S> for DisableIntrospection +where + S: ScalarValue, +{ + fn enter_field( + &mut self, + context: &mut ValidatorContext<'a, S>, + field: &'a Spanning>, + ) { + let field_name = field.item.name.item; + if matches!(field_name, "__schema" | "__type") { + context.report_error(&error_message(field_name), &[field.item.name.span.start]); + } + } +} + +fn error_message(field_name: &str) -> String { + format!("GraphQL introspection is not allowed, but the operation contained `{field_name}`") +} + +#[cfg(test)] +mod tests { + use super::{error_message, factory}; + + use crate::{ + parser::SourcePosition, + validation::{expect_fails_rule, expect_passes_rule, RuleError}, + value::DefaultScalarValue, + }; + + #[test] + fn allows_regular_fields() { + // language=GraphQL + expect_passes_rule::<_, _, DefaultScalarValue>( + factory, + r#" + query { + user { + name + ... on User { + email + } + alias: email + ... { + typeless + } + friends { + name + } + } + } + "#, + ); + } + + #[test] + fn allows_typename_field() { + // language=GraphQL + expect_passes_rule::<_, _, DefaultScalarValue>( + factory, + r#" + query { + __typename + user { + __typename + ... on User { + __typename + } + ... { + __typename + } + friends { + __typename + } + } + } + "#, + ); + } + + #[test] + fn forbids_query_schema() { + // language=GraphQL + expect_fails_rule::<_, _, DefaultScalarValue>( + factory, + r#" + query { + __schema { + queryType { + name + } + } + } + "#, + &[RuleError::new( + &error_message("__schema"), + &[SourcePosition::new(37, 2, 16)], + )], + ); + } + + #[test] + fn forbids_query_type() { + // language=GraphQL + expect_fails_rule::<_, _, DefaultScalarValue>( + factory, + r#" + query { + __type( + name: "Query" + ) { + name + } + } + "#, + &[RuleError::new( + &error_message("__type"), + &[SourcePosition::new(37, 2, 16)], + )], + ); + } + + #[test] + fn forbids_field_type() { + // language=GraphQL + expect_fails_rule::<_, _, DefaultScalarValue>( + factory, + r#" + query { + user { + name + ... on User { + email + } + alias: email + ... { + typeless + } + friends { + name + } + __type + } + } + "#, + &[RuleError::new( + &error_message("__type"), + &[SourcePosition::new(370, 14, 20)], + )], + ); + } + + #[test] + fn forbids_field_schema() { + // language=GraphQL + expect_fails_rule::<_, _, DefaultScalarValue>( + factory, + r#" + query { + user { + name + ... on User { + email + } + alias: email + ... { + typeless + } + friends { + name + } + __schema + } + } + "#, + &[RuleError::new( + &error_message("__schema"), + &[SourcePosition::new(370, 14, 20)], + )], + ); + } +} diff --git a/juniper/src/validation/rules/mod.rs b/juniper/src/validation/rules/mod.rs index 60a38d13..6c22d2c9 100644 --- a/juniper/src/validation/rules/mod.rs +++ b/juniper/src/validation/rules/mod.rs @@ -1,5 +1,8 @@ +//! Definitions of rules for validation. + mod arguments_of_correct_type; mod default_values_of_correct_type; +pub mod disable_introspection; mod fields_on_correct_type; mod fragments_on_composite_types; mod known_argument_names; @@ -23,12 +26,13 @@ mod unique_variable_names; mod variables_are_input_types; mod variables_in_allowed_position; +use std::fmt::Debug; + use crate::{ ast::Document, validation::{visit, MultiVisitorNil, ValidatorContext}, value::ScalarValue, }; -use std::fmt::Debug; #[doc(hidden)] pub fn visit_all_rules<'a, S: Debug>(ctx: &mut ValidatorContext<'a, S>, doc: &'a Document) diff --git a/tests/integration/tests/disabled_introspection.rs b/tests/integration/tests/disabled_introspection.rs new file mode 100644 index 00000000..91e5c9da --- /dev/null +++ b/tests/integration/tests/disabled_introspection.rs @@ -0,0 +1,184 @@ +//! Checks whether [`RootNode::disable_introspection()`] works. + +use futures::stream; +use juniper::{ + execute, graphql_object, graphql_subscription, graphql_vars, resolve_into_stream, GraphQLError, + RootNode, +}; + +pub struct Query; + +#[graphql_object] +impl Query { + fn some() -> bool { + true + } +} + +pub struct Mutation; + +#[graphql_object] +impl Mutation { + fn another() -> bool { + false + } +} + +pub struct Subscription; + +#[graphql_subscription] +impl Subscription { + async fn another() -> stream::Empty { + stream::empty() + } +} + +#[tokio::test] +async fn query_schema() { + // language=GraphQL + let query = "query { __schema { queryType { name } } }"; + + let schema = RootNode::new(Query, Mutation, Subscription).disable_introspection(); + + match execute(query, None, &schema, &graphql_vars! {}, &()).await { + Err(GraphQLError::ValidationError(errors)) => { + assert_eq!(errors.len(), 1); + + let err = errors.first().unwrap(); + + assert_eq!( + err.message(), + "GraphQL introspection is not allowed, but the operation contained `__schema`", + ); + assert_eq!(err.locations()[0].index(), 8); + assert_eq!(err.locations()[0].line(), 0); + assert_eq!(err.locations()[0].column(), 8); + } + res => panic!("expected `ValidationError`, returned: {res:#?}"), + } +} + +#[tokio::test] +async fn mutation_schema() { + // language=GraphQL + let query = "mutation { __schema { queryType { name } } }"; + + let schema = RootNode::new(Query, Mutation, Subscription).disable_introspection(); + + match execute(query, None, &schema, &graphql_vars! {}, &()).await { + Err(GraphQLError::ValidationError(errors)) => { + assert_eq!(errors.len(), 2); + + let err = errors.first().unwrap(); + + assert_eq!( + err.message(), + "GraphQL introspection is not allowed, but the operation contained `__schema`", + ); + assert_eq!(err.locations()[0].index(), 11); + assert_eq!(err.locations()[0].line(), 0); + assert_eq!(err.locations()[0].column(), 11); + } + res => panic!("expected `ValidationError`, returned: {res:#?}"), + } +} + +#[tokio::test] +async fn subscription_schema() { + // language=GraphQL + let query = "subscription { __schema { queryType { name } } }"; + + let schema = RootNode::new(Query, Mutation, Subscription).disable_introspection(); + + match resolve_into_stream(query, None, &schema, &graphql_vars! {}, &()).await { + Err(GraphQLError::ValidationError(errors)) => { + assert_eq!(errors.len(), 2); + + let err = errors.first().unwrap(); + + assert_eq!( + err.message(), + "GraphQL introspection is not allowed, but the operation contained `__schema`", + ); + assert_eq!(err.locations()[0].index(), 15); + assert_eq!(err.locations()[0].line(), 0); + assert_eq!(err.locations()[0].column(), 15); + } + _ => panic!("expected `ValidationError`"), + }; +} + +#[tokio::test] +async fn query_type() { + // language=GraphQL + let query = r#"query { __type(name: "String") { name } }"#; + + let schema = RootNode::new(Query, Mutation, Subscription).disable_introspection(); + + match execute(query, None, &schema, &graphql_vars! {}, &()).await { + Err(GraphQLError::ValidationError(errors)) => { + assert_eq!(errors.len(), 1); + + let err = errors.first().unwrap(); + + assert_eq!( + err.message(), + "GraphQL introspection is not allowed, but the operation contained `__type`", + ); + assert_eq!(err.locations()[0].index(), 8); + assert_eq!(err.locations()[0].line(), 0); + assert_eq!(err.locations()[0].column(), 8); + } + res => panic!("expected `ValidationError`, returned: {res:#?}"), + } +} + +#[tokio::test] +async fn mutation_type() { + // language=GraphQL + let query = r#"mutation { __type(name: "String") { name } }"#; + + let schema = RootNode::new(Query, Mutation, Subscription).disable_introspection(); + + match execute(query, None, &schema, &graphql_vars! {}, &()).await { + Err(GraphQLError::ValidationError(errors)) => { + assert_eq!(errors.len(), 2); + + let err = errors.first().unwrap(); + + assert_eq!( + err.message(), + "GraphQL introspection is not allowed, but the operation contained `__type`", + ); + assert_eq!(err.locations()[0].index(), 11); + assert_eq!(err.locations()[0].line(), 0); + assert_eq!(err.locations()[0].column(), 11); + } + res => panic!("expected `ValidationError`, returned: {res:#?}"), + } +} + +#[tokio::test] +async fn subscription_type() { + // language=GraphQL + let query = r#"subscription Subscription { __type(name: "String") { name } }"#; + + let schema = RootNode::new(Query, Mutation, Subscription).disable_introspection(); + + match resolve_into_stream(query, None, &schema, &graphql_vars! {}, &()).await { + Err(GraphQLError::ValidationError(errors)) => { + assert_eq!(errors.len(), 2); + + let err = errors.first().unwrap(); + + assert_eq!( + err.message(), + "GraphQL introspection is not allowed, but the operation contained `__type`", + ); + assert_eq!(err.locations()[0].index(), 28); + assert_eq!(err.locations()[0].line(), 0); + assert_eq!(err.locations()[0].column(), 28); + } + _ => panic!("expected `ValidationError`"), + }; +}