Prevent accidental disclosure of configuration secrets

 
 
  • Gérald Barré

An application often uses secrets to access databases or external services. The secrets are usually provided using environment variables, configuration files, or Vault (Azure Vault, Google Secret Manager, etc.). These secrets are often bound as string making it easy to accidentally disclose. For example, a secret can be accidentally logged or part of a json serialization. Also, it's harder to know where are the secrets used in the code when everything is of type string.

C#
class MyDbConfiguration
{
    // 1. It's easy to accidentally disclose the value of this property
    // 2. It's hard to know this string is a secret
    public string ConnectionString { get; set; }
}

Most of the time the secret is provided in clear text (environment variables, configuration file), so there is no need to secure the secret in-memory. The goal is to make it harder for developers to accidentally disclose the secret, not to protect it. A solution is to create a simple wrapper for the string value and not expose any property to avoid exposing the secret when serializing data. Also, the wrapper should not implement ToString to avoid accidental disclosure. Most .NET applications rely on the Microsoft.Extensions.Configuration package for the configuration. It can bind a configuration value to any type as long as a compatible TypeConverter.

Let's create the Secret class and its TypeConverter:

C#
[TypeConverter(typeof(ConfigurationSecretTypeConverter))]
internal sealed class Secret
{
    // Do not use System.String as some serializers can serialize fields.
    // At the moment, System.Text.Json does not support ReadOnlyMemory<char>, so it cannot be serialized.
    private readonly ReadOnlyMemory<char> _data;

    public Secret(string value)
    {
        ArgumentException.ThrowIfNullOrEmpty(value);

        _data = value.ToCharArray();

        // If you want to prevent the string to from being moved in memory, and so, copied multiple times in memory,
        // you can use the Pinned Heap: https://github.com/dotnet/runtime/blob/main/docs/design/features/PinnedHeap.md
        // var data = GC.AllocateUninitializedArray<char>(value.Length, pinned: true);
        // value.AsSpan().CopyTo(data);
        // _data = data;

        // Also, if you want something more secure, you can look at the Microsoft.AspNetCore.DataProtection.Secret. But it may be harder
        // to use with the Microsoft.Extensions.Configuration package. Indeed, Secret is a disposable object, but using IOptions<T> will
        // not dispose the object, so you need to take care of disposing it yourself.
        // https://github.com/dotnet/aspnetcore/blob/ea683686bfac765690cb6e40f6ba7198cae26e65/src/DataProtection/DataProtection/src/Secret.cs
    }

    public string Reveal() => string.Create(_data.Length, _data, (span, data) => data.Span.CopyTo(span));

    [Obsolete($"Use {nameof(Reveal)} instead")]
    public override string? ToString() => base.ToString();

    /// <summary>
    /// Allow Microsoft.Extensions.Configuration to instantiate the <seealso cref="Secret" />
    /// from a string.
    /// </summary>
    private sealed class ConfigurationSecretTypeConverter : TypeConverter
    {
        public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
            => sourceType == typeof(string);

        public override bool CanConvertTo(ITypeDescriptorContext? context, [NotNullWhen(true)] Type? destinationType)
            => false;

        public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
            => throw new InvalidOperationException();

        public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
            => value is string { Length: > 0 } str ? new Secret(str) : null;
    }
}

Here's an example of the Secret class in action:

C#
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
    ["UserName"] = "john",
    ["Password"] = "Pa$$w0rd",
});

builder.Services.Configure<SampleOptions>(builder.Configuration);
await using var app = builder.Build();

var configuration = app.Services.GetRequiredService<IOptions<SampleOptions>>().Value;
Console.WriteLine(configuration.UserName);

 // 👇 Use the Reveal method to get the actual password
Console.WriteLine(configuration.Password?.Reveal());

class SampleOptions
{
    public string? UserName { get; set; }

     // 👇 Use the Secret type
    public Secret? Password { get; set; }
}

You can also add some tests to make sure the Secret class is working as expected:

C#
public sealed class SecretTests
{
    [Fact]
    public void RevealToString()
    {
        var secret = new Secret("foo");
        Assert.Equal("foo", secret.Reveal());
        Assert.NotEqual("foo", secret.ToString());
    }

    [Fact]
    public void SystemTestJsonDoesNotRevealValue()
    {
        var secret = new Secret("foo");
        var json = JsonSerializer.Serialize(secret);
        Assert.Equal("{}", json);
    }

    [Fact]
    public void SystemTestJsonDoesNotRevealValue_Field()
    {
        var secret = new Secret("foo");
        var options = new JsonSerializerOptions { IncludeFields = true };
        var json = JsonSerializer.Serialize(secret, options);
        Assert.Equal("{}", json);
    }

    [Fact]
    public void NewtonsoftJsonDoesNotRevealValue()
    {
        var secret = new Secret("foo");
        var json = Newtonsoft.Json.JsonConvert.SerializeObject(secret);
        Assert.Equal("{}", json);
    }

    [Fact]
    public void CanConvertFromString()
    {
        var secret = (Secret)TypeDescriptor.GetConverter(typeof(Secret)).ConvertFromString("foo");
        Assert.Equal("foo", secret.Reveal());
    }

    [Fact]
    public void TypeConverterToStringDoesNotRevealValue()
    {
        var secret = new Secret("foo");
        Assert.Throws<InvalidOperationException>(() => TypeDescriptor.GetConverter(typeof(Secret)).ConvertToString(secret));
    }

    [Fact]
    public async Task CanBindConfiguration()
    {
        var builder = WebApplication.CreateBuilder();
        builder.WebHost.UseSetting("Password", "Pa$$w0rd");
        builder.Services.Configure<SampleOptions>(builder.Configuration);
        await using var app = builder.Build();
        var configuration = app.Services.GetRequiredService<IOptions<SampleOptions>>().Value;

        Assert.Equal("Pa$$w0rd", configuration.Password.Reveal());
    }

    private class SampleOptions
    {
        public Secret? Password { get; set; }
    }
}

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub