Obfusquer une application ASP.NET Core : contrôleurs, DI, liaison de modèle et ce qui doit rester public
ASP.NET Core trouve presque tout par son nom à l'exécution : les types de contrôleur par leur suffixe, les actions par le nom de méthode, les routes depuis les jetons [controller] et [action], les paramètres de query et de formulaire par les noms de paramètre, les propriétés JSON par les noms de propriété, les méthodes de hub par des chaînes, les classes d'options par les clés de configuration. Renommez n'importe lequel et l'application compile toujours, démarre toujours et renvoie 404 ou un DTO vide. Cet article cartographie chaque couture pilotée par réflexion d'une application ASP.NET Core — MVC, Minimal APIs, vues et tag helpers Razor, SignalR, le pattern options, services hébergés —, montre à quoi ressemblent réellement les métadonnées renommées, et construit un nebula.config.json qui renomme et durcit les services et le domaine tout en épinglant exactement la surface que le framework observe.
ASP.NET Core est bâti sur la réflexion d”une manière dont la plupart des frameworks applicatifs ne le sont pas. Il ne se contente pas d”utiliser la réflexion pour construire des choses ; il utilise des noms — noms de type, de méthode, de paramètre, de propriété — comme source de vérité pour le routage, la liaison, la sérialisation et la configuration. Cela en fait le pire cas pour un obfuscateur qui renomme agressivement, et un cas très instructif pour un qui renomme avec précision, car la couture entre « ce que le framework lit par nom » et « ce que personne hors du processus ne voit jamais » est nette et bien définie une fois qu”on sait où regarder.
Cet article parcourt toute la couture. Pour chaque fonctionnalité du framework, nous montrons ce qu”elle découvre par nom, ce qu”un renommage naïf lui fait, et quoi épingler. Puis nous assemblons un nebula.config.json qui laisse tranquille la surface observée et durcit tout ce qu”il y a derrière — les services, le domaine, la vérification de licence, la logique d”intégration — là où vit réellement le code qui vaut d”être protégé.
MVC : les contrôleurs et les actions sont trouvés par nom et routés par nom
Un contrôleur MVC est découvert parce que son type est public, non abstrait, et soit se termine par Controller, soit porte [Controller]/[ApiController]. Ses actions sont découvertes parce que ce sont des méthodes d”instance publiques. Jusque-là, ce n”est que de la visibilité, que le renommage préserve. Le problème est ce qui se passe ensuite : le framework construit les routes à partir des noms.
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orders;
public OrdersController(IOrderService orders) => _orders = orders;
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> GetById(int id) => ...;
[HttpGet("[action]")]
public async Task<ActionResult<PagedResult<OrderDto>>> Search([FromQuery] int page = 1,
[FromQuery] string? customer = null) => ...;
}
Le modèle de route api/[controller] est résolu au démarrage en prenant le nom du type de contrôleur, en retirant le suffixe Controller, et en substituant : api/Orders. Le jeton [action] substitue le nom de méthode : api/Orders/Search. Les deux substitutions se produisent à la construction de l”ApplicationModel à partir de TypeInfo.Name et MethodInfo.Name — de pures lectures de métadonnées. Renommez le type en b et la méthode en c et les URLs deviennent api/b et api/b/c. L”application démarre proprement. Chaque client obtient 404.
Les paramètres [FromQuery] ont le même problème un cran plus bas. Sans Name explicite, le lieur utilise le nom de métadonnées du paramètre : ?page=2&customer=acme. Un obfuscateur qui réécrit les noms de paramètre casse la liaison en silence — le paramètre reste simplement à sa valeur par défaut, qui pour page est 1 et pour customer est null, et personne ne voit d”erreur.
Trois choses maintiennent cela en état de marche, et vous devriez faire les trois indépendamment de l”obfuscation car elles rendent aussi votre contrat d”API explicite :
- Préférez des modèles de route explicites aux jetons :
[Route("api/orders")],[HttpGet("search")]. Une chaîne littérale dans un attribut n”est pas un nom que l”obfuscateur voit comme un membre ; elle survit. - Nommez vos paramètres liés là où le nom fait partie du contrat :
[FromQuery(Name = "page")]. Même raisonnement. - Gardez les noms de type et d”action des contrôleurs avec une règle, pour les endroits où vous n”avez pas fait 1 et 2.
preservePublicApile couvre pour les contrôleurs publics ; une règle de motif couvre les internes.
Le renommage de Nebula réécrit les types et les membres. Il laisse en place les noms de paramètre de méthode, donc l”étape 2 est une défense en profondeur plutôt qu”une nécessité — mais vous la voulez quand même, car le jour où un collègue refactorise page en pageNumber, votre API publique change sans que personne ne le remarque, et un Name explicite est ce qui l”empêche.
Liaison de modèle et JSON : les noms de propriété sont le format sur le fil
Les noms de propriété d”un DTO sont son JSON. System.Text.Json construit son contrat à partir de PropertyInfo.Name (en camelCase par les options web par défaut), et la liaison des POST de formulaire et des chaînes de query utilise les mêmes noms. Renommez OrderDto.CustomerName en a et le corps de la réponse est {"a":"Acme"}.
public sealed class OrderDto
{
public int Id { get; set; }
public string CustomerName { get; set; } = "";
public decimal Total { get; set; }
}
Vous avez deux options correctes. La première est de décorer chaque propriété sérialisée avec [JsonPropertyName("customerName")], après quoi le nom du membre est sans importance pour le format sur le fil et peut être renommé librement. C”est aussi une bonne habitude : cela rend le contrat explicite et survit à tout renommage futur, d”obfuscateur ou d”humain. La seconde est de garder les noms de membre du DTO avec une règle. Pour une grande API, la règle est moins de frappe ; pour une petite, les attributs sont plus robustes. Dans les deux cas, ce qui ne doit pas arriver, c”est un DTO qui n”est ni attribué ni gardé.
Notez l”asymétrie avec le modèle de domaine. Votre entité Order, PricingPolicy, DiscountRule et le reste ne sont jamais sérialisés sur le fil — ils sont mappés en DTOs par du code, par accès aux propriétés que le compilateur a résolu en jetons de membre, que le renommage met à jour de façon cohérente. Ceux-là peuvent être renommés, et c”est là qu”est la logique précieuse.
Minimal APIs : lambdas, closures et les noms de paramètre du handler
Les Minimal APIs ont l”air de n”avoir aucun nom, et c”est à moitié vrai. Le handler est une lambda, que le compilateur abaisse en une méthode sur une classe de closure — <>c.<<Main>$>b__0_0 ou Program.<>c__DisplayClass0_0.<<Main>$>b__0 si elle a capturé quelque chose — et rien de ces noms n”importe au routage car la route est la chaîne littérale que vous avez passée à MapGet.
app.MapGet("/api/orders/search",
async (int page, string? customer, IOrderService orders, CancellationToken ct)
=> TypedResults.Ok(await orders.SearchAsync(page, customer, ct)));
Le RequestDelegateFactory du framework inspecte le MethodInfo de la lambda et, pour chaque paramètre, décide d”où vient la valeur : une valeur de route si le nom correspond à un jeton de modèle de route, un service si le type est enregistré, le corps si c”est un type complexe, et sinon la chaîne de query par nom de paramètre. Ainsi page et customer sont liés depuis ?page= et ?customer= purement à cause de la façon dont les paramètres de la lambda sont nommés, et orders est lié depuis la DI purement à cause de son type. Les noms de paramètre sont le contrat ; le nom de la classe de closure est du bruit.
Cela place les Minimal APIs dans une bonne position : aucun nom de type ou de membre n”est observé, seulement des noms de paramètre, que Nebula ne réécrit pas. Rendez-les explicites quand même — [FromQuery(Name = "page")] int page — pour la même raison de stabilité de contrat que ci-dessus, et laissez les classes de closure se renommer ; elles ont déjà des noms qu”aucun humain ne lit.
Vues et tag helpers Razor : la model expression lit MemberInfo.Name
Les vues Razor compilent en classes C# — dans .NET 6+ par défaut dans votre assembly principal sous AspNetCoreGeneratedDocument, chacune étiquetée [RazorCompiledItem(typeof(Views_Orders_Index), "mvc.1.0.view", "/Views/Orders/Index.cshtml")]. L”attribut référence le type par typeof, donc renommer la classe de vue est sûr : le jeton est mis à jour, la chaîne identifiante (le chemin) est ce que le moteur de vues cherche, et c”est un littéral.
Les tag helpers sont là où les noms fuient. Prenez un formulaire :
<form asp-action="Create" asp-controller="Orders" method="post">
<input asp-for="CustomerName" />
<span asp-validation-for="CustomerName"></span>
<input asp-for="Total" />
</form>
Trois choses se passent, chacune avec une réponse différente :
asp-action="Create" asp-controller="Orders"sont des chaînes littérales résolues contre la table de routes au rendu — la même table dont les entrées venaient des noms de contrôleur et d”action. Si ces noms ont été gardés (section précédente), cela marche ; s”ils ont été renommés, la génération de liens renvoie un attributactionvide et le formulaire POST vers l”URL courante.asp-for="CustomerName"compile enModelExpressionProvider.CreateModelExpression(ViewData, __model => __model.CustomerName). Au rendu, le tag helper parcourt cet arbre d”expression, litMemberInfo.Nameet émetname="CustomerName" id="CustomerName". Si la propriété est renommée ena, le HTML émis ditname="a"— et comme le formulaire POST vers un lieur de modèle qui utilise aussi le nom renommé, la liaison marche toujours. Ce qui casse, c”est tout ce qui référençait le champ depuis l”extérieur : votre JavaScript faisantdocument.getElementById("CustomerName"), votre CSS, vos tests de bout en bout et les règles de validation côté client deasp-validation-forà clé sur ce même nom.- Le modèle de vue lui-même est une frontière de type sérialisation : il est lu par nom via des expressions, et ses noms atteignent le navigateur.
La règle pratique : traitez les modèles de vue et de page exactement comme des DTOs. Gardez les noms de leurs membres (ou, pour Razor Pages, gardez les propriétés [BindProperty] du PageModel), renommez les classes de vue librement, et ne référencez jamais un type de domaine renommé depuis une vue — les vues devraient parler à des modèles de vue, ce que vous vouliez déjà.
SignalR : les méthodes de hub sont invoquées par chaîne
Un client SignalR appelle connection.invoke("PlaceOrder", dto) et le serveur résout "PlaceOrder" contre les méthodes publiques du hub par nom. La chaîne du client n”est pas touchée par l”obfuscateur ; la méthode du serveur, si. Renommez-la et chaque invocation échoue avec Method does not exist. Les noms client-vers-serveur doivent être gardés — preservePublicApi couvre les méthodes publiques de hub — et les appels serveur-vers-client (Clients.All.SendAsync("OrderUpdated", ...)) sont des chaînes littérales qui survivent d”elles-mêmes. Les hubs fortement typés (Hub<IOrderClient>) résolvent les noms de méthodes client à partir des noms de membre de l”interface, donc l”interface doit aussi être gardée.
Options, configuration et les choses que vous avez oublié être liées par nom
services.Configure<SmtpOptions>(config.GetSection("Smtp")) peuple SmtpOptions en faisant correspondre les clés de configuration aux noms de propriété via ConfigurationBinder, qui utilise PropertyInfo.Name avec une comparaison insensible à la casse. Renommez SmtpOptions.Host en a et la valeur liée est null. Rien ne lève ; vos e-mails cessent simplement de partir en production. Les classes d”options sont la couture la plus souvent oubliée dans l”obfuscation d”applications web car elles sont internal (donc preservePublicApi les ignore) et ressemblent à de simples POCOs sans rien qui mérite un attribut.
Il en va de même pour tout ce qui lit la configuration par convention : [FromKeyedServices("...")] avec un nameof, les noms de health-check dérivés de noms de type, les noms de catégorie de ILogger<T> (ceux-ci deviennent a.b dans vos logs — inoffensif mais laid ; la carte de renommage le résout) et les implémentations de IHostedService (enregistrées par type — sûres).
Tout assembler : la config
Voici un nebula.config.json pour une application web en couches typique : MyApp.Web (contrôleurs, DTOs, modèles de vue, hubs, options, démarrage), MyApp.Application (services), MyApp.Domain (entités et règles), MyApp.Infrastructure (EF Core, clients d”intégration). La couche web reçoit des keeps sélectifs ; les trois couches internes sont renommées et durcies complètement.
{
"schemaVersion": 1,
"inputs": [
"bin/Release/net8.0/publish/MyApp.Web.dll",
"bin/Release/net8.0/publish/MyApp.Application.dll",
"bin/Release/net8.0/publish/MyApp.Domain.dll",
"bin/Release/net8.0/publish/MyApp.Infrastructure.dll"
],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "MyApp.Web.Controllers.*", "action": "keep" },
{ "pattern": "MyApp.Web.Hubs.*", "action": "keep" },
{ "pattern": "MyApp.Web.Contracts.*", "action": "keep" },
{ "pattern": "MyApp.Web.ViewModels.*", "action": "keep" },
{ "pattern": "MyApp.Web.Pages.*Model", "action": "keep" },
{ "pattern": "MyApp.Web.Options.*", "action": "keep" },
{ "pattern": "AspNetCoreGeneratedDocument.*", "action": "rename" },
{ "pattern": "MyApp.Web.*", "action": "rename" },
{ "pattern": "MyApp.Application.*", "action": "rename" },
{ "pattern": "MyApp.Domain.*", "action": "rename" },
{ "pattern": "MyApp.Infrastructure.*", "action": "rename" }
]
},
"exclude": [
"MyApp.Infrastructure.Persistence.Entities.*"
]
}
En le lisant de haut en bas : preservePublicApi est désactivé, car dans une application web « public » est surtout un accident du modèle de projet et nous voulons être explicites. Les six premières règles épinglent exactement la surface que le framework observe — contrôleurs, hubs, contrats de requête/réponse, modèles de vue, modèles de page, options. Les documents Razor générés et tout le reste de la couche web sont renommés. Les trois assemblies internes sont renommées en bloc, avec chiffrement de chaînes et obfuscation de flux de contrôle appliqués à toutes — c”est là que vivent la porte de licence, le moteur de tarification et les secrets d”intégration. L”espace de noms des entités EF Core est exclu du renommage pour les raisons que parcourt l”article Entity Framework (le model builder et le snapshot de migrations référencent des membres par nom) ; tout le reste dans Infrastructure, y compris les clients d”intégration, est permis.
Si vous préférez les attributs aux motifs, [JsonPropertyName] sur chaque propriété de DTO et des modèles de route explicites sur chaque action vous permettent de supprimer entièrement les keeps Contracts et Controllers, et la config rétrécit en conséquence.
Vérifier la build protégée
Les tests unitaires n”attraperont rien de tout cela, car ils référencent le projet et n”exécutent jamais l”assembly obfusqué. WebApplicationFactory<Program> est pareil : il construit votre application en processus à partir de la référence du projet de test, pas depuis protected/. Ce qui l”attrape, c”est d”exécuter la sortie publiée et protégée comme un processus et de la piloter en HTTP.
# CI, after obfuscation
dotnet protected/MyApp.Web.dll --urls http://localhost:5199 --environment Smoke &
sleep 3
dotnet test tests/MyApp.Smoke --filter Category=Protected -- BaseUrl=http://localhost:5199
La suite de fumée est délibérément petite et délibérément ennuyeuse : une requête par route vérifiant le statut et la forme, un aller-retour JSON par DTO affirmant que chaque propriété est présente sous son nom attendu, un POST de formulaire par page Razor affirmant que le modèle a lié, une invocation de méthode de hub, et une assertion au démarrage que chaque classe d”options a lié une valeur non par défaut depuis la configuration de fumée. Exécutez-la contre la build protégée dans le même pipeline qui produit l”artefact, et le jeton de route renommé ou la classe d”options oubliée fait échouer la build plutôt que le déploiement d”un client. Combiné au fait de garder propres les builds de développement et de n”obfusquer que sur le serveur de build, c”est toute la boucle.
À retenir
ASP.NET Core lit des noms depuis une liste courte et bien définie d”endroits : jetons de route, noms de paramètre liés, noms de propriété sérialisés, noms de méthode de hub, expressions de modèle Razor et options liées depuis la configuration. Chacun est soit un littéral que vous pouvez rendre explicite, soit un membre que vous gardez avec une règle, et aucun n”est là où est votre code précieux. Épinglez cette surface, rendez le contrat explicite avec des attributs où vous pouvez, et laissez l”obfuscateur renommer, chiffrer et aplatir les services, le domaine, la vérification de licence et les intégrations derrière. Puis exécutez la build protégée, pas le code source, à travers une suite de fumée, car le framework ne vous dira pas ce qu”il n”a pas réussi à trouver — il renverra simplement 404.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.