Skip to content

Commit 3b58f34

Browse files
Implement depth-limit to GraphQL Queries/Mutation operations For Non-Hosted Scenario (#2267)
## Why make this change? - Closes #1577 - This PR allows to limit query/mutation nested depth. - Any query/mutation with nested depth more than the specified maximum allowed depth limit will fail. - This will help in blocking potential queries that may be targeted to slow down our servers. ## What is this change? - Using `.AddMaxExecutionDepthRule` provided by Hot Chocolate. - Added in the StartUp, will be setup during the startup. - Updated `dab.draft.schema.json` file to allow both integer and null value for depth-limit. - NOTE: Currently this feature is only for Non-Hosted Scenario. - Introspection Queries are not impacted. ## How was this tested? - [X] Integration Tests ## Sample Request(s) #nested depth: 8 ```graphql { books(filter: {id: {eq: 1}}){ items{ id title publishers { id name books (filter: {authors: {id: {isNull: false}}}){ items { authors{ items{ id name } } } } } } } } ``` ![image](https://github.com/Azure/data-api-builder/assets/102276754/e9c0c79b-62b5-46ef-906a-d6a208bae598) # nested depth: 3 ```graphql { book_by_pk(id: 2){ id publishers { id name } } } ``` ![image](https://github.com/Azure/data-api-builder/assets/102276754/f21e0e52-0169-4d3d-9667-a83c511313b4) Mutation (nested depth: 7) ```graphql mutation { createbook(item: {title: "My New Book", publisher_id: 1234}) { id title publishers { id name books (filter: {authors: {id: {isNull: false}}}){ items { authors{ items{ id name } } } } } } } ``` ![image](https://github.com/Azure/data-api-builder/assets/102276754/67df3351-34c0-41be-a08f-9428cce12f18)
1 parent ef3b678 commit 3b58f34

File tree

4 files changed

+402
-41
lines changed

4 files changed

+402
-41
lines changed

schemas/dab.draft.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@
177177
"description": "Allow enabling/disabling GraphQL requests for all entities."
178178
},
179179
"depth-limit": {
180-
"type": "integer",
180+
"type": [ "integer", "null" ],
181181
"description": "Maximum allowed depth of a GraphQL query.",
182182
"default": null
183183
},

src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,22 @@ internal GraphQLRuntimeOptionsConverter(bool replaceEnvVar)
132132
}
133133
else if (reader.TokenType is JsonTokenType.Number)
134134
{
135-
graphQLRuntimeOptions = graphQLRuntimeOptions with { DepthLimit = reader.GetInt32(), UserProvidedDepthLimit = true };
135+
int depthLimit;
136+
try
137+
{
138+
depthLimit = reader.GetInt32();
139+
}
140+
catch (FormatException)
141+
{
142+
throw new JsonException($"The JSON token value is of the incorrect numeric format.");
143+
}
144+
145+
if (depthLimit < -1 || depthLimit == 0)
146+
{
147+
throw new JsonException($"Invalid depth-limit: {depthLimit}. Specify a depth limit > 0 or remove the existing depth limit by specifying -1.");
148+
}
149+
150+
graphQLRuntimeOptions = graphQLRuntimeOptions with { DepthLimit = depthLimit, UserProvidedDepthLimit = true };
136151
}
137152
else
138153
{

src/Service.Tests/Configuration/ConfigurationTests.cs

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,6 +1298,73 @@ public async Task TestConfigIsValid()
12981298
}
12991299
}
13001300

