diff --git a/src/core/StackExchange.Redis.Extensions.Core/Abstractions/IRedisDatabase.cs b/src/core/StackExchange.Redis.Extensions.Core/Abstractions/IRedisDatabase.cs index f1b66ca7..7c25f37c 100644 --- a/src/core/StackExchange.Redis.Extensions.Core/Abstractions/IRedisDatabase.cs +++ b/src/core/StackExchange.Redis.Extensions.Core/Abstractions/IRedisDatabase.cs @@ -407,12 +407,24 @@ public Task SetContainsAsync(string key, T item, CommandFlags flag = Co /// public Task> GetInfoAsync(); + /// + /// Gets the information about redis. + /// More info see http://redis.io/commands/INFO + /// + public Task> GetInfoAsync(string section); + /// /// Gets the information about redis with category. /// More info see http://redis.io/commands/INFO /// public Task GetInfoCategorizedAsync(); + /// + /// Gets the information about redis with category. + /// More info see http://redis.io/commands/INFO + /// + public Task GetInfoCategorizedAsync(string section); + /// /// Updates the expiry time of a redis cache object /// diff --git a/src/core/StackExchange.Redis.Extensions.Core/Extensions/SpanExtensions.cs b/src/core/StackExchange.Redis.Extensions.Core/Extensions/SpanExtensions.cs deleted file mode 100644 index 41fd15be..00000000 --- a/src/core/StackExchange.Redis.Extensions.Core/Extensions/SpanExtensions.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; - -using StackExchange.Redis.Extensions.Core.Models; - -namespace StackExchange.Redis.Extensions.Core.Extensions; - -internal static class SpanExtensions -{ - public static void EnumerateLines(this ReadOnlySpan span, ref List data, ref string category) - { - var start = 0; - - while (start < span.Length) - { - var end = span[start..].IndexOf('\n'); - ReadOnlySpan line; - - if (end == -1) - { - line = span[start..].Trim(); - start = span.Length; // Termina il loop - } - else - { - line = span[start..(start + end)].Trim(); - start += end + 1; - } - - // Gestisci ogni riga - if (line.IsEmpty) - continue; - - if (line[0] == '#') - { - category = line[1..].Trim().ToString(); - continue; - } - - var idx = line.IndexOf(':'); - if (idx > 0) - { - var key = line[..idx].Trim(); - var infoValue = line[(idx + 1)..].Trim(); - - data.Add(new InfoDetail(category, key.ToString(), infoValue.ToString())); - } - } - } -} diff --git a/src/core/StackExchange.Redis.Extensions.Core/Helpers/ExceptionThrowHelper.cs b/src/core/StackExchange.Redis.Extensions.Core/Helpers/ExceptionThrowHelper.cs index 25ec6243..e47a97e6 100644 --- a/src/core/StackExchange.Redis.Extensions.Core/Helpers/ExceptionThrowHelper.cs +++ b/src/core/StackExchange.Redis.Extensions.Core/Helpers/ExceptionThrowHelper.cs @@ -1,7 +1,10 @@ +// Copyright (c) Ugo Lattanzi. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + using System; using System.Diagnostics.CodeAnalysis; namespace StackExchange.Redis.Extensions.Core.Helpers; + internal static class ExceptionThrowHelper { public static void ThrowIfExistsNullElement(ReadOnlySpan argument, string paramName) diff --git a/src/core/StackExchange.Redis.Extensions.Core/Helpers/InfoDetailsParser.cs b/src/core/StackExchange.Redis.Extensions.Core/Helpers/InfoDetailsParser.cs new file mode 100644 index 00000000..83b31c72 --- /dev/null +++ b/src/core/StackExchange.Redis.Extensions.Core/Helpers/InfoDetailsParser.cs @@ -0,0 +1,137 @@ +// Copyright (c) Ugo Lattanzi. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace StackExchange.Redis.Extensions.Core.Helpers; + +/// +/// A helper class that provides methods to parse Redis info strings into structured data. +/// +/// +/// See for details about the format of the info command output. +/// +internal static class InfoDetailsParser +{ + private const string LineSeparator = "\r\n"; + + /// + /// Parses the given info string into a flat list of tuples, where each tuple contains the section name, key, and value. + /// + /// + /// + public static List<(string Section, string Key, string Value)> ParseAsFlatRows(ReadOnlySpan info) + { + List<(string Section, string Key, string Value)> rows = []; + + var currentSection = string.Empty; +#if NET9_0_OR_GREATER + foreach (var r in info.Split(LineSeparator)) + { + var line = info[r].Trim(); + if (line.IsEmpty) + continue; + + if (TryParseSection(line, out var section)) + currentSection = section; + else if (TryParseDetail(line, out var detail)) + rows.Add((currentSection, detail.Key, detail.Value)); + } +#else + var start = 0; + while (start < info.Length) + { + var end = info[start..].IndexOf(LineSeparator); + ReadOnlySpan line; + if (end < 0) + { + line = info[start..].Trim(); + start = info.Length; + } + else + { + line = info[start..(start + end)].Trim(); + start += end + LineSeparator.Length; + } + + if (line.IsEmpty) + continue; + + if (TryParseSection(line, out var section)) + currentSection = section; + else if (TryParseDetail(line, out var detail)) + rows.Add((currentSection, detail.Key, detail.Value)); + } + +#endif + return rows; + } + + /// + /// Parses the given info string into a dictionary of key-value pairs. Ingore section. + /// + /// + /// + public static Dictionary ParseAsDictionary(ReadOnlySpan info) + { + var dict = new Dictionary(StringComparer.Ordinal); + + // No need to trim the line, as TryParseDetail will handle it +#if NET9_0_OR_GREATER + foreach (var r in info.Split(LineSeparator)) + { + var line = info[r]; + if (TryParseDetail(line, out var detail)) + dict.TryAdd(detail.Key, detail.Value); // It's possible that `INFO` emits duplicate keys + } +#else + var start = 0; + while (start < info.Length) + { + var end = info[start..].IndexOf(LineSeparator); + ReadOnlySpan line; + if (end < 0) + { + line = info[start..]; + start = info.Length; + } + else + { + line = info[start..(start + end)]; + start += end + LineSeparator.Length; + } + + if (TryParseDetail(line, out var detail)) + dict.TryAdd(detail.Key, detail.Value); // It's possible that `INFO` emits duplicate keys + } + +#endif + return dict; + } + + private static bool TryParseSection(ReadOnlySpan line, out string section) + { + section = string.Empty; + + if (line[0] != '#') + return false; + + section = new(line[1..].Trim()); + return true; + } + + private static bool TryParseDetail(ReadOnlySpan line, out KeyValuePair detail) + { + var idx = line.IndexOf(':'); + if (idx <= 0) + { + detail = default; + return false; + } + + var key = new string(line[..idx].Trim()); + var value = new string(line[(idx + 1)..].Trim()); + detail = new(key, value); + return true; + } +} diff --git a/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.cs b/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.cs index 1b5ffcdf..2371fdb0 100644 --- a/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.cs +++ b/src/core/StackExchange.Redis.Extensions.Core/Implementations/RedisDatabase.cs @@ -22,6 +22,9 @@ namespace StackExchange.Redis.Extensions.Core.Implementations; /// public partial class RedisDatabase : IRedisDatabase { + private const string InfoScript = "return redis.call('INFO')"; + private const string InfoSectionScript = "return redis.call('INFO', ARGV[1])"; + private readonly IRedisConnectionPoolManager connectionPoolManager; private readonly ServerEnumerationStrategy serverEnumerationStrategy; private readonly string keyPrefix; @@ -559,21 +562,38 @@ public Task SaveAsync(SaveType saveType, CommandFlags flag = CommandFlags.None) /// public async Task> GetInfoAsync() { - var info = (await Database.ScriptEvaluateAsync("return redis.call('INFO')").ConfigureAwait(false)).ToString(); + var info = (await Database.ScriptEvaluateAsync(InfoScript).ConfigureAwait(false)).ToString(); - return string.IsNullOrEmpty(info) - ? new Dictionary() - : ParseInfo(info); + return InfoDetailsParser.ParseAsDictionary(info); + } + + /// + public async Task> GetInfoAsync(string section) + { + if (string.IsNullOrWhiteSpace(section)) + return await GetInfoAsync().ConfigureAwait(false); + + var info = (await Database.ScriptEvaluateAsync(InfoSectionScript, values: [section]).ConfigureAwait(false)).ToString(); + return InfoDetailsParser.ParseAsDictionary(info); } /// public async Task GetInfoCategorizedAsync() { - var info = (await Database.ScriptEvaluateAsync("return redis.call('INFO')").ConfigureAwait(false)).ToString(); + var info = (await Database.ScriptEvaluateAsync(InfoScript).ConfigureAwait(false)).ToString(); - return string.IsNullOrEmpty(info) - ? [] - : ParseCategorizedInfo(info); + return InfoDetail.ParseFrom(info); + } + + /// + public async Task GetInfoCategorizedAsync(string section) + { + if (string.IsNullOrWhiteSpace(section)) + return await GetInfoCategorizedAsync().ConfigureAwait(false); + + var info = (await Database.ScriptEvaluateAsync(InfoSectionScript, values: [section]).ConfigureAwait(false)).ToString(); + + return InfoDetail.ParseFrom(info); } /// @@ -614,29 +634,4 @@ public Task KeyTypeAsync(string key, CommandFlags flag = CommandFlags /// public Task KeyRestoreAsync(string key, byte[] value, TimeSpan? expiry = null, CommandFlags flag = CommandFlags.None) => Database.KeyRestoreAsync(key, value, expiry, flag); - - private static Dictionary ParseInfo(string info) - { - // Call Parse Categorized Info to cut back on duplicated code. - var data = ParseCategorizedInfo(info); - - // Return a dictionary of the Info Key and Info value - - var result = new Dictionary(data.Length); - - foreach (var detail in data) - result.TryAdd(detail.Key, detail.InfoValue); - - return result; - } - - private static InfoDetail[] ParseCategorizedInfo(string info) - { - var data = new List(); - var category = string.Empty; - - info.AsSpan().EnumerateLines(ref data, ref category); - - return [.. data]; - } } diff --git a/src/core/StackExchange.Redis.Extensions.Core/Models/InfoDetail.cs b/src/core/StackExchange.Redis.Extensions.Core/Models/InfoDetail.cs index 5ad4b215..68a70e43 100644 --- a/src/core/StackExchange.Redis.Extensions.Core/Models/InfoDetail.cs +++ b/src/core/StackExchange.Redis.Extensions.Core/Models/InfoDetail.cs @@ -1,37 +1,45 @@ // Copyright (c) Ugo Lattanzi. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. +using StackExchange.Redis.Extensions.Core.Helpers; + namespace StackExchange.Redis.Extensions.Core.Models; /// /// A class that contains redis info. /// -public class InfoDetail +/// The category name. +/// The redis key. +/// The information +public class InfoDetail(string category, string key, string infoValue) { - /// - /// Initializes a new instance of the class. - /// - /// The category name. - /// The redis key. - /// The information - public InfoDetail(string category, string key, string infoValue) - { - Category = category; - Key = key; - InfoValue = infoValue; - } - /// /// Gets or sets the category name /// - public string Category { get; } + public string Category { get; } = category; /// /// Gets or sets the redis key. /// - public string Key { get; } + public string Key { get; } = key; /// /// Gets or sets the informations. /// - public string InfoValue { get; } + public string InfoValue { get; } = infoValue; + + internal static InfoDetail[] ParseFrom(string? info) + { + var rows = InfoDetailsParser.ParseAsFlatRows(info); + if (rows.Count < 1) + return []; + + var details = new InfoDetail[rows.Count]; + for (var i = 0; i < rows.Count; i++) + { + var (category, key, val) = rows[i]; + details[i] = new(category, key, val); + } + + return details; + } } diff --git a/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.cs b/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.cs index 40167ce5..27ea8d30 100644 --- a/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.cs +++ b/tests/StackExchange.Redis.Extensions.Core.Tests/CacheClientTestBase.cs @@ -90,6 +90,20 @@ public async Task Info_Should_Return_Valid_Information_Async() Assert.Equal("6379", response["tcp_port"]); } + [Theory] + [InlineData("Server", "tcp_port", "6379")] + [InlineData("clients", "blocked_clients", "0")] + public async Task Info_Section_Should_Return_Valid_Information_Async(string section, string key, string value) + { + var response = await Sut + .GetDefaultDatabase() + .GetInfoAsync(section); + + Assert.NotNull(response); + Assert.True(response.Count > 0); + Assert.Equal(value, response[key]); + } + [Fact] public async Task Info_Category_Should_Return_Valid_Information_Async() { @@ -102,6 +116,42 @@ public async Task Info_Category_Should_Return_Valid_Information_Async() Assert.Equal("6379", response.Single(x => x.Key == "tcp_port").InfoValue); } + [Theory] + [InlineData("Server", "tcp_port", "6379")] + [InlineData("clients", "blocked_clients", "0")] + public async Task Info_Section_Category_Should_Return_Valid_Information_Async(string section, string key, string value) + { + var response = await Sut + .GetDefaultDatabase() + .GetInfoCategorizedAsync(section); + + Assert.NotNull(response); + Assert.NotEmpty(response); + Assert.Equal(value, response.Single(x => x.Key == key).InfoValue); + } + + // The section must reach Redis as a script argument, never spliced into the script body. + // Interpolating it lets a caller-supplied string run arbitrary commands: the payload below + // closes the INFO call and chains a SET, which the canary check would then find. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Info_Section_Should_Not_Allow_Lua_Injection_Async(bool categorized) + { + const string canary = "lua_injection_canary"; + const string payload = "Server') and redis.call('SET','" + canary + "','pwned') --"; + + if (categorized) + await Sut.GetDefaultDatabase().GetInfoCategorizedAsync(payload); + else + await Sut.GetDefaultDatabase().GetInfoAsync(payload); + + // Lua runs server-side, so an injected write lands on the unprefixed key: check the raw database. + var rawDatabase = db.Multiplexer.GetDatabase(db.Database); + + Assert.False(await rawDatabase.KeyExistsAsync(canary), "the section parameter was interpolated into the Lua script"); + } + [Fact] public async Task Add_Item_To_Redis_Database_Async() {