Предотвращаем случайное раскрытие секретов конфигураций
В приложениях часто используются секреты (пароли, токены от 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; }
}
}
Оригинальную статью можно прочитать по ссылке.