1301+
/// <summary>
1302+
/// Test to verify that provided invalid value of depth-limit in the config file should
1303+
/// result in validation failure during `dab validate` and `dab start`.
1304+
/// </summary>
1305+
[DataTestMethod]
1306+
[DataRow(0, DisplayName = "[FAIL]: Invalid Value: 0 for depth-limit.")]
1307+
[DataRow(-2, DisplayName = "[FAIL]: Invalid Value: -2 for depth-limit.")]
1308+
[TestCategory(TestCategory.MSSQL)]
1309+
public async Task TestValidateConfigForInvalidDepthLimit(int? depthLimit)
1310+
{
1311+
await ValidateConfigWithDepthLimit(depthLimit, expectedSuccess: false);
1312+
}
1313+
1314+
/// <summary>
1315+
/// Test to verify that provided valid value of depth-limit in the config file should not
1316+
/// result in any validation failure during `dab validate` and `dab start`.
1317+
/// -1 and null are special values.
1318+
/// -1 can be set to remove the depth limit, while `null` is the default value which means no depth limit check.
1319+
/// </summary>
1320+
[DataTestMethod]
1321+
[DataRow(-1, DisplayName = "[PASS]: Valid Value: -1 to disable depth limit")]
1322+
[DataRow(2, DisplayName = "[PASS]: Valid Value: 2 for depth-limit.")]
1323+
[DataRow(2147483647, DisplayName = "[PASS]: Valid Value: Using Int32.MaxValue(2147483647) for depth-limit.")]
1324+
[DataRow(null, DisplayName = "[PASS]: Default Value: null for depth-limit.")]
1325+
[TestCategory(TestCategory.MSSQL)]
1326+
public async Task TestValidateConfigForValidDepthLimit(int? depthLimit)
1327+
{
1328+
await ValidateConfigWithDepthLimit(depthLimit, expectedSuccess: true);
1329+
}
1330+
1331+
/// <summary>
1332+
/// This method validates that depth-limit outside the valid range should fail validation
1333+
/// during `dab validate` and `dab start`.
1334+
/// </summary>
1335+
/// <param name="depthLimit"></param>
1336+
/// <param name="expectedSuccess"></param>
1337+
private static async Task ValidateConfigWithDepthLimit(int? depthLimit, bool expectedSuccess)
1338+
{
1339+
// Arrange: Common setup logic
1340+
TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT);
1341+
const string CUSTOM_CONFIG = "custom-config.json";
1342+
FileSystemRuntimeConfigLoader testConfigPath = TestHelper.GetRuntimeConfigLoader();
1343+
RuntimeConfig configuration = TestHelper.GetRuntimeConfigProvider(testConfigPath).GetConfig();
1344+
configuration = configuration with
1345+
{
1346+
Runtime = configuration.Runtime with
1347+
{
1348+
GraphQL = configuration.Runtime.GraphQL with { DepthLimit = depthLimit, UserProvidedDepthLimit = true }
1349+
}
1350+
};
1351+
1352+
MockFileSystem fileSystem = new();
1353+
fileSystem.AddFile(CUSTOM_CONFIG, new MockFileData(configuration.ToJson()));
1354+
FileSystemRuntimeConfigLoader configLoader = new(fileSystem);
1355+
configLoader.UpdateConfigFilePath(CUSTOM_CONFIG);
1356+
RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(configLoader);
1357+
1358+
Mock<ILogger<RuntimeConfigValidator>> configValidatorLogger = new();
1359+
RuntimeConfigValidator configValidator = new(configProvider, fileSystem, configValidatorLogger.Object, true);
1360+
1361+
// Act
1362+
bool isSuccess = await configValidator.TryValidateConfig(CUSTOM_CONFIG, TestHelper.ProvisionLoggerFactory());
1363+
1364+
// Assert based on expected success
1365+
Assert.AreEqual(expectedSuccess, isSuccess);
1366+
}
1367+
13011368
/// <summary>
13021369
/// This test method checks a valid config's entities against
13031370
/// the database and ensures they are valid.
@@ -3698,6 +3765,273 @@ public async Task ValidateNextLinkUsage()
36983765
}
36993766
}
37003767

