Skip to content

Commit 01d4729

Browse files
Copilotbaywet
andauthored
fix: components-key validation timeouts under load (#3108)
* Initial plan * fix(library): avoid components key validation timeouts under load Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * fix(library): use conditional regex fallback for older frameworks Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>
1 parent e1a7543 commit 01d4729

5 files changed

Lines changed: 125 additions & 9 deletions

File tree

‎agents.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Regex handling
2+
3+
- Analyze regex patterns for matching complexity and excessive backtracking, including on long and near-matching inputs. Prefer equivalent patterns with less backtracking when available, while preserving matching semantics and target-framework compatibility.
4+
- For fixed patterns on modern targets, use source-generated regexes with explicit match timeouts (`GeneratedRegex` under `NET8_0_OR_GREATER`).
5+
- Use conditional compilation to provide a regular `Regex` with the same pattern and an explicit match timeout for older targets. Do not duplicate regex validation with a manually maintained character scanner.
6+
- Older-runtime regex matching may still time out under load because timeouts use wall-clock time. If consumers encounter this limitation, recommend upgrading to a modern runtime that uses the source-generated implementation.
7+
- Keep shared patterns in constants and reference those constants in validation diagnostics and tests.

‎src/Microsoft.OpenApi/Expressions/CompositeExpression.cs‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license.
33

4+
using System;
45
using System.Collections.Generic;
56
using System.Linq;
67
using System.Text.RegularExpressions;
@@ -10,10 +11,17 @@ namespace Microsoft.OpenApi
1011
/// <summary>
1112
/// String literal with embedded expressions
1213
/// </summary>
13-
public class CompositeExpression : RuntimeExpression
14+
public partial class CompositeExpression : RuntimeExpression
1415
{
1516
private readonly string template;
16-
private readonly Regex expressionPattern = new(@"{(?<exp>\$[^}]*)");
17+
private const string ExpressionPattern = @"{(?<exp>\$[^}]*)";
18+
19+
#if NET8_0_OR_GREATER
20+
[GeneratedRegex(ExpressionPattern, RegexOptions.None, matchTimeoutMilliseconds: 100)]
21+
private static partial Regex ExpressionRegex();
22+
#else
23+
private static readonly Regex ExpressionRegex = new(ExpressionPattern, RegexOptions.None, TimeSpan.FromMilliseconds(100));
24+
#endif
1725

1826
/// <summary>
1927
/// Expressions embedded into string literal
@@ -24,12 +32,17 @@ public class CompositeExpression : RuntimeExpression
2432
/// Create a composite expression from a string literal with an embedded expression
2533
/// </summary>
2634
/// <param name="expression"></param>
35+
/// <exception cref="RegexMatchTimeoutException">Extracting embedded expressions exceeds the regex match timeout.</exception>
2736
public CompositeExpression(string expression)
2837
{
2938
template = expression;
3039

3140
// Extract subexpressions and convert to RuntimeExpressions
32-
var matches = expressionPattern.Matches(expression);
41+
#if NET8_0_OR_GREATER
42+
var matches = ExpressionRegex().Matches(expression);
43+
#else
44+
var matches = ExpressionRegex.Matches(expression);
45+
#endif
3346

3447
foreach (var item in matches.Cast<Match>())
3548
{

‎src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,19 @@ namespace Microsoft.OpenApi
1111
/// The validation rules for <see cref="OpenApiComponents"/>.
1212
/// </summary>
1313
[OpenApiRule]
14-
public static class OpenApiComponentsRules
14+
public static partial class OpenApiComponentsRules
1515
{
1616
/// <summary>
17-
/// The key regex.
17+
/// The key regex pattern.
1818
/// </summary>
19-
internal static readonly Regex KeyRegex = new(@"^[a-zA-Z0-9\.\-_]+$", RegexOptions.None, TimeSpan.FromMilliseconds(100));
19+
internal const string KeyPattern = @"^[a-zA-Z0-9\.\-_]+$";
20+
21+
#if NET8_0_OR_GREATER
22+
[GeneratedRegex(KeyPattern, RegexOptions.None, matchTimeoutMilliseconds: 100)]
23+
private static partial Regex KeyRegex();
24+
#else
25+
private static readonly Regex KeyRegex = new(KeyPattern, RegexOptions.None, TimeSpan.FromMilliseconds(100));
26+
#endif
2027

2128
/// <summary>
2229
/// All the fixed fields declared above are objects
@@ -54,12 +61,18 @@ private static void ValidateKeys(IValidationContext context, IEnumerable<string>
5461

5562
foreach (var key in keys)
5663
{
57-
if (!KeyRegex.IsMatch(key))
64+
#if NET8_0_OR_GREATER
65+
var isValidKey = KeyRegex().IsMatch(key);
66+
#else
67+
var isValidKey = KeyRegex.IsMatch(key);
68+
#endif
69+
if (!isValidKey)
5870
{
5971
context.CreateError(nameof(KeyMustBeRegularExpression),
60-
string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, component, KeyRegex.ToString()));
72+
string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, component, KeyPattern));
6173
}
6274
}
6375
}
76+
6477
}
6578
}

