Tri & filtre sécurisés en ASP.NET Core : Query Builder avec whitelist
Parsez ?sort=name,-createdAt&filter=country:TR,status:active de façon sûre grâce à une whitelist et un mapping vers expressions/colonnes SQL, sans injection dynamique.
Le tri et le filtrage dynamiques paraissent souvent être une « petite » fonctionnalité, mais peuvent rapidement devenir
un problème de sécurité et de maintenance. Si vous autorisez les clients à envoyer ?sort=... et ?filter=...
puis que vous construisez vos requêtes à partir de chaînes brutes, vous vous exposez à des risques d’injection,
à des requêtes cassées, ou à du tri/filtre sur des champs qui ne devraient pas être exposés.
La solution est un pattern simple : la whitelist. Le client ne « choisit pas une colonne ». Il envoie une clé, et le serveur mappe cette clé vers une opération de requête sûre et prédéfinie. Dans cette version, on va un cran plus loin : pas de boxing côté tri et pas de if/else en cascade côté filtre.
Objectif
- Supporter des requêtes comme
?sort=code,-createdAtet?filter=country:TR,status:active. - Whitelister les champs autorisés pour le tri et le filtrage.
- Éviter le boxing / les nodes
Convertau tri (ne pas utiliserExpression<Func<T, object>>). - Éviter les blocs de filtre hardcodés en
if/elseen utilisant des “filter factories”.
Format des paramètres
-
sort : séparé par des virgules, le préfixe
-signifie décroissant.
Exemple :?sort=code,-createdAt -
filter : paires
field:valueséparées par des virgules.
Exemple :?filter=country:TR,status:active
Entity d’exemple
public sealed class Reservation
{
public int Id { get; set; }
public string Code { get; set; } = "";
public string Country { get; set; } = "";
public string Status { get; set; } = "";
public DateTime CreatedAt { get; set; }
}
Étape 1 : parser les query strings (structured specs)
Le parser transforme des chaînes brutes en instructions structurées. Pas de fragments SQL, pas de noms de colonnes dynamiques : uniquement des données.
public sealed record SortSpec(string Key, bool Desc);
public sealed record FilterSpec(string Key, string Value);
public static class QueryParser
{
public static List<SortSpec> ParseSort(string? sort)
{
var list = new List<SortSpec>();
if (string.IsNullOrWhiteSpace(sort)) return list;
foreach (var raw in sort.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var desc = raw.StartsWith('-', StringComparison.Ordinal);
var key = desc ? raw[1..] : raw;
if (string.IsNullOrWhiteSpace(key)) continue;
list.Add(new SortSpec(key, desc));
}
return list;
}
public static List<FilterSpec> ParseFilter(string? filter)
{
var list = new List<FilterSpec>();
if (string.IsNullOrWhiteSpace(filter)) return list;
foreach (var raw in filter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var idx = raw.IndexOf(':');
if (idx <= 0 || idx == raw.Length - 1) continue;
var key = raw[..idx].Trim();
var value = raw[(idx + 1)..].Trim();
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
continue;
list.Add(new FilterSpec(key, value));
}
return list;
}
}
Étape 2 : whitelist du tri (pas de boxing + ThenBy)
Un raccourci fréquent consiste à utiliser Expression<Func<Reservation, object>>. Mais les champs de type value type
(comme Id ou CreatedAt) sont alors “boxés” en object, ce qui génère un node Convert.
Certaines anciennes versions d’EF Core pouvaient échouer à traduire cela en SQL ; même quand ça passe, ce n’est pas idéal.
À la place, on stocke des delegates qui appliquent le tri directement :
public static class ReservationSortWhitelist
{
// Premier OrderBy/OrderByDescending
public static readonly Dictionary<string, Func<IQueryable<Reservation>, bool, IOrderedQueryable<Reservation>>> First
= new(StringComparer.OrdinalIgnoreCase)
{
["id"] = (q, desc) => desc ? q.OrderByDescending(x => x.Id) : q.OrderBy(x => x.Id),
["code"] = (q, desc) => desc ? q.OrderByDescending(x => x.Code) : q.OrderBy(x => x.Code),
["country"] = (q, desc) => desc ? q.OrderByDescending(x => x.Country) : q.OrderBy(x => x.Country),
["status"] = (q, desc) => desc ? q.OrderByDescending(x => x.Status) : q.OrderBy(x => x.Status),
["createdAt"] = (q, desc) => desc ? q.OrderByDescending(x => x.CreatedAt) : q.OrderBy(x => x.CreatedAt)
};
// Puis ThenBy/ThenByDescending
public static readonly Dictionary<string, Func<IOrderedQueryable<Reservation>, bool, IOrderedQueryable<Reservation>>> Then
= new(StringComparer.OrdinalIgnoreCase)
{
["id"] = (q, desc) => desc ? q.ThenByDescending(x => x.Id) : q.ThenBy(x => x.Id),
["code"] = (q, desc) => desc ? q.ThenByDescending(x => x.Code) : q.ThenBy(x => x.Code),
["country"] = (q, desc) => desc ? q.ThenByDescending(x => x.Country) : q.ThenBy(x => x.Country),
["status"] = (q, desc) => desc ? q.ThenByDescending(x => x.Status) : q.ThenBy(x => x.Status),
["createdAt"] = (q, desc) => desc ? q.ThenByDescending(x => x.CreatedAt) : q.ThenBy(x => x.CreatedAt)
};
}
Étape 3 : whitelist du filtre (sans chaîne if/else)
Le filtrage peut suivre exactement la même logique. Chaque clé de filtre autorisée est mappée vers une opération de requête sûre.
Ici, on utilise des delegates “apply” : (query, value) => query.Where(...).
public static class ReservationFilterWhitelist
{
// filterKey -> (query, value) => updatedQuery
public static readonly Dictionary<string, Func<IQueryable<Reservation>, string, IQueryable<Reservation>>> Map
= new(StringComparer.OrdinalIgnoreCase)
{
["country"] = (q, v) => q.Where(r => r.Country == v),
["status"] = (q, v) => q.Where(r => r.Status == v)
};
}
Étape 4 : appliquer filtres + tri sur IQueryable
On applique d’abord les filtres, puis le tri. Dans cet exemple, les clés inconnues sont ignorées ; en mode “strict”, vous pouvez renvoyer 400.
public static class ReservationQueryBuilder
{
public static IQueryable<Reservation> Apply(
IQueryable<Reservation> query,
List<FilterSpec> filters,
List<SortSpec> sorts)
{
// Filters (whitelist)
foreach (var f in filters)
{
if (!ReservationFilterWhitelist.Map.TryGetValue(f.Key, out var apply))
continue;
query = apply(query, f.Value);
}
// Sorts (whitelist, pas de boxing)
IOrderedQueryable<Reservation>? ordered = null;
foreach (var s in sorts)
{
if (ordered is null)
{
if (ReservationSortWhitelist.First.TryGetValue(s.Key, out var firstSort))
ordered = firstSort(query, s.Desc);
}
else
{
if (ReservationSortWhitelist.Then.TryGetValue(s.Key, out var thenSort))
ordered = thenSort(ordered, s.Desc);
}
}
return ordered ?? query;
}
}
Exemple de controller (compatible EF Core)
Un endpoint minimal qui utilise le parser + le builder :
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
[ApiController]
[Route("api/reservations")]
public sealed class ReservationsController : ControllerBase
{
private readonly AppDbContext _db;
public ReservationsController(AppDbContext db)
{
_db = db;
}
[HttpGet]
public async Task<IActionResult> List(
[FromQuery] string? sort,
[FromQuery] string? filter,
CancellationToken ct)
{
var sorts = QueryParser.ParseSort(sort);
var filters = QueryParser.ParseFilter(filter);
IQueryable<Reservation> query = _db.Reservations.AsNoTracking();
query = ReservationQueryBuilder.Apply(query, filters, sorts);
// Dans une vraie API : limiter les résultats
var items = await query.Take(100).ToListAsync(ct);
return Ok(items);
}
}
Exemples de requêtes
/api/reservations?sort=code,-createdAt/api/reservations?filter=country:TR,status:active/api/reservations?filter=country:TR&sort=-createdAt/api/reservations?sort=unknown,-id(dans cet exemple, la clé inconnue est ignorée)
Améliorations courantes
-
Mode strict : collecter les clés inconnues et renvoyer
400(avec la liste des clés autorisées). -
Ajouter des opérateurs :
createdAt:gt:2026-01-01,status:in:active|pending. - Parser les valeurs de manière typée (date/int/enum) et renvoyer des erreurs de validation claires.
-
Assurer des defaults stables (par ex. toujours ajouter un tri “tie-breaker” sur
Id).
TL;DR
- Ne construisez jamais un tri/filtre à partir de noms de champs bruts envoyés par le client.
- Whitelistez les clés et mappez-les vers des opérations de requête sûres.
- Appliquez le tri via des delegates pour éviter le boxing.
- Appliquez les filtres via des delegates plutôt que d’entretenir des chaînes if/else.