3768+
/// <summary>
3769+
/// Tests the enforcement of depth limit restrictions on GraphQL queries and mutations in non-hosted mode.
3770+
/// Verifies that requests exceeding the specified depth limit result in a BadRequest,
3771+
/// while requests within the limit succeed with the expected status code.
3772+
/// Also verifies that the error message contains the current and allowed max depth limit value.
3773+
/// Example:
3774+
/// Query:
3775+
/// query book_by_pk{
3776+
/// book_by_pk(id: 1) { // depth: 1
3777+
/// id, // depth: 2
3778+
/// title, // depth: 2
3779+
/// publisher_id // depth: 2
3780+
/// }
3781+
/// }
3782+
/// Mutation:
3783+
/// mutation createbook {
3784+
/// createbook(item: { title: ""Book #1"", publisher_id: 1234 }) { // depth: 1
3785+
/// title, // depth: 2
3786+
/// publisher_id // depth: 2
3787+
/// }
3788+
/// </summary>
3789+
/// <param name="depthLimit">The maximum allowed depth for GraphQL queries and mutations.</param>
3790+
/// <param name="operationType">Indicates whether the operation is a mutation or a query.</param>
3791+
/// <param name="expectedStatusCodeForGraphQL">The expected HTTP status code for the operation.</param>
3792+
[DataTestMethod]
3793+
[DataRow(1, GraphQLOperation.Query, HttpStatusCode.BadRequest, DisplayName = "Failed Query execution when max depth limit is set to 1")]
3794+
[DataRow(2, GraphQLOperation.Query, HttpStatusCode.OK, DisplayName = "Query execution successful when max depth limit is set to 2")]
3795+
[DataRow(1, GraphQLOperation.Mutation, HttpStatusCode.BadRequest, DisplayName = "Failed Mutation execution when max depth limit is set to 1")]
3796+
[DataRow(2, GraphQLOperation.Mutation, HttpStatusCode.OK, DisplayName = "Mutation execution successful when max depth limit is set to 2")]
3797+
[TestCategory(TestCategory.MSSQL)]
3798+
public async Task TestDepthLimitRestrictionOnGraphQLInNonHostedMode(
3799+
int depthLimit,
3800+
GraphQLOperation operationType,
3801+
HttpStatusCode expectedStatusCodeForGraphQL)
3802+
{
3803+
// Arrange
3804+
GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: depthLimit);
3805+
graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true };
3806+
3807+
DataSource dataSource = new(DatabaseType.MSSQL,
3808+
GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null);
3809+
3810+
RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new());
3811+
const string CUSTOM_CONFIG = "custom-config.json";
3812+
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());
3813+
3814+
string[] args = new[]
3815+
{
3816+
$"--ConfigFileName={CUSTOM_CONFIG}"
3817+
};
3818+
3819+
using (TestServer server = new(Program.CreateWebHostBuilder(args)))
3820+
using (HttpClient client = server.CreateClient())
3821+
{
3822+
string query;
3823+
if (operationType is GraphQLOperation.Mutation)
3824+
{
3825+
// requested mutation operation has depth of 2
3826+
query = @"mutation createbook{
3827+
createbook(item: { title: ""Book #1"", publisher_id: 1234 }) {
3828+
title
3829+
publisher_id
3830+
}
3831+
}";
3832+
}
3833+
else
3834+
{
3835+
// requested query operation has depth of 2
3836+
query = @"query book_by_pk{
3837+
book_by_pk(id: 1) {
3838+
id,
3839+
title,
3840+
publisher_id
3841+
}
3842+
}";
3843+
}
3844+
3845+
object payload = new { query };
3846+
3847+
HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql")
3848+
{
3849+
Content = JsonContent.Create(payload)
3850+
};
3851+
3852+
// Act
3853+
HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest);
3854+
3855+
// Assert
3856+
Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode);
3857+
string body = await graphQLResponse.Content.ReadAsStringAsync();
3858+
JsonElement responseJson = JsonSerializer.Deserialize<JsonElement>(body);
3859+
if (graphQLResponse.StatusCode == HttpStatusCode.OK)
3860+
{
3861+
Assert.IsTrue(responseJson.TryGetProperty("data", out JsonElement data), "The response should contain data.");
3862+
Assert.IsFalse(data.TryGetProperty("errors", out _), "The response should not contain any errors.");
3863+
}
3864+
else
3865+
{
3866+
Assert.IsTrue(responseJson.TryGetProperty("errors", out JsonElement data), "The response should contain errors.");
3867+
Assert.IsTrue(data.EnumerateArray().Any(), "The response should contain at least one error.");
3868+
Assert.IsTrue(data.EnumerateArray().FirstOrDefault().TryGetProperty("message", out JsonElement message), "The error should contain a message.");
3869+
string errorMessage = message.GetString();
3870+
string expectedErrorMessage = $"The GraphQL document has an execution depth of 2 which exceeds the max allowed execution depth of {depthLimit}.";
3871+
Assert.AreEqual(expectedErrorMessage, errorMessage, "The error message should contain the current and allowed max depth limit value.");
3872+
}
3873+
}
3874+
}
3875+
3876+
/// <summary>
3877+
/// This test verifies that the depth-limit specified for GraphQL does not affect introspection queries.
3878+
/// In this test, we have specified the depth limit as 2 and we are sending introspection query with depth 6.
3879+
/// The expected result is that the query should be successful and should not return any errors.
3880+
/// Example:
3881+
/// {
3882+
/// __schema { // depth: 1
3883+
/// types { // depth: 2
3884+
/// name // depth: 3
3885+
/// fields { // depth: 3
3886+
/// name // depth: 4
3887+
/// type { // depth: 4
3888+
/// name // depth: 5
3889+
/// kind // depth: 5
3890+
/// ofType { // depth: 5
3891+
/// name // depth: 6
3892+
/// kind // depth: 6
3893+
/// }
3894+
/// }
3895+
/// }
3896+
/// }
3897+
/// </summary>
3898+
[TestCategory(TestCategory.MSSQL)]
3899+
[TestMethod]
3900+
public async Task TestGraphQLIntrospectionQueriesAreNotImpactedByDepthLimit()
3901+
{
3902+
// Arrange
3903+
GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: 2);
3904+
graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true };
3905+
3906+
DataSource dataSource = new(DatabaseType.MSSQL,
3907+
GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null);
3908+
3909+
RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new());
3910+
const string CUSTOM_CONFIG = "custom-config.json";
3911+
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());
3912+
3913+
string[] args = new[]
3914+
{
3915+
$"--ConfigFileName={CUSTOM_CONFIG}"
3916+
};
3917+
3918+
using (TestServer server = new(Program.CreateWebHostBuilder(args)))
3919+
using (HttpClient client = server.CreateClient())
3920+
{
3921+
// nested depth:6
3922+
string query = @"{
3923+
__schema {
3924+
types {
3925+
name
3926+
fields {
3927+
name
3928+
type {
3929+
name
3930+
kind
3931+
ofType {
3932+
name
3933+
kind
3934+
}
3935+
}
3936+
}
3937+
}
3938+
}
3939+
}";
3940+
3941+
object payload = new { query };
3942+
3943+
HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql")
3944+
{
3945+
Content = JsonContent.Create(payload)
3946+
};
3947+
3948+
// Act
3949+
HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest);
3950+
3951+
// Assert
3952+
Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode);
3953+
string body = await graphQLResponse.Content.ReadAsStringAsync();
3954+
3955+
JsonElement responseJson = JsonSerializer.Deserialize<JsonElement>(body);
3956+
Assert.IsNotNull(responseJson, "The response should be a valid JSON.");
3957+
Assert.IsTrue(responseJson.TryGetProperty("data", out JsonElement data), "The response should contain data.");
3958+
Assert.IsFalse(data.TryGetProperty("errors", out _), "The response should not contain any errors.");
3959+
Assert.IsTrue(responseJson.GetProperty("data").TryGetProperty("__schema", out JsonElement schema));
3960+
Assert.IsNotNull(schema, "The response should contain schema information.");
3961+
}
3962+
}
3963+
3964+
/// <summary>
3965+
/// Tests the behavior of GraphQL queries in non-hosted mode when the depth limit is explicitly set to -1 or null.
3966+
/// Setting the depth limit to -1 is intended to disable the depth limit check, allowing queries of any depth.
3967+
/// Using null as default value of dab which also disables the depth limit check.
3968+
/// This test verifies that queries are processed successfully without any errors under these configurations.
3969+
/// Example Query:
3970+
/// {
3971+
/// book_by_pk(id: 1) { // depth: 1
3972+
/// id, // depth: 2
3973+
/// title, // depth: 2
3974+
/// publisher_id // depth: 2
3975+
/// }
3976+
/// }
3977+
/// </summary>
3978+
/// <param name="depthLimit"> </param>
3979+
[DataTestMethod]
3980+
[DataRow(-1, DisplayName = "Setting -1 for depth-limit will disable the depth limit")]
3981+
[DataRow(null, DisplayName = "Using default value: null for depth-limit which also disables the depth limit check")]
3982+
[TestCategory(TestCategory.MSSQL)]
3983+
public async Task TestNoDepthLimitOnGrahQLInNonHostedMode(int? depthLimit)
3984+
{
3985+
// Arrange
3986+
GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: depthLimit);
3987+
graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true };
3988+
3989+
DataSource dataSource = new(DatabaseType.MSSQL,
3990+
GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null);
3991+
3992+
RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new());
3993+
const string CUSTOM_CONFIG = "custom-config.json";
3994+
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());
3995+
3996+
string[] args = new[]
3997+
{
3998+
$"--ConfigFileName={CUSTOM_CONFIG}"
3999+
};
4000+
4001+
using (TestServer server = new(Program.CreateWebHostBuilder(args)))
4002+
using (HttpClient client = server.CreateClient())
4003+
{
4004+
// requested query operation has depth of 2
4005+
string query = @"{
4006+
book_by_pk(id: 1) {
4007+
id,
4008+
title,
4009+
publisher_id
4010+
}
4011+
}";
4012+
4013+
object payload = new { query };
4014+
4015+
HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql")
4016+
{
4017+
Content = JsonContent.Create(payload)
4018+
};
4019+
4020+
// Act
4021+
HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest);
4022+
4023+
// Assert
4024+
Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode);
4025+
string body = await graphQLResponse.Content.ReadAsStringAsync();
4026+
4027+
JsonElement responseJson = JsonSerializer.Deserialize<JsonElement>(body);
4028+
Assert.IsNotNull(responseJson, "The response should be a valid JSON.");
4029+
Assert.IsTrue(responseJson.TryGetProperty("data", out JsonElement data), "The response should contain data.");
4030+
Assert.IsFalse(data.TryGetProperty("errors", out _), "The response should not contain any errors.");
4031+
Assert.IsTrue(data.TryGetProperty("book_by_pk", out _), "The response data should contain book_by_pk data.");
4032+
}
4033+
}
4034+
37014035
/// <summary>
37024036
/// Helper function to write custom configuration file. with minimal REST/GraphQL global settings
37034037
/// using the supplied entities.

0 commit comments

Comments
 (0)