Skip to content

Commit 92c5d1d

Browse files
Stop passing raw API query options into the expression parser (#766)
1 parent e1679e8 commit 92c5d1d

3 files changed

Lines changed: 348 additions & 14 deletions

File tree

src/Modules/Grand.Module.Api/Attributes/EnableQueryAttribute.cs

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
using Microsoft.AspNetCore.Mvc.Filters;
1+
using Grand.Module.Api.Constants;
2+
using Grand.Module.Api.Queries;
3+
using Microsoft.AspNetCore.Http;
24
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.AspNetCore.Mvc.Filters;
36
using System.Linq.Dynamic.Core;
4-
using Microsoft.AspNetCore.Http;
5-
using Grand.Module.Api.Constants;
7+
using System.Linq.Dynamic.Core.Exceptions;
68

79
namespace Grand.Module.Api.Attributes;
810

@@ -14,34 +16,62 @@ public override void OnActionExecuted(ActionExecutedContext context)
1416
if (context.Result is not ObjectResult result || result.Value == null)
1517
return;
1618

17-
if (result.Value is IQueryable queryable)
19+
if (result.Value is not IQueryable queryable)
20+
return;
21+
22+
try
23+
{
24+
result.Value = ApplyQueryOptions(queryable, context.HttpContext.Request.Query);
25+
}
26+
catch (ApiQueryOptionException ex)
27+
{
28+
//a rejected query option is the client's mistake, not ours - this filter runs after the
29+
//action, so without this the exception would leave the pipeline as a 500
30+
context.Result = new BadRequestObjectResult(new { error = ex.Message });
31+
}
32+
catch (ParseException ex)
1833
{
19-
queryable = ApplyQueryOptions(queryable, context.HttpContext.Request.Query, context.HttpContext.Response);
20-
result.Value = queryable;
34+
context.Result = new BadRequestObjectResult(new { error = $"$filter could not be parsed: {ex.Message}" });
2135
}
2236
}
2337

