Skip to content
← Tous les articles
· Delta1 Labs ObfuscationNebula

Obfusquer une configuration fortement typée sans casser la liaison d'IOptions<T>

Le lieur de configuration associe les clés d'appsettings.json et des variables d'environnement aux noms des propriétés publiques de votre POCO d'options par réflexion. Renommez ces propriétés lors d'une passe d'obfuscation et les valeurs liées deviennent silencieusement null ou leur valeur par défaut à l'exécution : aucune erreur de compilation, aucune exception, juste un comportement erroné. Cet article détaille comment `services.Configure<T>` résout les clés vers les setters des propriétés, ce que change le lieur généré par source de .NET 8+ et ce qu'il ne change pas, comment construire une keep-list dans nebula.config.json qui épingle les noms des propriétés publiques et le constructeur sans paramètre de vos classes d'options tout en renommant le reste, et comment confirmer dans Glass.NET que la surface d'options a survécu.

Une classe d”options fortement typée ressemble au C# le plus ordinaire de votre solution : un POCO avec des propriétés publiques et rien d”astucieux. Mais dès que vous la passez à services.Configure<T>, quelques-uns de ses noms cessent d”être des noms C# et deviennent des clés à l”extérieur de votre programme : des chaînes dans appsettings.json, des chaînes dans les variables d”environnement, des chaînes sur la ligne de commande. Le lieur de configuration relie ces chaînes aux noms de vos propriétés par réflexion, à l”exécution, et le compilateur n”en est jamais témoin. Cela fait des noms de propriétés précisément le genre de surface déterminante qu”une passe de renommage aveugle détruit.

La panne est du type silencieux. Renommez MaxRetries en a et votre application compile encore, démarre encore, s”exécute encore. Mais le lieur, cherchant une propriété nommée MaxRetries pour recevoir la valeur venant de la configuration, ne trouve rien, et laisse donc la propriété à sa valeur par défaut. Aucune exception n”est levée, car une clé de configuration sans correspondance n”est pas une erreur. Vous livrez un objet d”options rempli de zéros et de null, et une politique de nouvelle tentative qui ne retente jamais.

appsettings.json"ConnectionString""MaxRetries""TimeoutSeconds"les clés sont des chaînesà l'extérieurLIEUR DE CONFIG.Activator.CreateInstancepuis GetProperties()apparie chaque clé à unepropriété PAR NOMréflexion, sans contrôleCONSERVER CES NOMSConnectionStringMaxRetries, TimeoutSeconds() constructeur sans param.lié → épingler exactementLIBRE À RENOMMERle nom de la classechamps privés, auxiliairesservices consommateurs, logiquejamais lu comme une clé

Comment le lieur apparie une clé à une propriété

Partez d”une classe d”options ordinaire et de la section d”où elle se lie :

public sealed class EmailOptions
{
    public string ConnectionString { get; set; } = "";
    public int MaxRetries { get; set; } = 3;
    public int TimeoutSeconds { get; set; } = 30;
    public bool UseTls { get; set; } = true;
}
{
  "Email": {
    "ConnectionString": "smtp://mail.internal:587",
    "MaxRetries": 5,
    "TimeoutSeconds": 15,
    "UseTls": true
  }
}

Vous reliez les deux dans le point de composition :

builder.Services.Configure<EmailOptions>(
    builder.Configuration.GetSection("Email"));

Notez quelle chaîne vit où. Le nom de la section — "Email" — est un littéral de chaîne dans votre code, passé à GetSection. Nebula laisse les littéraux de chaîne intacts par défaut (et les chiffre plutôt que de les renommer quand encryptStrings est activé), si bien que le nom de la section est rarement la partie fragile. La partie fragile est l”autre extrémité : les clés à l”intérieur de la section — ConnectionString, MaxRetries — qui n”existent que dans appsettings.json et doivent correspondre à des noms de propriété qui n”existent que dans votre assembly compilé.

Le lieur par réflexion comble cet écart à l”exécution. Conceptuellement, ConfigurationBinder.Bind fait l”équivalent de ceci :

// Simplified from Microsoft.Extensions.Configuration.ConfigurationBinder.
object instance = Activator.CreateInstance(typeof(EmailOptions))!; // needs a parameterless ctor
foreach (PropertyInfo prop in typeof(EmailOptions)
             .GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
    if (!prop.CanWrite) continue;
    IConfigurationSection child = section.GetSection(prop.Name); // key == property NAME
    if (child.Value is not null)
        prop.SetValue(instance, Convert(child.Value, prop.PropertyType));
}

