Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 4 additions & 4 deletions src/HotChocolate/Core/src/Types.Analyzers/Errors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public static class Errors

public static readonly DiagnosticDescriptor DataLoaderCannotBeGeneric =
new(
id: "HC0085",
id: "HC0111",
title: "DataLoader Cannot Be Generic",
messageFormat: "The DataLoader source generator cannot generate generic DataLoaders",
category: "DataLoader",
Expand All @@ -88,7 +88,7 @@ public static class Errors

public static readonly DiagnosticDescriptor ConnectionSingleGenericTypeArgument =
new(
id: "HC0086",
id: "HC0110",
title: "Invalid Connection Structure",
messageFormat: "A generic connection/edge type must have a single generic type argument that represents the node type",
category: "TypeSystem",
Expand All @@ -97,7 +97,7 @@ public static class Errors

public static readonly DiagnosticDescriptor ConnectionNameFormatIsInvalid =
new(
id: "HC0087",
id: "HC0109",
title: "Invalid Connection/Edge Name Format",
messageFormat: "A connection/edge name must be in the format `{0}Edge` or `{0}Connection`",
category: "TypeSystem",
Expand All @@ -106,7 +106,7 @@ public static class Errors

public static readonly DiagnosticDescriptor ConnectionNameDuplicate =
new(
id: "HC0088",
id: "HC0108",
title: "Invalid Connection/Edge Name",
messageFormat: "The type `{0}` cannot be mapped to the GraphQL type name `{1}` as `{2}` is already mapped to it",
category: "TypeSystem",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,36 @@ public static IRequestExecutorBuilder RemoveMaxAllowedFieldCycleDepthRule(
return builder;
}

/// <summary>
/// Sets the maximum allowed field merge comparisons during
/// overlapping-fields-can-be-merged validation.
/// </summary>
/// <param name="builder">
/// The <see cref="IRequestExecutorBuilder"/>.
/// </param>
/// <param name="maxAllowedFieldMergeComparisons">
/// The maximum number of field-merge comparisons.
/// </param>
/// <returns>
/// Returns an <see cref="IRequestExecutorBuilder"/> that can be used to chain
/// configuration.
/// </returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="builder"/> is <c>null</c>.
/// </exception>
public static IRequestExecutorBuilder SetMaxAllowedFieldMergeComparisons(
this IRequestExecutorBuilder builder,
int maxAllowedFieldMergeComparisons)
{
ArgumentNullException.ThrowIfNull(builder);

ConfigureValidation(
builder,
(_, b) => b.ModifyOptions(o => o.MaxAllowedFieldMergeComparisons = maxAllowedFieldMergeComparisons));

return builder;
}

/// <summary>
/// Configures the underlying <see cref="DocumentValidatorBuilder"/>.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public static IServiceCollection AddGraphQLCore(this IServiceCollection services
maxAllowedNodes: options.MaxAllowedNodes,
maxAllowedTokens: options.MaxAllowedTokens,
maxAllowedFields: options.MaxAllowedFields,
maxAllowedDirectives: options.MaxAllowedDirectives,
maxAllowedRecursionDepth: options.MaxAllowedRecursionDepth);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ public sealed class RequestParserOptions
/// </summary>
public int MaxAllowedFields { get; set; } = 2048;

/// <summary>
/// <para>
/// The maximum number of directives allowed per location (e.g. per field,
/// per operation, per fragment definition). Repeatable directives can be used
/// to exhaust CPU and memory resources if not limited.
/// </para>
/// </summary>
public int MaxAllowedDirectives { get; set; } = 4;

/// <summary>
/// <para>
/// The maximum allowed recursion depth when parsing a document.
Expand Down
20 changes: 16 additions & 4 deletions src/HotChocolate/Core/src/Validation/DocumentValidatorContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ internal void Clear()
public sealed class FragmentContext
{
private readonly HashSet<string> _visited = [];
private readonly HashSet<string> _completed = [];
private readonly Dictionary<string, FragmentDefinitionNode> _fragments = new(StringComparer.Ordinal);

public IEnumerable<string> Names => _fragments.Keys;
Expand All @@ -267,7 +268,8 @@ public bool TryGet(FragmentSpreadNode spread, [NotNullWhen(true)] out FragmentDe

public bool TryEnter(FragmentSpreadNode spread, [NotNullWhen(true)] out FragmentDefinitionNode? fragment)
{
if (_visited.Add(spread.Name.Value)
if (!_completed.Contains(spread.Name.Value)
&& _visited.Add(spread.Name.Value)
&& _fragments.TryGetValue(spread.Name.Value, out fragment))
{
Comment thread
michaelstaib marked this conversation as resolved.
return true;
Expand All @@ -278,20 +280,30 @@ public bool TryEnter(FragmentSpreadNode spread, [NotNullWhen(true)] out Fragment
}

public void Leave(FragmentSpreadNode spread)
=> _visited.Remove(spread.Name.Value);
{
_visited.Remove(spread.Name.Value);
_completed.Add(spread.Name.Value);
}

public void Leave(FragmentDefinitionNode fragment)
=> _visited.Remove(fragment.Name.Value);
{
_visited.Remove(fragment.Name.Value);
_completed.Add(fragment.Name.Value);
}

public bool Exists(FragmentSpreadNode spread)
=> _fragments.ContainsKey(spread.Name.Value);

internal void Reset()
=> _visited.Clear();
{
_visited.Clear();
_completed.Clear();
}

internal void Clear()
{
_visited.Clear();
_completed.Clear();
_fragments.Clear();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public static DocumentValidatorBuilder AddFieldRules(
return builder
.AddRule<FieldSelectionsRule>()
.AddRule<LeafFieldSelectionsRule>()
.AddRule<OverlappingFieldsCanBeMergedRule>();
.AddRule((_, o) => new OverlappingFieldsCanBeMergedRule(o.MaxAllowedFieldMergeComparisons));
}

/// <summary>
Expand Down
23 changes: 23 additions & 0 deletions src/HotChocolate/Core/src/Validation/Options/ValidationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,27 @@ public ushort MaxAllowedListRecursiveDepth
get;
set => field = value > 0 ? value : (ushort)16;
} = 1;

/// <summary>
/// <para>
/// The maximum number of field-merge comparisons allowed during
/// overlapping-fields-can-be-merged validation. This prevents
/// adversarial queries with deeply nested inline fragments from
/// consuming unbounded CPU.
/// </para>
/// <para>Default: <c>100,000</c></para>
/// </summary>
public int MaxAllowedFieldMergeComparisons
{
get;
set
{
if (value < 1)
{
value = 100_000;
}

field = value;
}
} = 100_000;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ namespace HotChocolate.Validation.Rules;
/// </remarks>
internal sealed class OverlappingFieldsCanBeMergedRule : IDocumentValidatorRule
{
private readonly int _maxAllowedFieldMergeComparisons;

Comment thread
michaelstaib marked this conversation as resolved.
public OverlappingFieldsCanBeMergedRule(int maxAllowedFieldMergeComparisons)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maxAllowedFieldMergeComparisons, 1);

_maxAllowedFieldMergeComparisons = maxAllowedFieldMergeComparisons;
}

public ushort Priority => ushort.MaxValue;

public bool IsCacheable => true;
Expand All @@ -24,7 +33,7 @@ public void Validate(DocumentValidatorContext context, DocumentNode document)
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(document);

ValidateInternal(new MergeContext(context), document);
ValidateInternal(new MergeContext(context, _maxAllowedFieldMergeComparisons), document);
}

private static void ValidateInternal(MergeContext context, DocumentNode document)
Expand Down Expand Up @@ -168,11 +177,22 @@ private static void SameResponseShapeByName(
{
foreach (var entry in fieldMap)
{
if (context.BudgetExhausted)
{
return;
}

if (context.SameResponseShapeChecked.Contains(entry.Value))
{
continue;
}

if (!context.TryConsumeBudget(entry.Value.Count))
{
context.ReportBudgetExhausted();
return;
}

context.SameResponseShapeChecked.Add(entry.Value);

var newPath = path.Append(entry.Key);
Expand All @@ -198,16 +218,32 @@ private static void SameForCommonParentsByName(
{
foreach (var entry in fieldMap)
{
if (context.BudgetExhausted)
{
return;
}

var groups = GroupByCommonParents(entry.Value);
var newPath = path.Append(entry.Key);

foreach (var group in groups)
{
if (context.BudgetExhausted)
{
return;
}

if (context.SameForCommonParentsChecked.Contains(group))
{
continue;
}

if (!context.TryConsumeBudget(group.Count))
{
context.ReportBudgetExhausted();
return;
}

context.SameForCommonParentsChecked.Add(group);

var conflict = RequireSameNameAndArguments(newPath, group, context);
Expand Down Expand Up @@ -735,22 +771,62 @@ public int GetHashCode(HashSet<T> obj)
}
}

private sealed class MergeContext(DocumentValidatorContext context)
private sealed class MergeContext
{
private const int MaxPoolSize = 8;

private readonly DocumentValidatorContext _context;
private readonly int _maxAllowedFieldMergeComparisons;
private readonly Stack<Dictionary<string, HashSet<FieldAndType>>> _fieldMapPool = new();
private readonly Stack<HashSet<string>> _stringSetPool = new();
private readonly Stack<List<Conflict>> _conflictListPool = new();
private int _remainingBudget;

public MergeContext(DocumentValidatorContext context, int maxAllowedFieldMergeComparisons)
{
_context = context;
_maxAllowedFieldMergeComparisons = maxAllowedFieldMergeComparisons;
_remainingBudget = maxAllowedFieldMergeComparisons;
TypenameFieldType = new NonNullType(context.Schema.Types["String"]);
IsStreamEnabled = context.Schema.DirectiveDefinitions.ContainsName(DirectiveNames.Stream.Name);
}

public bool BudgetExhausted { get; private set; }

public bool TryConsumeBudget(int cost)
{
_remainingBudget -= cost;

if (_remainingBudget < 0)
{
BudgetExhausted = true;
return false;
}

return true;
}

public void ReportBudgetExhausted()
{
_context.FatalErrorDetected = true;
ReportError(
ErrorBuilder.New()
.SetMessage(
"The field merge validation budget of {0} comparisons was exhausted. "
+ "The document is too complex to validate.",
_maxAllowedFieldMergeComparisons)
.SetCode(ErrorCodes.Validation.BudgetExceeded)
.SpecifiedBy("sec-Field-Selection-Merging")
.Build());
}

public ISchemaDefinition Schema
=> context.Schema;
=> _context.Schema;

public int MaxLocationsPerError
=> context.MaxLocationsPerError;
=> _context.MaxLocationsPerError;

public IType TypenameFieldType { get; } =
new NonNullType(context.Schema.Types["String"]);
public IType TypenameFieldType { get; }

public HashSet<HashSet<FieldAndType>> SameResponseShapeChecked { get; } =
new HashSet<HashSet<FieldAndType>>(HashSetComparer<FieldAndType>.Instance);
Expand All @@ -761,10 +837,9 @@ public int MaxLocationsPerError
public HashSet<HashSet<FieldNode>> ConflictsReported { get; } =
new HashSet<HashSet<FieldNode>>(HashSetComparer<FieldNode>.Instance);

public DocumentValidatorContext.FragmentContext Fragments => context.Fragments;
public DocumentValidatorContext.FragmentContext Fragments => _context.Fragments;

public bool IsStreamEnabled { get; } =
context.Schema.DirectiveDefinitions.ContainsName(DirectiveNames.Stream.Name);
public bool IsStreamEnabled { get; }

public Dictionary<string, HashSet<FieldAndType>> RentFieldMap()
{
Expand Down Expand Up @@ -830,7 +905,7 @@ public void ReturnConflictList(List<Conflict> list)
}

public void ReportError(IError error)
=> context.ReportError(error);
=> _context.ReportError(error);
}

private sealed class FieldLocationComparer : IComparer<FieldNode>
Expand Down
Loading
Loading