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.