diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 69d4e3212c..a764d064c4 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -177,7 +177,7 @@ "description": "Allow enabling/disabling GraphQL requests for all entities." }, "depth-limit": { - "type": "integer", + "type": [ "integer", "null" ], "description": "Maximum allowed depth of a GraphQL query.", "default": null }, diff --git a/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs b/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs index ecd3252bb9..1c3fde09f4 100644 --- a/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs +++ b/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs @@ -132,7 +132,22 @@ internal GraphQLRuntimeOptionsConverter(bool replaceEnvVar) } else if (reader.TokenType is JsonTokenType.Number) { - graphQLRuntimeOptions = graphQLRuntimeOptions with { DepthLimit = reader.GetInt32(), UserProvidedDepthLimit = true }; + int depthLimit; + try + { + depthLimit = reader.GetInt32(); + } + catch (FormatException) + { + throw new JsonException($"The JSON token value is of the incorrect numeric format."); + } + + if (depthLimit < -1 || depthLimit == 0) + { + throw new JsonException($"Invalid depth-limit: {depthLimit}. Specify a depth limit > 0 or remove the existing depth limit by specifying -1."); + } + + graphQLRuntimeOptions = graphQLRuntimeOptions with { DepthLimit = depthLimit, UserProvidedDepthLimit = true }; } else { diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index e125b659f5..bbd07e28ce 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -1298,6 +1298,73 @@ public async Task TestConfigIsValid() } } + /// + /// Test to verify that provided invalid value of depth-limit in the config file should + /// result in validation failure during `dab validate` and `dab start`. + /// + [DataTestMethod] + [DataRow(0, DisplayName = "[FAIL]: Invalid Value: 0 for depth-limit.")] + [DataRow(-2, DisplayName = "[FAIL]: Invalid Value: -2 for depth-limit.")] + [TestCategory(TestCategory.MSSQL)] + public async Task TestValidateConfigForInvalidDepthLimit(int? depthLimit) + { + await ValidateConfigWithDepthLimit(depthLimit, expectedSuccess: false); + } + + /// + /// Test to verify that provided valid value of depth-limit in the config file should not + /// result in any validation failure during `dab validate` and `dab start`. + /// -1 and null are special values. + /// -1 can be set to remove the depth limit, while `null` is the default value which means no depth limit check. + /// + [DataTestMethod] + [DataRow(-1, DisplayName = "[PASS]: Valid Value: -1 to disable depth limit")] + [DataRow(2, DisplayName = "[PASS]: Valid Value: 2 for depth-limit.")] + [DataRow(2147483647, DisplayName = "[PASS]: Valid Value: Using Int32.MaxValue(2147483647) for depth-limit.")] + [DataRow(null, DisplayName = "[PASS]: Default Value: null for depth-limit.")] + [TestCategory(TestCategory.MSSQL)] + public async Task TestValidateConfigForValidDepthLimit(int? depthLimit) + { + await ValidateConfigWithDepthLimit(depthLimit, expectedSuccess: true); + } + + /// + /// This method validates that depth-limit outside the valid range should fail validation + /// during `dab validate` and `dab start`. + /// + /// + /// + private static async Task ValidateConfigWithDepthLimit(int? depthLimit, bool expectedSuccess) + { + // Arrange: Common setup logic + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + const string CUSTOM_CONFIG = "custom-config.json"; + FileSystemRuntimeConfigLoader testConfigPath = TestHelper.GetRuntimeConfigLoader(); + RuntimeConfig configuration = TestHelper.GetRuntimeConfigProvider(testConfigPath).GetConfig(); + configuration = configuration with + { + Runtime = configuration.Runtime with + { + GraphQL = configuration.Runtime.GraphQL with { DepthLimit = depthLimit, UserProvidedDepthLimit = true } + } + }; + + MockFileSystem fileSystem = new(); + fileSystem.AddFile(CUSTOM_CONFIG, new MockFileData(configuration.ToJson())); + FileSystemRuntimeConfigLoader configLoader = new(fileSystem); + configLoader.UpdateConfigFilePath(CUSTOM_CONFIG); + RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(configLoader); + + Mock> configValidatorLogger = new(); + RuntimeConfigValidator configValidator = new(configProvider, fileSystem, configValidatorLogger.Object, true); + + // Act + bool isSuccess = await configValidator.TryValidateConfig(CUSTOM_CONFIG, TestHelper.ProvisionLoggerFactory()); + + // Assert based on expected success + Assert.AreEqual(expectedSuccess, isSuccess); + } + /// /// This test method checks a valid config's entities against /// the database and ensures they are valid. @@ -3698,6 +3765,273 @@ public async Task ValidateNextLinkUsage() } } + /// + /// Tests the enforcement of depth limit restrictions on GraphQL queries and mutations in non-hosted mode. + /// Verifies that requests exceeding the specified depth limit result in a BadRequest, + /// while requests within the limit succeed with the expected status code. + /// Also verifies that the error message contains the current and allowed max depth limit value. + /// Example: + /// Query: + /// query book_by_pk{ + /// book_by_pk(id: 1) { // depth: 1 + /// id, // depth: 2 + /// title, // depth: 2 + /// publisher_id // depth: 2 + /// } + /// } + /// Mutation: + /// mutation createbook { + /// createbook(item: { title: ""Book #1"", publisher_id: 1234 }) { // depth: 1 + /// title, // depth: 2 + /// publisher_id // depth: 2 + /// } + /// + /// The maximum allowed depth for GraphQL queries and mutations. + /// Indicates whether the operation is a mutation or a query. + /// The expected HTTP status code for the operation. + [DataTestMethod] + [DataRow(1, GraphQLOperation.Query, HttpStatusCode.BadRequest, DisplayName = "Failed Query execution when max depth limit is set to 1")] + [DataRow(2, GraphQLOperation.Query, HttpStatusCode.OK, DisplayName = "Query execution successful when max depth limit is set to 2")] + [DataRow(1, GraphQLOperation.Mutation, HttpStatusCode.BadRequest, DisplayName = "Failed Mutation execution when max depth limit is set to 1")] + [DataRow(2, GraphQLOperation.Mutation, HttpStatusCode.OK, DisplayName = "Mutation execution successful when max depth limit is set to 2")] + [TestCategory(TestCategory.MSSQL)] + public async Task TestDepthLimitRestrictionOnGraphQLInNonHostedMode( + int depthLimit, + GraphQLOperation operationType, + HttpStatusCode expectedStatusCodeForGraphQL) + { + // Arrange + GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: depthLimit); + graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true }; + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new()); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + string query; + if (operationType is GraphQLOperation.Mutation) + { + // requested mutation operation has depth of 2 + query = @"mutation createbook{ + createbook(item: { title: ""Book #1"", publisher_id: 1234 }) { + title + publisher_id + } + }"; + } + else + { + // requested query operation has depth of 2 + query = @"query book_by_pk{ + book_by_pk(id: 1) { + id, + title, + publisher_id + } + }"; + } + + object payload = new { query }; + + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + { + Content = JsonContent.Create(payload) + }; + + // Act + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); + + // Assert + Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode); + string body = await graphQLResponse.Content.ReadAsStringAsync(); + JsonElement responseJson = JsonSerializer.Deserialize(body); + if (graphQLResponse.StatusCode == HttpStatusCode.OK) + { + Assert.IsTrue(responseJson.TryGetProperty("data", out JsonElement data), "The response should contain data."); + Assert.IsFalse(data.TryGetProperty("errors", out _), "The response should not contain any errors."); + } + else + { + Assert.IsTrue(responseJson.TryGetProperty("errors", out JsonElement data), "The response should contain errors."); + Assert.IsTrue(data.EnumerateArray().Any(), "The response should contain at least one error."); + Assert.IsTrue(data.EnumerateArray().FirstOrDefault().TryGetProperty("message", out JsonElement message), "The error should contain a message."); + string errorMessage = message.GetString(); + string expectedErrorMessage = $"The GraphQL document has an execution depth of 2 which exceeds the max allowed execution depth of {depthLimit}."; + Assert.AreEqual(expectedErrorMessage, errorMessage, "The error message should contain the current and allowed max depth limit value."); + } + } + } + + /// + /// This test verifies that the depth-limit specified for GraphQL does not affect introspection queries. + /// In this test, we have specified the depth limit as 2 and we are sending introspection query with depth 6. + /// The expected result is that the query should be successful and should not return any errors. + /// Example: + /// { + /// __schema { // depth: 1 + /// types { // depth: 2 + /// name // depth: 3 + /// fields { // depth: 3 + /// name // depth: 4 + /// type { // depth: 4 + /// name // depth: 5 + /// kind // depth: 5 + /// ofType { // depth: 5 + /// name // depth: 6 + /// kind // depth: 6 + /// } + /// } + /// } + /// } + /// + [TestCategory(TestCategory.MSSQL)] + [TestMethod] + public async Task TestGraphQLIntrospectionQueriesAreNotImpactedByDepthLimit() + { + // Arrange + GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: 2); + graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true }; + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new()); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + // nested depth:6 + string query = @"{ + __schema { + types { + name + fields { + name + type { + name + kind + ofType { + name + kind + } + } + } + } + } + }"; + + object payload = new { query }; + + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + { + Content = JsonContent.Create(payload) + }; + + // Act + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); + string body = await graphQLResponse.Content.ReadAsStringAsync(); + + JsonElement responseJson = JsonSerializer.Deserialize(body); + Assert.IsNotNull(responseJson, "The response should be a valid JSON."); + Assert.IsTrue(responseJson.TryGetProperty("data", out JsonElement data), "The response should contain data."); + Assert.IsFalse(data.TryGetProperty("errors", out _), "The response should not contain any errors."); + Assert.IsTrue(responseJson.GetProperty("data").TryGetProperty("__schema", out JsonElement schema)); + Assert.IsNotNull(schema, "The response should contain schema information."); + } + } + + /// + /// Tests the behavior of GraphQL queries in non-hosted mode when the depth limit is explicitly set to -1 or null. + /// Setting the depth limit to -1 is intended to disable the depth limit check, allowing queries of any depth. + /// Using null as default value of dab which also disables the depth limit check. + /// This test verifies that queries are processed successfully without any errors under these configurations. + /// Example Query: + /// { + /// book_by_pk(id: 1) { // depth: 1 + /// id, // depth: 2 + /// title, // depth: 2 + /// publisher_id // depth: 2 + /// } + /// } + /// + /// + [DataTestMethod] + [DataRow(-1, DisplayName = "Setting -1 for depth-limit will disable the depth limit")] + [DataRow(null, DisplayName = "Using default value: null for depth-limit which also disables the depth limit check")] + [TestCategory(TestCategory.MSSQL)] + public async Task TestNoDepthLimitOnGrahQLInNonHostedMode(int? depthLimit) + { + // Arrange + GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: depthLimit); + graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true }; + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new()); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + // requested query operation has depth of 2 + string query = @"{ + book_by_pk(id: 1) { + id, + title, + publisher_id + } + }"; + + object payload = new { query }; + + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + { + Content = JsonContent.Create(payload) + }; + + // Act + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); + string body = await graphQLResponse.Content.ReadAsStringAsync(); + + JsonElement responseJson = JsonSerializer.Deserialize(body); + Assert.IsNotNull(responseJson, "The response should be a valid JSON."); + Assert.IsTrue(responseJson.TryGetProperty("data", out JsonElement data), "The response should contain data."); + Assert.IsFalse(data.TryGetProperty("errors", out _), "The response should not contain any errors."); + Assert.IsTrue(data.TryGetProperty("book_by_pk", out _), "The response data should contain book_by_pk data."); + } + } + /// /// Helper function to write custom configuration file. with minimal REST/GraphQL global settings /// using the supplied entities. diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 1fbb22a88f..77ebb63b9d 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -25,6 +25,7 @@ using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.HealthCheck; using HotChocolate.AspNetCore; +using HotChocolate.Execution.Configuration; using HotChocolate.Types; using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.Channel; @@ -189,7 +190,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); - AddGraphQLService(services); + AddGraphQLService(services, runtimeConfig?.Runtime?.GraphQL); services.AddFusionCache() .WithOptions(options => { @@ -213,50 +214,61 @@ public void ConfigureServices(IServiceCollection services) /// when determining whether to allow introspection requests to proceed. /// /// Service Collection - private void AddGraphQLService(IServiceCollection services) + private void AddGraphQLService(IServiceCollection services, GraphQLRuntimeOptions? graphQLRuntimeOptions) { - services.AddGraphQLServer() - .AddHttpRequestInterceptor() - .ConfigureSchema((serviceProvider, schemaBuilder) => - { - GraphQLSchemaCreator graphQLService = serviceProvider.GetRequiredService(); - graphQLService.InitializeSchemaAndResolvers(schemaBuilder); - }) - .AddHttpRequestInterceptor() - .AddAuthorization() - .AllowIntrospection(false) - .AddAuthorizationHandler() - .AddErrorFilter(error => + IRequestExecutorBuilder server = services.AddGraphQLServer() + .AddHttpRequestInterceptor() + .ConfigureSchema((serviceProvider, schemaBuilder) => + { + GraphQLSchemaCreator graphQLService = serviceProvider.GetRequiredService(); + graphQLService.InitializeSchemaAndResolvers(schemaBuilder); + }) + .AddHttpRequestInterceptor() + .AddAuthorization() + .AllowIntrospection(false) + .AddAuthorizationHandler(); + + // Conditionally adds a maximum depth rule to the GraphQL queries/mutation selection set. + // This rule is only added if a positive depth limit is specified, ensuring that the server + // enforces a limit on the depth of incoming GraphQL queries/mutation to prevent extremely deep queries + // that could potentially lead to performance issues. + // Additionally, the skipIntrospectionFields parameter is set to true to skip depth limit enforcement on introspection queries. + if (graphQLRuntimeOptions is not null && graphQLRuntimeOptions.DepthLimit.HasValue && graphQLRuntimeOptions.DepthLimit.Value > 0) + { + server = server.AddMaxExecutionDepthRule(maxAllowedExecutionDepth: graphQLRuntimeOptions.DepthLimit.Value, skipIntrospectionFields: true); + } + + server.AddErrorFilter(error => + { + if (error.Exception is not null) { - if (error.Exception is not null) - { - _logger.LogError(exception: error.Exception, message: "A GraphQL request execution error occurred."); - return error.WithMessage(error.Exception.Message); - } + _logger.LogError(exception: error.Exception, message: "A GraphQL request execution error occurred."); + return error.WithMessage(error.Exception.Message); + } - if (error.Code is not null) - { - _logger.LogError(message: "Error code: {errorCode}\nError message: {errorMessage}", error.Code, error.Message); - return error.WithMessage(error.Message); - } + if (error.Code is not null) + { + _logger.LogError(message: "Error code: {errorCode}\nError message: {errorMessage}", error.Code, error.Message); + return error.WithMessage(error.Message); + } - return error; - }) - .AddErrorFilter(error => + return error; + }) + .AddErrorFilter(error => + { + if (error.Exception is DataApiBuilderException thrownException) { - if (error.Exception is DataApiBuilderException thrownException) - { - return error.RemoveException() - .RemoveLocations() - .RemovePath() - .WithMessage(thrownException.Message) - .WithCode($"{thrownException.SubStatusCode}"); - } + return error.RemoveException() + .RemoveLocations() + .RemovePath() + .WithMessage(thrownException.Message) + .WithCode($"{thrownException.SubStatusCode}"); + } - return error; - }) - .UseRequest() - .UseDefaultPipeline(); + return error; + }) + .UseRequest() + .UseDefaultPipeline(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.