Ofuscar configuración fuertemente tipada sin romper el enlace de IOptions<T>
El enlazador de configuración asigna las claves de appsettings.json y de las variables de entorno a los nombres de las propiedades públicas de tu POCO de opciones mediante reflexión. Renombra esas propiedades en un paso de ofuscación y los valores enlazados pasan silenciosamente a ser null o el valor por defecto en tiempo de ejecución: sin error de compilación, sin excepción, solo comportamiento incorrecto. Este artículo explica cómo `services.Configure<T>` resuelve las claves a los setters de las propiedades, qué cambia el enlazador generado por fuente de .NET 8+ y qué no, cómo construir una keep-list en nebula.config.json que fije los nombres de las propiedades públicas y el constructor sin parámetros de tus clases de opciones mientras renombra todo lo demás, y cómo confirmar en Glass.NET que la superficie de opciones sobrevivió.
Una clase de opciones fuertemente tipada parece el C# más corriente de tu solución: un POCO con propiedades públicas y nada ingenioso. Pero en cuanto se lo pasas a services.Configure<T>, algunos de sus nombres dejan de ser nombres de C# y se convierten en claves en el exterior de tu programa: cadenas en appsettings.json, cadenas en variables de entorno, cadenas en la línea de comandos. El enlazador de configuración une esas cadenas con los nombres de tus propiedades por reflexión, en tiempo de ejecución, y el compilador nunca lo presencia. Eso convierte los nombres de las propiedades justamente en el tipo de superficie determinante que un paso de renombrado ciego destruye.
El fallo es del tipo silencioso. Renombra MaxRetries a a y tu aplicación sigue compilando, sigue arrancando, sigue ejecutándose. Pero el enlazador, buscando una propiedad llamada MaxRetries para recibir el valor de la configuración, no encuentra nada, así que deja la propiedad en su valor por defecto. No se lanza ninguna excepción, porque una clave de configuración sin coincidencia no es un error. Entregas un objeto de opciones lleno de ceros y nulos, y una política de reintentos que nunca reintenta.
Cómo empareja el enlazador una clave con una propiedad
Parte de una clase de opciones corriente y la sección desde la que se enlaza:
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
}
}
Conectas las dos cosas en el punto de composición:
builder.Services.Configure<EmailOptions>(
builder.Configuration.GetSection("Email"));
Fíjate en qué cadena vive dónde. El nombre de la sección — "Email" — es un literal de cadena en tu código, pasado a GetSection. Nebula deja los literales de cadena intactos por defecto (y los cifra en lugar de renombrarlos cuando encryptStrings está activado), así que el nombre de la sección rara vez es la parte frágil. La parte frágil es el otro extremo: las claves dentro de la sección — ConnectionString, MaxRetries — que existen solo en appsettings.json y deben coincidir con nombres de propiedad que existen solo en tu ensamblado compilado.
El enlazador por reflexión cierra ese hueco en tiempo de ejecución. Conceptualmente, ConfigurationBinder.Bind hace el equivalente a esto:
// 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));
}
Ahora hay dos cosas determinantes. Primero, Activator.CreateInstance necesita un constructor sin parámetros accesible. Segundo, cada clave se empareja con prop.Name — el nombre reflejado en vivo de la propiedad. Renombra la propiedad y prop.Name cambia con ella; la clave en appsettings.json no. La coincidencia falla, SetValue nunca se llama y la propiedad conserva su valor por defecto del CLR. El compilador no participa en nada de esto, por lo que la compilación sigue en verde mientras el comportamiento se vuelve incorrecto.
Consumir las opciones es igual sin importar nada:
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 se enlazó a su valor por defecto de 0 porque la clave ya no coincidía, SendAsync hace exactamente un intento y se rinde — un defecto que nunca aflora en una prueba unitaria que construye EmailOptions directamente, solo en producción donde el valor llega a través de la configuración.
El enlazador generado por fuente cambia el mecanismo, no la dependencia
.NET 8 introdujo un generador de fuente del enlazador de configuración que activas con una propiedad de proyecto:
<PropertyGroup>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
Con él activado, la llamada Configure<EmailOptions> se intercepta en tiempo de compilación y la reflexión anterior se reemplaza por código generado y fuertemente tipado — bueno para Native AOT y el trimming, ya que ya no hay un recorrido por reflexión que preservar. Pero mira lo que emite el generador:
// 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 clave "MaxRetries" queda congelada en el código generado como un literal, derivado del nombre de la propiedad en tiempo de compilación. La ofuscación posterior a la compilación renombra el token de la propiedad y la asignación generada juntos — se mantienen coherentes — pero el literal de la clave fijado y tu appsettings.json todavía tienen que coincidir, y ese literal sigue siendo el nombre original MaxRetries. Así que el generador de fuente recorta la reflexión sin eliminar el requisito de que la clave de configuración sea igual al nombre de la propiedad. Sigues fijando los nombres; solo que además obtienes compatibilidad con AOT. (Y como otras rutas — validación, diagnósticos de IConfiguration, seguimiento de cambios de opciones — pueden seguir leyendo el nombre real por reflexión, conservar los nombres es lo seguro por defecto con cualquiera de los dos enlazadores.)
Validar que el enlace realmente ocurrió
Vale la pena cerrar el problema del fallo silencioso también desde el lado de la aplicación, con independencia de la ofuscación. La validación de opciones convierte un valor silenciosamente no enlazado en un fallo ruidoso al arrancar:
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();
Para reglas que los atributos no pueden expresar, implementa IValidateOptions<T> — o usa el generador de fuente [OptionsValidator], que produce el validador por ti sin reflexión:
[OptionsValidator]
public sealed partial class ValidateEmailOptions : IValidateOptions<EmailOptions> { }
Con ValidateOnStart, un MaxRetries que volvió como 0 porque su clave no se enlazó ahora falla [Range(1, 10)] y el host se niega a arrancar con un mensaje preciso, en lugar de ejecutarse con una política de reintentos rota. Eso convierte justamente la regresión de ofuscación de la que trata este artículo en un fallo rápido al arrancar — una red de seguridad barata sobre una keep-list correcta, no un sustituto de ella.
La keep-list en nebula.config.json
Nebula no adivina qué propiedades se enlazan desde la configuración. Tú declaras los tipos de opciones como reglas keep acotadas a su superficie pública, y dejas que un comodín general renombre todo lo demás:
{
"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 primera regla fija todos los tipos de opciones bajo MyApp.Configuration y sus miembros públicos — las propiedades enlazadas ConnectionString, MaxRetries y las demás conservan sus nombres, y como un constructor público sin parámetros forma parte de la superficie pública, también se preserva, de modo que Activator.CreateInstance todavía tiene un constructor que llamar. Los campos privados, el estado de respaldo y cualquier auxiliar privado dentro del tipo de opciones aún se renombran, porque members: "public" acota la conservación a la superficie pública. El nombre de la clase en sí es público aquí, así que se conserva; si prefieres dejar que el nombre del tipo se renombre, conserva solo las propiedades emparejando los miembros explícitamente — el enlazador nunca lee el nombre del tipo, solo los nombres de las propiedades y el constructor.
El comodín rename segundo barre todo lo que está detrás de la superficie de opciones — los servicios que inyectan IOptions<EmailOptions>, los cuerpos de tus validadores, la lógica de negocio, los internos de los DTO y el cableado de DI — hacia el renombrado completo, además del cifrado de cadenas y la ofuscación del flujo de control. Ese es el código que vale la pena proteger, y nada de él se lee por nombre desde fuera.
Para opciones enlazadas por constructor (registros, o un tipo con un único constructor no predeterminado), el enlazador empareja los nombres de los parámetros del constructor con las claves, así que fíjalos también — conservar los miembros públicos del registro cubre sus propiedades posicionales, y Nebula preserva los nombres de parámetro en un miembro conservado.
Si prefieres anotar el tipo exacto en lugar de emparejar un patrón de espacio de nombres, el atributo estándar [Obfuscation] vive junto al código:
[Obfuscation(Feature = "renaming", Exclude = true, ApplyToMembers = true)]
public sealed class EmailOptions { /* ... */ }
Ejecuta la protección como parte de la compilación de publicación, nunca en las máquinas de los desarrolladores:
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
O invoca la CLI directamente contra una compilación publicada:
nebula protect --config nebula.config.json
Verificar la superficie con Glass.NET
Una keep-list es una afirmación sobre qué nombres sobrevivieron; comprueba la afirmación en lugar de confiar en ella. Abre el ensamblado protegido en Glass.NET, el decompilador .NET gratuito de Delta1 Labs, y mira dos cosas.
Primero, la superficie enlazada. Tu tipo EmailOptions debería seguir exponiendo propiedades públicas llamadas ConnectionString, MaxRetries, TimeoutSeconds y UseTls, con un constructor público sin parámetros presente. Esos son los nombres exactos con los que coinciden las claves de tu appsettings.json; si alguno salió renombrado a a, el enlazador dejará ese valor en su valor por defecto en producción y tu patrón de keep-list lo pasó por alto.
Segundo, todo lo que está detrás de la superficie. Los servicios que consumen las opciones, los cuerpos de los validadores, tus auxiliares privados y los internos de los DTO deberían mostrar todos identificadores renombrados y — con encryptStrings activado — constantes de cadena opacas. Esa es la protección que buscabas, intacta exactamente sobre el código que vale la pena proteger.
La regla que subyace a todo esto es la que obedece todo framework guiado por reflexión: un nombre es determinante exactamente cuando algo fuera de la propia resolución de tokens del CLR lo lee como una cadena. Para la configuración fuertemente tipada ese conjunto es pequeño y conocible — los nombres de las propiedades públicas de tus POCOs de opciones, más un constructor sin parámetros — y una vez que has fijado precisamente esos, Nebula renombra los consumidores, los validadores y los internos sin problemas, protegiendo tu propia aplicación entregada sin mover nunca un nombre del que depende el enlazador de configuración.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.