Deux choses sont désormais déterminantes. D”abord, Activator.CreateInstance a besoin d”un constructeur sans paramètre accessible. Ensuite, chaque clé est appariée à prop.Name — le nom réfléchi en direct de la propriété. Renommez la propriété et prop.Name change avec elle ; la clé dans appsettings.json, non. La correspondance échoue, SetValue n”est jamais appelé et la propriété conserve sa valeur par défaut du CLR. Le compilateur n”intervient dans rien de tout cela, c”est pourquoi la compilation reste au vert tandis que le comportement se dégrade.

Consommer les options est identique quoi qu”il arrive :

public sealed class EmailSender(IOptions<EmailOptions> options)
{
    private readonly EmailOptions _cfg = options.Value;

    public async Task SendAsync(/* ... */)
    {
        for (int attempt = 0; attempt <= _cfg.MaxRetries; attempt++) { /* ... */ }
    }
}

Si MaxRetries s”est lié à sa valeur par défaut de 0 parce que la clé ne correspondait plus, SendAsync fait exactement une tentative et abandonne — un défaut qui n”affleure jamais dans un test unitaire construisant EmailOptions directement, seulement en production où la valeur arrive par la configuration.

Le lieur généré par source change le mécanisme, pas la dépendance

.NET 8 a introduit un générateur de source du lieur de configuration que vous activez via une propriété de projet :

<PropertyGroup>
  <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>

Avec lui activé, l”appel Configure<EmailOptions> est intercepté à la compilation et la réflexion ci-dessus est remplacée par du code généré et fortement typé — bon pour Native AOT et le trimming, puisqu”il n”y a plus de parcours par réflexion à préserver. Mais regardez ce que le générateur émet :

// Generated binder (shape). The key is a string LITERAL taken from the property name.
if (section["ConnectionString"] is string s0) instance.ConnectionString = s0;
if (section["MaxRetries"] is string s1) instance.MaxRetries = ParseInt(s1);

La clé "MaxRetries" est figée dans le code généré sous forme de littéral, dérivé du nom de la propriété à la compilation. L”obfuscation postérieure à la compilation renomme le jeton de la propriété et l”affectation générée ensemble — ils restent cohérents — mais le littéral de clé figé et votre appsettings.json doivent encore concorder, et ce littéral reste le nom d”origine MaxRetries. Le générateur de source rogne donc la réflexion sans supprimer l”exigence que la clé de configuration soit égale au nom de la propriété. Vous épinglez toujours les noms ; vous obtenez en plus la compatibilité AOT. (Et comme d”autres chemins — validation, diagnostics d”IConfiguration, suivi des changements d”options — peuvent encore lire le vrai nom par réflexion, conserver les noms est le choix sûr par défaut avec l”un ou l”autre lieur.)

Valider que la liaison a réellement eu lieu

Il vaut la peine de refermer le problème de la panne silencieuse aussi du côté de l”application, indépendamment de l”obfuscation. La validation des options transforme une valeur silencieusement non liée en un échec bruyant au démarrage :

