Предотвращаем случайное раскрытие секретов конфигураций

Предотвращаем случайное раскрытие секретов конфигураций
Photo by Michael Dziedzic / Unsplash

В приложениях часто используются секреты (пароли, токены от API и так далее) для доступа к базам данных или внешним службам. Секреты обычно предоставляются с помощью переменных окружения, конфигурационного файла или хранилища (Azure Vault, Google Secret Manager и т.д.). Эти секреты часто связаны в виде строки, что облегчает их случайное раскрытие. Например, секрет может быть случайно записан в лог или стать частью сериализации json. Кроме того, сложнее определить, где в коде используются секретные данные, если все имеет тип string.

class MyDbConfiguration
{
    // 1. легко случайно раскрыть значение этого свойства
    // 2. Трудно понять, что эта строка является секретом
    public string ConnectionString { get; set; }
}

В большинстве случаев секретные данные предоставляются в открытом виде (переменные окружения, конфигурационный файл), поэтому нет необходимости защищать его в памяти. Цель состоит в том, чтобы усложнить разработчикам случайное раскрытие секретных данных, а не в том, чтобы защитить их. Решение состоит в том, чтобы создать простую обертку для строкового значения и не раскрывать никаких свойств, чтобы избежать раскрытия важных данных при сериализации . Также обертка не должна реализовывать ToString, чтобы избежать случайного раскрытия. Большинство приложений .NET полагаются на пакет Microsoft.Extensions.Configuration для конфигурации. Он может привязать значение конфигурации к любому типу при условии наличия совместимого TypeConverter`а.

Давайте создадим для наших секретных данных класс Secret и конвертер типа для него:

[TypeConverter(typeof(ConfigurationSecretTypeConverter))]
internal sealed class Secret
{
    // Не используйте System.String, так как некоторые сериализаторы могут сериализовать поля.
    // На данный момент System.Text.Json не поддерживает ReadOnlyMemory<char>, поэтому его нельзя сериализовать.
    private readonly ReadOnlyMemory<char> _data;

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

        _data = value.ToCharArray();

        // Если вы хотите предотвратить перемещение строки в памяти и, соответственно, ее многократное копирование в память,
        // вы можете использовать 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;

        // Также, если вы хотите что-то более безопасное, вы можете посмотреть на Microsoft.AspNetCore.DataProtection.Secret. Но это может быть сложнее
        // использовать с пакетом Microsoft.Extensions.Configuration. Действительно, Secret является одноразовым объектом, но использование IOptions<T> позволит // не утилизировать объект.
        // не утилизирует объект, поэтому вам нужно позаботиться о его утилизации самостоятельно.
        // 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>
    /// Разрешает Microsoft.Extensions.Configuration инстанцировать  <seealso cref="Secret" />
    /// из строки.
    /// </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;
    }
}

Вот пример класса Secret в действии:

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);

 // 👇 Используйте метод Reveal для получения фактического пароля
Console.WriteLine(configuration.Password?.Reveal());

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

     // 👇 Используйте тип Secret
    public Secret? Password { get; set; }
}

Вы также можете добавить несколько тестов, чтобы убедиться, что класс Secret работает так, как ожидается:

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; }
    }
}

Оригинальную статью можно прочитать по ссылке.