Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,7 @@ public static IRequestExecutorBuilder AddGraphQLServer(
var environment = sp.GetService<IHostEnvironment>();
return environment?.IsDevelopment() != true;
});
builder.AddMaxAllowedFieldCycleDepthRule(
isEnabled: (sp, _) =>
{
var environment = sp.GetService<IHostEnvironment>();
return environment?.IsDevelopment() != true;
});
builder.AddMaxAllowedFieldCycleDepthRule();
}

return builder;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
using System.Text.Json;
using HotChocolate.AspNetCore.Tests.Utilities;
using HotChocolate.Transport.Http;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Moq;

namespace HotChocolate.AspNetCore;

public class DefaultSecurityTests(TestServerFactory serverFactory) : ServerTestBase(serverFactory)
{
[Fact]
public async Task DefaultSecurity_InProduction_IntrospectionIsDisabled()
{
// arrange
using var server = CreateServer(environment: Environments.Production);

// act
using var client = GraphQLHttpClient.Create(server.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 server = CreateServer(environment: Environments.Development);

// act
using var client = GraphQLHttpClient.Create(server.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 server = CreateServer(
environment: Environments.Production,
disableDefaultSecurity: true);

// act
using var client = GraphQLHttpClient.Create(server.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
}
}
}
""");
}

[Theory]
[InlineData("Development")]
[InlineData("Production")]
public async Task DefaultSecurity_FieldCycleDepthIsEnforced(string environment)
{
// arrange - 4 levels of `relatives` exceeds the default limit of 3
using var server = CreateServer(environment: environment);

// act
using var client = GraphQLHttpClient.Create(server.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_Disabled_FieldCycleDepthIsNotEnforced()
{
// arrange
using var server = CreateServer(disableDefaultSecurity: true);

// act
using var client = GraphQLHttpClient.Create(server.CreateClient());
using var result = await client.PostAsync(
"""
{
human {
relatives {
relatives {
relatives {
relatives {
name
}
}
}
}
}
}
""",
new Uri("http://localhost:5000/graphql"));

// assert - query passes validation (no HC0087 error)
using var response = await result.ReadAsResultAsync();
Assert.Equal(JsonValueKind.Undefined, response.Errors.ValueKind);
}

private TestServer CreateServer(
string environment = "Development",
bool disableDefaultSecurity = false)
{
var mockHostEnvironment = new Mock<IHostEnvironment>();
mockHostEnvironment.Setup(env => env.EnvironmentName).Returns(environment);

return ServerFactory.Create(
services =>
{
services
.AddSingleton(mockHostEnvironment.Object)
.AddRouting()
.AddGraphQLServer(disableDefaultSecurity: disableDefaultSecurity)
.AddQueryType<Query>();
},
app => app
.UseRouting()
.UseEndpoints(endpoints => endpoints.MapGraphQL()));
}

public class Query
{
public Human? Human => null;
}

public class Human
{
public string? Name { get; set; }

public List<Human>? Relatives { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,7 @@ public static IFusionGatewayBuilder AddGraphQLGatewayServer(
var environment = sp.GetService<IHostEnvironment>();
return environment?.IsDevelopment() != true;
});
builder.AddMaxAllowedFieldCycleDepthRule(
isEnabled: (sp, _) =>
{
var environment = sp.GetService<IHostEnvironment>();
return environment?.IsDevelopment() != true;
});
builder.AddMaxAllowedFieldCycleDepthRule();
}

return builder;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ internal static bool AllowOperationPlanRequests(
this RequestContext context)
{
ArgumentNullException.ThrowIfNull(context);

if (context.Features.Get<OperationPlanRequestOverrides>()?.IsAllowed == true)
{
return true;
}

return context.Schema.GetRequestOptions().AllowOperationPlanRequests;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using HotChocolate.Features;
using HotChocolate.Fusion.Execution;

// ReSharper disable once CheckNamespace
#pragma warning disable IDE0130 // Namespace does not match folder structure
namespace HotChocolate.Execution;
#pragma warning restore IDE0130 // Namespace does not match folder structure

/// <summary>
/// Provides extension methods to configure per-request operation plan overrides.
/// </summary>
public static class OperationPlanRequestOverridesExtensions
{
/// <summary>
/// Allows the current request to retrieve the operation plan when the
/// <c>Fusion-Operation-Plan</c> header is set, regardless of the schema-level
/// <see cref="FusionRequestOptions.AllowOperationPlanRequests"/> setting.
/// </summary>
/// <param name="builder">
/// The operation request builder.
/// </param>
/// <returns>
/// Returns the operation request builder.
/// </returns>
public static OperationRequestBuilder AllowOperationPlanRequest(
this OperationRequestBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);

var options = builder.Features.Get<OperationPlanRequestOverrides>();

if (options is null)
{
options = new OperationPlanRequestOverrides(IsAllowed: true);
}
else
{
options = options with { IsAllowed = true };
}

builder.Features.Set(options);
return builder;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace HotChocolate.Fusion.Execution;

/// <summary>
/// Represents per-request overrides for the operation plan request behavior.
/// </summary>
/// <param name="IsAllowed">
/// A value indicating whether the current request is allowed to retrieve the operation plan
/// when the corresponding header is set, regardless of the schema-level
/// <see cref="FusionRequestOptions.AllowOperationPlanRequests"/> setting.
/// </param>
internal sealed record OperationPlanRequestOverrides(bool IsAllowed = true);
Loading
Loading