public sealed class EmailOptions
{
    [Required] public string ConnectionString { get; set; } = "";
    [Range(1, 10)] public int MaxRetries { get; set; } = 3;
    [Range(1, 120)] public int TimeoutSeconds { get; set; } = 30;
    public bool UseTls { get; set; } = true;
}
builder.Services.AddOptions<EmailOptions>()
    .Bind(builder.Configuration.GetSection("Email"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Pour les règles que les attributs ne peuvent exprimer, implémentez IValidateOptions<T> — ou utilisez le générateur de source [OptionsValidator], qui produit le validateur pour vous sans réflexion :

[OptionsValidator]
public sealed partial class ValidateEmailOptions : IValidateOptions<EmailOptions> { }

Avec ValidateOnStart, un MaxRetries revenu à 0 parce que sa clé ne s”est pas liée échoue désormais à [Range(1, 10)] et l”hôte refuse de démarrer avec un message précis, au lieu de s”exécuter avec une politique de nouvelle tentative cassée. Cela transforme précisément la régression d”obfuscation dont traite cet article en un échec rapide au démarrage — un filet de sécurité bon marché par-dessus une keep-list correcte, pas un substitut de celle-ci.

La keep-list dans nebula.config.json

Nebula ne devine pas quelles propriétés sont liées depuis la configuration. Vous déclarez les types d”options comme des règles keep cantonnées à leur surface publique, et vous laissez un attrape-tout renommer tout le reste :

{
  "schemaVersion": 1,
  "inputs": [ "bin/Release/net8.0/MyApp.dll" ],
  "outputDirectory": "protected",

  "encryptStrings": true,
  "controlFlowObfuscation": true,

  "renaming": {
    "rules": [
      { "pattern": "MyApp.Configuration.*Options", "action": "keep", "members": "public" },
      { "pattern": "MyApp.*", "action": "rename" }
    ]
  }
}

La première règle épingle tous les types d”options sous MyApp.Configuration et leurs membres publics — les propriétés liées ConnectionString, MaxRetries et les autres conservent leurs noms, et comme un constructeur public sans paramètre fait partie de la surface publique, il est préservé lui aussi, de sorte qu”Activator.CreateInstance a toujours un constructeur à appeler. Les champs privés, l”état de stockage et tout auxiliaire privé à l”intérieur du type d”options se renomment encore, car members: "public" cantonne la conservation à la surface publique. Le nom de la classe lui-même est public ici, donc il est conservé ; si vous préférez laisser le nom du type se renommer, ne conservez que les propriétés en appariant les membres explicitement — le lieur ne lit jamais le nom du type, seulement les noms des propriétés et le constructeur.

L”attrape-tout rename second balaie tout ce qui se trouve derrière la surface d”options — les services qui injectent IOptions<EmailOptions>, les corps de vos validateurs, la logique métier, les internes des DTO et le câblage de l”injection de dépendances — vers le renommage complet, en plus du chiffrement des chaînes et de l”obfuscation du flux de contrôle. C”est le code qui mérite d”être protégé, et rien de tout cela n”est lu par nom depuis l”extérieur.

Pour des options liées par constructeur (records, ou un type doté d”un unique constructeur non par défaut), le lieur apparie les noms des paramètres du constructeur aux clés, alors épinglez-les aussi — conserver les membres publics du record couvre ses propriétés positionnelles, et Nebula préserve les noms de paramètre sur un membre conservé.

Si vous préférez annoter le type exact plutôt que d”apparier un motif d”espace de noms, l”attribut standard [Obfuscation] vit à côté du code :

[Obfuscation(Feature = "renaming", Exclude = true, ApplyToMembers = true)]
public sealed class EmailOptions { /* ... */ }

Exécutez la protection dans le cadre de la compilation de publication, jamais sur les machines des développeurs :

<Target Name="NebulaProtect" AfterTargets="Publish"
        Condition="'$(CI)' == 'true'">
  <Exec Command="nebula protect --config nebula.config.json" />
</Target>

Ou invoquez la CLI directement contre une compilation publiée :

nebula protect --config nebula.config.json

Vérifier la surface avec Glass.NET

Une keep-list est une affirmation sur les noms qui ont survécu ; vérifiez l”affirmation plutôt que de lui faire confiance. Ouvrez l”assembly protégé dans Glass.NET, le décompilateur .NET gratuit de Delta1 Labs, et regardez deux choses.

D”abord, la surface liée. Votre type EmailOptions devrait encore exposer des propriétés publiques nommées ConnectionString, MaxRetries, TimeoutSeconds et UseTls, avec un constructeur public sans paramètre présent. Ce sont les noms exacts auxquels vos clés d”appsettings.json correspondent ; si l”un d”eux est ressorti renommé en a, le lieur laissera cette valeur à sa valeur par défaut en production et votre motif de keep-list l”a manqué.

Ensuite, tout ce qui se trouve derrière la surface. Les services qui consomment les options, les corps des validateurs, vos auxiliaires privés et les internes des DTO devraient tous afficher des identifiants renommés et — avec encryptStrings activé — des constantes de chaîne opaques. C”est la protection que vous êtes venu chercher, intacte précisément sur le code qui mérite d”être protégé.

La règle sous-jacente à tout cela est celle que tout framework piloté par réflexion respecte : un nom est déterminant exactement quand quelque chose en dehors de la résolution de jetons propre au CLR le lit comme une chaîne. Pour la configuration fortement typée, cet ensemble est petit et connaissable — les noms des propriétés publiques de vos POCO d”options, plus un constructeur sans paramètre — et une fois que vous avez épinglé précisément ceux-là, Nebula renomme les consommateurs, les validateurs et les internes librement, protégeant votre propre application livrée sans jamais déplacer un nom dont dépend le lieur de configuration.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.