|
| 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 | +} |
0 commit comments