24-
private static IQueryable ApplyQueryOptions(IQueryable queryable, IQueryCollection query, HttpResponse response)
38+
private static IQueryable ApplyQueryOptions(IQueryable queryable, IQueryCollection query)
2539
{
40+
var elementType = queryable.ElementType;
41+
2642
if (query.TryGetValue("$filter", out var filter))
27-
queryable = queryable.Where(filter.ToString());
43+
{
44+
ApiQueryOptions.ValidateFilter(filter.ToString(), elementType);
45+
queryable = queryable.Where(ApiQueryOptions.FilterConfig, filter.ToString());
46+
}
2847

2948
if (query.TryGetValue("$orderby", out var orderBy))
30-
queryable = queryable.OrderBy(orderBy.ToString());
49+
queryable = queryable.OrderBy(ApiQueryOptions.FilterConfig,
50+
ApiQueryOptions.ParseOrderBy(orderBy.ToString(), elementType));
3151

3252
if (query.TryGetValue("$select", out var select))
33-
queryable = queryable.Select($"new({select})");
53+
queryable = queryable.Select(ApiQueryOptions.SelectConfig,
54+
ApiQueryOptions.ParseSelect(select.ToString(), elementType));
55+
56+
if (query.TryGetValue("$skip", out var skipValue))
57+
{
58+
if (!int.TryParse(skipValue, out var skip) || skip < 0)
59+
throw new ApiQueryOptionException("$skip must be a non-negative integer");
3460

35-
if (query.TryGetValue("$skip", out var skipValue) && int.TryParse(skipValue, out var skip))
3661
queryable = queryable.Skip(skip);
62+
}
3763

38-
if (query.TryGetValue("$top", out var topValue) && int.TryParse(topValue, out var top))
64+
if (query.TryGetValue("$top", out var topValue))
3965
{
40-
top = Math.Min(top, Configurations.MaxLimit);
41-
queryable = queryable.Take(top);
66+
if (!int.TryParse(topValue, out var top) || top < 0)
67+
throw new ApiQueryOptionException("$top must be a non-negative integer");
68+
69+
queryable = queryable.Take(Math.Min(top, Configurations.MaxLimit));
4270
}
4371
else
72+
{
4473
queryable = queryable.Take(Configurations.MaxLimit);
74+
}
4575

4676
return queryable;
4777
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
using System.Linq.Dynamic.Core;
2+
using System.Linq.Dynamic.Core.CustomTypeProviders;
3+
using System.Reflection;
4+
using System.Text.RegularExpressions;
5+
6+
namespace Grand.Module.Api.Queries;
7+
8+
/// <summary>
9+
/// Raised when a client sends a query option the API refuses to run. Mapped to 400 by the caller;
10+
/// it is never an internal error, so it must not surface as 500.
11+
/// </summary>
12+
public class ApiQueryOptionException(string message) : Exception(message);
13+
14+
/// <summary>
15+
/// Parses and restricts the OData-like query options.
16+
/// $filter reaches a real expression parser, so it is fenced on three sides: a parsing config that
17+
/// denies types, context keywords and object construction; a length limit; and a whitelist that
18+
/// accepts only members of the projected model. $orderby and $select never reach the parser as raw
19+
/// text - they are read as field lists and rebuilt here.
20+
/// </summary>
21+
public static class ApiQueryOptions
22+
{
23+
/// <summary>
24+
/// Long enough for a realistic filter, short enough that a pathological expression cannot make
25+
/// the parser the slow part of the request.
26+
/// </summary>
27+
public const int MaxFilterLength = 512;
28+
29+
public const int MaxFields = 50;
30+
31+
/// <summary>
32+
/// Methods a filter may name. Everything here is a cheap, side-effect free string or text
33+
/// operation; nothing that reflects, constructs, or walks a collection.
34+
/// </summary>
35+
private static readonly HashSet<string> AllowedMethods = new(StringComparer.OrdinalIgnoreCase) {
36+
"Contains", "StartsWith", "EndsWith", "ToLower", "ToUpper", "Trim", "Length", "Equals"
37+
};
38+
39+
/// <summary>
40+
/// Operators and literals the parser understands that are not members of the model.
41+
/// </summary>
42+
private static readonly HashSet<string> Keywords = new(StringComparer.OrdinalIgnoreCase) {
43+
"and", "or", "not", "true", "false", "null", "iif"
44+
};
45+
46+
private static readonly Regex Identifier = new(@"[A-Za-z_][A-Za-z0-9_]*", RegexOptions.Compiled);
47+
48+
/// <summary>
49+
/// String literals hold user text, not member names, so they are removed before the whitelist
50+
/// runs - otherwise a product named "Password" would fail its own search.
51+
/// </summary>
52+
private static readonly Regex StringLiteral = new(@"""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'", RegexOptions.Compiled);
53+
54+
/// <summary>
55+
/// Denies everything the parser can reach outside the model: no type resolution, no `it`/`root`
56+
/// context keywords, no `new`, no assembly probing, no Equals/ToString on object.
57+
/// </summary>
58+
public static ParsingConfig FilterConfig { get; } = new() {
59+
AreContextKeywordsEnabled = false,
60+
AllowNewToEvaluateAnyType = false,
61+
DisallowNewKeyword = true,
62+
ResolveTypesBySimpleName = false,
63+
SupportCastingToFullyQualifiedTypeAsString = false,
64+
LoadAdditionalAssembliesFromCurrentDomainBaseDirectory = false,
65+
AllowEqualsAndToStringMethodsOnObject = false,
66+
RestrictOrderByToPropertyOrField = true,
67+
CustomTypeProvider = new NoCustomTypesProvider()
68+
};
69+
70+
/// <summary>
71+
/// $select is rebuilt from validated field names, so `new` has to be available for that one
72+
/// projection - and for nothing else.
73+
/// </summary>
74+
public static ParsingConfig SelectConfig { get; } = new() {
75+
AreContextKeywordsEnabled = false,
76+
AllowNewToEvaluateAnyType = false,
77+
ResolveTypesBySimpleName = false,
78+
SupportCastingToFullyQualifiedTypeAsString = false,
79+
LoadAdditionalAssembliesFromCurrentDomainBaseDirectory = false,
80+
AllowEqualsAndToStringMethodsOnObject = false,
81+
RestrictOrderByToPropertyOrField = true,
82+
CustomTypeProvider = new NoCustomTypesProvider()
83+
};
84+
85+
/// <summary>
86+
/// Checks that a filter names nothing outside the model it runs against.
87+
/// </summary>
88+
public static void ValidateFilter(string filter, Type elementType)
89+
{
90+
if (string.IsNullOrWhiteSpace(filter))
91+
throw new ApiQueryOptionException("$filter is empty");
92+
93+
if (filter.Length > MaxFilterLength)
94+
throw new ApiQueryOptionException($"$filter exceeds {MaxFilterLength} characters");
95+
96+
var members = MemberNames(elementType);
97+
var expression = StringLiteral.Replace(filter, " ");
98+
99+
foreach (Match match in Identifier.Matches(expression))
100+
{
101+
var name = match.Value;
102+
if (Keywords.Contains(name) || AllowedMethods.Contains(name) || members.Contains(name))
103+
continue;
104+
105+
throw new ApiQueryOptionException($"'{name}' is not a queryable field of {elementType.Name}");
106+
}
107+
}
108+
109+
/// <summary>
110+
/// Reads "Field asc, Other desc" and gives it back with every field checked against the model.
111+
/// </summary>
112+
public static string ParseOrderBy(string orderBy, Type elementType)
113+
{
114+
var members = MemberNames(elementType);
115+
var parts = SplitFields(orderBy, "$orderby");
116+
var ordering = new List<string>(parts.Count);
117+
118+
foreach (var part in parts)
119+
{
120+
var tokens = part.Split(' ', StringSplitOptions.RemoveEmptyEntries);
121+
if (tokens.Length > 2)
122+
throw new ApiQueryOptionException($"'{part}' is not a valid $orderby entry");
123+
124+
if (!members.Contains(tokens[0]))
125+
throw new ApiQueryOptionException($"'{tokens[0]}' is not a queryable field of {elementType.Name}");
126+
127+
var direction = tokens.Length == 2 ? tokens[1].ToLowerInvariant() : "asc";
128+
if (direction != "asc" && direction != "desc")
129+
throw new ApiQueryOptionException($"'{tokens[1]}' is not a sort direction");
130+
131+
ordering.Add($"{tokens[0]} {direction}");
132+
}
133+
134+
return string.Join(", ", ordering);
135+
}
136+
137+
/// <summary>
138+
/// Reads a field list and builds the projection itself, so no client text reaches the parser.
139+
/// </summary>
140+
public static string ParseSelect(string select, Type elementType)
141+
{
142+
var members = MemberNames(elementType);
143+
var fields = SplitFields(select, "$select");
144+
145+
foreach (var field in fields)
146+
if (!members.Contains(field))
147+
throw new ApiQueryOptionException($"'{field}' is not a queryable field of {elementType.Name}");
148+
149+
return $"new({string.Join(", ", fields)})";
150+
}
151+
152+
private static List<string> SplitFields(string value, string option)
153+
{
154+
if (string.IsNullOrWhiteSpace(value))
155+
throw new ApiQueryOptionException($"{option} is empty");
156+
157+
var fields = value.Split(',', StringSplitOptions.RemoveEmptyEntries)
158+
.Select(x => x.Trim())
159+
.Where(x => x.Length > 0)
160+
.ToList();
161+
162+
if (fields.Count == 0)
163+
throw new ApiQueryOptionException($"{option} is empty");
164+
165+
if (fields.Count > MaxFields)
166+
throw new ApiQueryOptionException($"{option} lists more than {MaxFields} fields");
167+
168+
return fields;
169+
}
170+
171+
private static HashSet<string> MemberNames(Type elementType)
172+
{
173+
return new HashSet<string>(
174+
elementType.GetProperties(BindingFlags.Public | BindingFlags.Instance).Select(x => x.Name),
175+
StringComparer.OrdinalIgnoreCase);
176+
}
177+
178+
/// <summary>
179+
/// Leaves the parser with no types to resolve at all.
180+
/// </summary>
181+
private class NoCustomTypesProvider : IDynamicLinqCustomTypeProvider
182+
{
183+
public HashSet<Type> GetCustomTypes()
184+
{
185+
return [];
186+
}
187+
188+
public Dictionary<Type, List<MethodInfo>> GetExtensionMethods()
189+
{
190+
return [];
191+
}
192+
193+
public Type ResolveType(string typeName)
194+
{
195+
return null;
196+
}
197+
198+
public Type ResolveTypeBySimpleName(string simpleTypeName)
199+
{
200+
return null;
201+
}
202+
}
203+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using Grand.Module.Api.DTOs.Catalog;
2+
using Grand.Module.Api.Queries;
3+
using Microsoft.VisualStudio.TestTools.UnitTesting;
4+
using System.Linq.Dynamic.Core;
5+
using Assert = Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
6+
7+
namespace Grand.Module.Api.Tests.Queries;
8+
9+
[TestClass]
10+
public class ApiQueryOptionsTests
11+
{
12+
private static readonly Type ElementType = typeof(ProductDto);
13+
14+
[TestMethod]
15+
public void Filter_AcceptsAFieldOfTheModel()
16+
{
17+
ApiQueryOptions.ValidateFilter("Name.Contains(\"shirt\") and Published == true", ElementType);
18+
}
19+
20+
[TestMethod]
21+
public void Filter_KeepsALiteralOutOfTheFieldCheck()
22+
{
23+
//the text is data, not a member name - a product called "Password" must stay searchable
24+
ApiQueryOptions.ValidateFilter("Name == \"Password\"", ElementType);
25+
}
26+
27+
[TestMethod]
28+
[DataRow("PasswordHash != null", DisplayName = "field the model does not expose")]
29+
[DataRow("it.GetType().Assembly != null", DisplayName = "reflection through the context keyword")]
30+
[DataRow("\"\".GetType().Assembly.GetTypes().Length > 0", DisplayName = "type walk from a literal")]
31+
[DataRow("System.IO.File.ReadAllText(\"appsettings.json\") != null", DisplayName = "fully qualified type")]
32+
[DataRow("new(Name as X).X != null", DisplayName = "object construction")]
33+
[DataRow("Name.Equals(Name, StringComparison.Ordinal)", DisplayName = "type reference in an argument")]
34+
public void Filter_RejectsAnythingOutsideTheModel(string filter)
35+
{
36+
Assert.ThrowsExactly<ApiQueryOptionException>(() => ApiQueryOptions.ValidateFilter(filter, ElementType));
37+
}
38+
39+
[TestMethod]
40+
public void Filter_RejectsAnOverlongExpression()
41+
{
42+
var filter = string.Join(" or ", Enumerable.Repeat("Name == \"x\"", 100));
43+
44+
var ex = Assert.ThrowsExactly<ApiQueryOptionException>(
45+
() => ApiQueryOptions.ValidateFilter(filter, ElementType));
46+
47+
StringAssert.Contains(ex.Message, "characters");
48+
}
49+
50+
/// <summary>
51+
/// The whitelist decides what runs; this checks the parser it runs on is locked down too, so a
52+
/// future change to the whitelist cannot quietly re-open type access.
53+
/// </summary>
54+
[TestMethod]
55+
public void FilterConfig_RefusesToResolveTypes()
56+
{
57+
var source = new[] { new ProductDto { Name = "shirt" } }.AsQueryable();
58+
59+
Assert.ThrowsExactly<System.Linq.Dynamic.Core.Exceptions.ParseException>(
60+
() => source.Where(ApiQueryOptions.FilterConfig, "it.GetType().Name == \"ProductDto\"").ToList());
61+
}
62+
63+
[TestMethod]
64+
public void OrderBy_NormalizesDirectionAndDefaultsToAscending()
65+
{
66+
Assert.AreEqual("Name asc, Sku desc", ApiQueryOptions.ParseOrderBy("Name, Sku DESC", ElementType));
67+
}
68+
69+
[TestMethod]
70+
[DataRow("PasswordHash")]
71+
[DataRow("Name sideways")]
72+
[DataRow("Name asc extra")]
73+
[DataRow("")]
74+
public void OrderBy_RejectsWhatItCannotVerify(string orderBy)
75+
{
76+
Assert.ThrowsExactly<ApiQueryOptionException>(() => ApiQueryOptions.ParseOrderBy(orderBy, ElementType));
77+
}
78+
79+
[TestMethod]
80+
public void Select_BuildsTheProjectionFromCheckedFields()
81+
{
82+
Assert.AreEqual("new(Id, Name)", ApiQueryOptions.ParseSelect("Id, Name", ElementType));
83+
}
84+
85+
[TestMethod]
86+
[DataRow("PasswordHash", DisplayName = "field the model does not expose")]
87+
[DataRow("Name as Alias", DisplayName = "expression rather than a field")]
88+
[DataRow("it.GetType()", DisplayName = "reflection")]
89+
public void Select_RejectsAnythingThatIsNotAPlainField(string select)
90+
{
91+
Assert.ThrowsExactly<ApiQueryOptionException>(() => ApiQueryOptions.ParseSelect(select, ElementType));
92+
}
93+
94+
[TestMethod]
95+
public void Select_RejectsMoreFieldsThanTheLimit()
96+
{
97+
var select = string.Join(",", Enumerable.Range(0, ApiQueryOptions.MaxFields + 1).Select(_ => "Name"));
98+
99+
Assert.ThrowsExactly<ApiQueryOptionException>(() => ApiQueryOptions.ParseSelect(select, ElementType));
100+
}
101+
}

0 commit comments

Comments
 (0)