‎test/Microsoft.OpenApi.Tests/Expressions/RuntimeExpressionTests.cs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,28 @@ public void BuildRuntimeExpressionTwiceCreatesNewEquivalentInstances(string expr
145145
Assert.Equal(runtimeExpression1, runtimeExpression2);
146146
}
147147

148+
[Fact]
149+
public void CompositeRuntimeExpressionPreservesMultilineCaptures()
150+
{
151+
const string expression = "prefix {$request.header.foo\nbar} {$url} suffix";
152+
153+
var composite = Assert.IsType<CompositeExpression>(RuntimeExpression.Build(expression));
154+
155+
Assert.Equal(expression, composite.Expression);
156+
Assert.Equal(new[] { "$request.header.foo\nbar", "$url" },
157+
composite.ContainedExpressions.Select(static item => item.Expression));
158+
}
159+
160+
[Fact]
161+
public void CompositeRuntimeExpressionPreservesUnterminatedCapture()
162+
{
163+
const string expression = "prefix {$url";
164+
165+
var composite = Assert.IsType<CompositeExpression>(RuntimeExpression.Build(expression));
166+
167+
Assert.IsType<UrlExpression>(Assert.Single(composite.ContainedExpressions));
168+
}
169+
148170
[Fact]
149171
public void CompositeRuntimeExpressionContainsExpression()
150172
{

‎test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
// Licensed under the MIT license.
33

44
using System.Collections.Generic;
5+
using System.IO;
56
using System.Linq;
7+
using System.Text;
8+
using System.Text.Json;
9+
using System.Threading.Tasks;
610
using Xunit;
711

812
namespace Microsoft.OpenApi.Validations.Tests
@@ -32,8 +36,65 @@ public void ValidateKeyMustMatchRegularExpressionInComponents()
3236
Assert.False(result);
3337
Assert.NotNull(errors);
3438
var error = Assert.Single(errors);
35-
Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, "responses", OpenApiComponentsRules.KeyRegex.ToString()),
39+
Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, "responses", OpenApiComponentsRules.KeyPattern),
3640
error.Message);
3741
}
42+
43+
[Theory]
44+
[InlineData("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_", true)]
45+
[InlineData("", false)]
46+
[InlineData("a b", false)]
47+
[InlineData("a/b", false)]
48+
[InlineData("é", false)]
49+
[InlineData("12", false)]
50+
[InlineData("a\n", true)]
51+
[InlineData("\n", false)]
52+
[InlineData("a\n\n", false)]
53+
[InlineData("a\r\n", false)]
54+
[InlineData("a\nb", false)]
55+
[InlineData("a\0", false)]
56+
public void ValidateComponentKeyPreservesRegexBehavior(string key, bool isValid)
57+
{
58+
var components = new OpenApiComponents
59+
{
60+
Schemas = new Dictionary<string, IOpenApiSchema>
61+
{
62+
{ key, new OpenApiSchema() }
63+
}
64+
};
65+
66+
var rules = new ValidationRuleSet();
67+
rules.Add(typeof(OpenApiComponents), OpenApiComponentsRules.KeyMustBeRegularExpression);
68+
var errors = components.Validate(rules);
69+
70+
Assert.Equal(isValid, !errors.Any());
71+
}
72+
73+
[Theory]
74+
[InlineData(true)]
75+
[InlineData(false)]
76+
public async Task LoadAsyncValidatesLongComponentKeys(bool isValid)
77+
{
78+
var key = new string('a', 1_000_000) + (isValid ? string.Empty : "!");
79+
var json = """
80+
{"openapi":"3.1.0","info":{"title":"Test","version":"1.0"},"paths":{},"components":{"schemas":{
81+
""" + JsonSerializer.Serialize(key) + ":{\"type\":\"string\"}}}}";
82+
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
83+
84+
var result = await OpenApiDocument.LoadAsync(stream, cancellationToken: TestContext.Current.CancellationToken);
85+
86+
Assert.NotNull(result.Document);
87+
Assert.NotNull(result.Diagnostic);
88+
if (isValid)
89+
{
90+
Assert.Empty(result.Diagnostic.Errors);
91+
}
92+
else
93+
{
94+
var error = Assert.Single(result.Diagnostic.Errors);
95+
Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr,
96+
key, "schemas", OpenApiComponentsRules.KeyPattern), error.Message);
97+
}
98+
}
3899
}
39100
}

0 commit comments

Comments
 (0)