diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs index a2603de0425..769a359be79 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs @@ -61,13 +61,13 @@ public static IRequestExecutorBuilder AddGraphQLServer( (sp, _) => { var environment = sp.GetService(); - return environment?.IsDevelopment() == false; + return environment?.IsDevelopment() != true; }); builder.AddMaxAllowedFieldCycleDepthRule( isEnabled: (sp, _) => { var environment = sp.GetService(); - return environment?.IsDevelopment() == false; + return environment?.IsDevelopment() != true; }); } diff --git a/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs b/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs index dde463590e6..88206aaeb28 100644 --- a/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs +++ b/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs @@ -977,6 +977,7 @@ private static IServiceProvider CreateServices( Action? configure = null) => new ServiceCollection() .AddGraphQLServer() + .DisableIntrospection(disable: false) .AddQueryType() .AddUnionType() .AddType() diff --git a/src/HotChocolate/Core/test/Authorization.Tests/CodeFirstAuthorizationTests.cs b/src/HotChocolate/Core/test/Authorization.Tests/CodeFirstAuthorizationTests.cs index bc330f47a1c..499389a6e2e 100644 --- a/src/HotChocolate/Core/test/Authorization.Tests/CodeFirstAuthorizationTests.cs +++ b/src/HotChocolate/Core/test/Authorization.Tests/CodeFirstAuthorizationTests.cs @@ -462,6 +462,7 @@ private static IServiceProvider CreateServices( Action? configure = null) => new ServiceCollection() .AddGraphQLServer() + .DisableIntrospection(disable: false) .AddQueryType() .AddGlobalObjectIdentification(o => o.EnsureAllNodesCanBeResolved = false) .AddAuthorizationHandler(_ => handler) diff --git a/src/HotChocolate/Core/test/Execution.Tests/DependencyInjection/RequestExecutorBuilderExtensions_OptInFeatures.Tests.cs b/src/HotChocolate/Core/test/Execution.Tests/DependencyInjection/RequestExecutorBuilderExtensions_OptInFeatures.Tests.cs index 430e77f3290..bb6ac06f90c 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/DependencyInjection/RequestExecutorBuilderExtensions_OptInFeatures.Tests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/DependencyInjection/RequestExecutorBuilderExtensions_OptInFeatures.Tests.cs @@ -39,6 +39,7 @@ public async Task ExecuteRequestAsync_OptInFeatureStability_MatchesSnapshot() { (await new ServiceCollection() .AddGraphQLServer() + .DisableIntrospection(disable: false) .ModifyOptions(o => o.EnableOptInFeatures = true) .AddQueryType(d => d.Name("Query").Field("foo").Resolve("bar")) .OptInFeatureStability("feature1", "stability1") diff --git a/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/StarWarsCodeFirstTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/StarWarsCodeFirstTests.cs index 0bd5f511671..2d827999f71 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/StarWarsCodeFirstTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/StarWarsCodeFirstTests.cs @@ -822,7 +822,9 @@ public async Task SubscribeToReview() } """); - var results = subscriptionResult.ReadResultsAsync(); + // Get the enumerator before publishing so the consumer is registered + // and won't race with event dispatch. + await using var enumerator = subscriptionResult.ReadResultsAsync().GetAsyncEnumerator(); await executor.ExecuteAsync( """ @@ -834,17 +836,8 @@ await executor.ExecuteAsync( } """); - OperationResult? eventResult = null; - - using (var cts = new CancellationTokenSource(2000)) - { - await foreach (var queryResult in results.WithCancellation(cts.Token) - .ConfigureAwait(false)) - { - eventResult = queryResult; - break; - } - } + Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(30))); + var eventResult = enumerator.Current; snapshot.Add(eventResult); diff --git a/src/HotChocolate/Core/test/Execution.Tests/Pipeline/OperationCompilerSingleFlightTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Pipeline/OperationCompilerSingleFlightTests.cs index 8c04aeb9a51..c194ce23032 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Pipeline/OperationCompilerSingleFlightTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/Pipeline/OperationCompilerSingleFlightTests.cs @@ -152,10 +152,11 @@ query FailureCoalesce { public async Task Follower_Cancellation_Should_Not_Cancel_Leader_Compilation() { // arrange - using var leaderCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - using var followerCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + using var testCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using var followerCts = new CancellationTokenSource(); var compileCount = 0; var compileGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var leaderEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var executor = await new ServiceCollection() .AddGraphQL() @@ -163,13 +164,23 @@ public async Task Follower_Cancellation_Should_Not_Cancel_Leader_Compilation() .UseDefaultPipeline() .AddDiagnosticEventListener(_ => new CompileCountListener(() => Interlocked.Increment(ref compileCount))) .UseRequest( - (_, next) => CreateBlockingMiddleware(next, compileGate), + (_, next) => async context => + { + // Only block the leader. + if (context.Features.Get>() is not null) + { + leaderEntered.TrySetResult(); + await compileGate.Task; + } + + await next(context); + }, key: "Blocking", before: WellKnownRequestMiddleware.OperationResolverMiddleware, allowMultiple: true) .Services .BuildServiceProvider() - .GetRequestExecutorAsync(cancellationToken: leaderCts.Token); + .GetRequestExecutorAsync(cancellationToken: testCts.Token); const string operationText = """ @@ -180,27 +191,27 @@ query CancelFollowerOnly { // act var leaderTask = Task.Run( - () => executor.ExecuteAsync(operationText, leaderCts.Token), + () => executor.ExecuteAsync(operationText, testCts.Token), CancellationToken.None); - // Give the leader time to enter the pipeline and register in-flight. - await Task.Delay(50, leaderCts.Token); + // Wait for the leader to enter the pipeline and register in-flight. + await leaderEntered.Task.WaitAsync(testCts.Token); var followerTask = Task.Run( () => executor.ExecuteAsync(operationText, followerCts.Token), CancellationToken.None); - // Wait for the follower to cancel. - var followerCompletion = await Task.WhenAny( - followerTask, - Task.Delay(TimeSpan.FromSeconds(3), leaderCts.Token)); + // Give the follower a brief moment to register as a waiter behind the leader, + // then explicitly cancel it. + await Task.Delay(50, testCts.Token); + followerCts.Cancel(); + + // Wait for the follower to observe cancellation (hang-guard only; normal completion is fast). + var followerResult = await followerTask.WaitAsync(testCts.Token); // Release the leader. compileGate.TrySetResult(); - Assert.Same(followerTask, followerCompletion); - - var followerResult = await followerTask; - var leaderResult = await leaderTask; + var leaderResult = await leaderTask.WaitAsync(testCts.Token); // assert var followerErrors = Assert.IsType(followerResult).Errors; @@ -245,20 +256,6 @@ private static RequestDelegate CreateThrowingMiddleware( await next(context); }; - private static RequestDelegate CreateBlockingMiddleware( - RequestDelegate next, - TaskCompletionSource gate) - => async context => - { - // Only block the leader. - if (context.Features.Get>() is not null) - { - await gate.Task; - } - - await next(context); - }; - private sealed class RequestGate(int expectedRequests) { private readonly TaskCompletionSource _allArrived = diff --git a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerAspNetCoreHostingBuilderExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerAspNetCoreHostingBuilderExtensions.cs index ded109b5736..cb922e38094 100644 --- a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerAspNetCoreHostingBuilderExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerAspNetCoreHostingBuilderExtensions.cs @@ -21,12 +21,16 @@ public static class FusionServerAspNetCoreHostingBuilderExtensions /// /// The max allowed GraphQL request size. /// + /// + /// Defines if the default security policy should be disabled. + /// /// /// The for configuration chaining. /// public static IFusionGatewayBuilder AddGraphQLGateway( this IHostApplicationBuilder builder, string? name = null, - int maxAllowedRequestSize = ServerDefaults.MaxAllowedRequestSize) - => builder.Services.AddGraphQLGatewayServer(name, maxAllowedRequestSize); + int maxAllowedRequestSize = ServerDefaults.MaxAllowedRequestSize, + bool disableDefaultSecurity = false) + => builder.Services.AddGraphQLGatewayServer(name, maxAllowedRequestSize, disableDefaultSecurity); } diff --git a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs index 0533795b30f..f342c184ffc 100644 --- a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using HotChocolate.Fusion.Configuration; using HotChocolate.Language; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; namespace Microsoft.Extensions.DependencyInjection; @@ -18,17 +19,36 @@ public static class FusionServerServiceCollectionExtensions public static IFusionGatewayBuilder AddGraphQLGatewayServer( this IServiceCollection services, string? name = null, - int maxAllowedRequestSize = ServerDefaults.MaxAllowedRequestSize) + int maxAllowedRequestSize = ServerDefaults.MaxAllowedRequestSize, + bool disableDefaultSecurity = false) { ArgumentNullException.ThrowIfNull(services); ArgumentOutOfRangeException.ThrowIfNegative(maxAllowedRequestSize); - return services + var builder = services .AddGraphQLGateway(name) .AddGraphQLGatewayServerCore(maxAllowedRequestSize) .AddStartupInitialization() .AddDefaultHttpRequestInterceptor() .AddSubscriptionServices(); + + if (!disableDefaultSecurity) + { + builder.DisableIntrospection( + (sp, _) => + { + var environment = sp.GetService(); + return environment?.IsDevelopment() != true; + }); + builder.AddMaxAllowedFieldCycleDepthRule( + isEnabled: (sp, _) => + { + var environment = sp.GetService(); + return environment?.IsDevelopment() != true; + }); + } + + return builder; } private static IFusionGatewayBuilder AddGraphQLGatewayServerCore( diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DefaultSecurityTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DefaultSecurityTests.cs new file mode 100644 index 00000000000..673acfcfe4e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DefaultSecurityTests.cs @@ -0,0 +1,296 @@ +using System.Text.Json; +using HotChocolate.AspNetCore; +using HotChocolate.Transport.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +namespace HotChocolate.Fusion; + +public class DefaultSecurityTests : FusionTestBase +{ + private const string SimpleSchema = + """ + type Query { + field: String + } + """; + + private const string CyclicSchema = + """ + type Query { + human: Human + } + + type Human { + name: String + relatives: [Human] + } + """; + + [Fact] + public async Task DefaultSecurity_InProduction_IntrospectionIsDisabled() + { + // arrange + using var server1 = CreateSourceSchema("A", SimpleSchema); + + // Override the test base's default Development environment with Production. + using var gateway = await CreateCompositeSchemaAsync( + [("A", server1)], + environmentName: Environments.Production); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + "{ __schema { description } }", + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "Introspection is not allowed for the current request.", + "locations": [ + { + "line": 1, + "column": 3 + } + ], + "extensions": { + "code": "HC0046", + "field": "__schema" + } + } + ] + } + """); + } + + [Fact] + public async Task DefaultSecurity_InDevelopment_IntrospectionIsAllowed() + { + // arrange + using var server1 = CreateSourceSchema("A", SimpleSchema); + + // FusionTestBase already defaults to Development, so no configureServices needed. + using var gateway = await CreateCompositeSchemaAsync( + [("A", server1)], + configureGatewayBuilder: b => b.ConfigureSchemaServices((_, s) => + { + s.RemoveAll(); + s.AddSingleton(); + })); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + "{ __schema { description } }", + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__schema": { + "description": null + } + } + } + """); + } + + [Fact] + public async Task DefaultSecurity_Disabled_InProduction_IntrospectionIsAllowed() + { + // arrange + using var server1 = CreateSourceSchema("A", SimpleSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server1)], + configureGatewayBuilder: b => + { + b.DisableIntrospection(disable: false); + b.ConfigureSchemaServices((_, s) => + { + s.RemoveAll(); + s.AddSingleton(); + }); + }, + environmentName: Environments.Production); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + "{ __schema { description } }", + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__schema": { + "description": null + } + } + } + """); + } + + [Fact] + public async Task DefaultSecurity_InProduction_FieldCycleDepthIsEnforced() + { + // arrange - 4 levels of `relatives` exceeds the default limit of 3 + using var server1 = CreateSourceSchema("A", CyclicSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server1)], + environmentName: Environments.Production); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + human { + relatives { + relatives { + relatives { + relatives { + name + } + } + } + } + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "Maximum allowed coordinate cycle depth was exceeded.", + "locations": [ + { + "line": 6, + "column": 11 + } + ], + "path": [ + "human", + "relatives", + "relatives", + "relatives" + ], + "extensions": { + "code": "HC0087" + } + } + ] + } + """); + } + + [Fact] + public async Task DefaultSecurity_InDevelopment_FieldCycleDepthIsNotEnforced() + { + // arrange - 4 levels of `relatives` exceeds the limit but the rule is inactive in Development + // FusionTestBase already defaults to Development, so no configureServices needed. + using var server1 = CreateSourceSchema("A", CyclicSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server1)], + configureGatewayBuilder: b => b.ConfigureSchemaServices((_, s) => + { + s.RemoveAll(); + s.AddSingleton(); + })); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + human { + relatives { + relatives { + relatives { + relatives { + name + } + } + } + } + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // assert - query passes validation and executes (no HC0087 error) + using var response = await result.ReadAsResultAsync(); + Assert.Equal(JsonValueKind.Undefined, response.Errors.ValueKind); + Assert.Equal(JsonValueKind.Object, response.Data.ValueKind); + } + + [Fact] + public async Task DefaultSecurity_Disabled_InProduction_FieldCycleDepthIsNotEnforced() + { + // arrange + using var server1 = CreateSourceSchema("A", CyclicSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server1)], + configureGatewayBuilder: b => + { + b.RemoveMaxAllowedFieldCycleDepthRule(); + b.ConfigureSchemaServices((_, s) => + { + s.RemoveAll(); + s.AddSingleton(); + }); + }, + environmentName: Environments.Production); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + human { + relatives { + relatives { + relatives { + relatives { + name + } + } + } + } + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // assert - query passes validation and executes (no HC0087 error) + using var response = await result.ReadAsResultAsync(); + Assert.Equal(JsonValueKind.Undefined, response.Errors.ValueKind); + Assert.Equal(JsonValueKind.Object, response.Data.ValueKind); + } +}