Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion src/StackExchange.Redis/Message.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Text;
using System.Threading;
using Microsoft.Extensions.Logging;
using RESPite;
using RESPite.Internal;
using RESPite.Messages;
using StackExchange.Redis.Profiling;
Expand Down Expand Up @@ -171,6 +172,29 @@ internal void PrepareToResend(ServerEndPoint resendTo, bool isMoved)
public RedisCommand Command => command;
public virtual string CommandAndKey => Command.ToString();

[AsciiHash(nameof(SubCommandMetadata))]
internal enum SubCommand
{
[AsciiHash("")]
Unknown = 0,
[AsciiHash("GETNAME")]
GetName,
[AsciiHash("ID")]
Id,
[AsciiHash("INFO")]
Info,
[AsciiHash("SETINFO")]
SetInfo,
[AsciiHash("SETNAME")]
SetName,
}

protected virtual bool TryGetSubCommand(out SubCommand subCommand)
{
subCommand = SubCommand.Unknown;
return false;
}

/// <summary>
/// Things with the potential to cause harm, or to reveal configuration information.
/// </summary>
Expand All @@ -180,6 +204,21 @@ public bool IsAdmin
{
switch (Command)
{
case RedisCommand.CLIENT when TryGetSubCommand(out var subCommand):
switch (subCommand)
{
case SubCommand.GetName:
case SubCommand.SetName:
case SubCommand.Id:
case SubCommand.Info:
case SubCommand.SetInfo:
return false;
}
return true;
/* possible? reasonable?
case RedisCommand.CONFIG when TryGetSubCommand(out var subCommand):
// allow .Get?
*/
case RedisCommand.BGREWRITEAOF:
case RedisCommand.BGSAVE:
case RedisCommand.CLIENT:
Expand Down Expand Up @@ -691,7 +730,7 @@ internal bool ComputeResult(PhysicalConnection connection, ref RespReader reader
}
catch (Exception ex)
{
connection.OnDetailLog($"{ex.GetType().Name}: {ex.Message}");
connection?.OnDetailLog($"{ex.GetType().Name}: {ex.Message}");
ex.Data.Add("got", prefix.ToString());
connection?.BridgeCouldBeNull?.Multiplexer?.OnMessageFaulted(this, ex);
box?.SetException(ex);
Expand Down Expand Up @@ -2017,5 +2056,40 @@ private UnknownMessage() : base(0, CommandFlags.None, RedisCommand.UNKNOWN) { }
}

public void SetNoFlush() => Flags |= NoFlushFlag;

internal static partial class SubCommandMetadata
{
[AsciiHash(CaseSensitive = false)]
internal static partial bool TryParse(ReadOnlySpan<byte> value, out SubCommand subCommand);

[AsciiHash(CaseSensitive = false)]
internal static partial bool TryParse(ReadOnlySpan<char> value, out SubCommand subCommand);

internal static bool TryGetSubCommand(in RedisValue value, out SubCommand subCommand)
{
switch (value.Type)
{
case RedisValue.StorageType.ByteArray:
case RedisValue.StorageType.MemoryManager:
case RedisValue.StorageType.ShortBlob:
// all three contiguous byte-blob kinds expose their bytes directly
// (the discard here *must* be stack-local; that's the "Unsafe" in this API)
return TryParse(value.UnsafeRawSpan(out _), out subCommand);
case RedisValue.StorageType.String:
// char-backed: parse the chars directly, no UTF8 round-trip
return TryParse(value.RawString().AsSpan(), out subCommand);
case RedisValue.StorageType.Sequence when value.GetByteCount() <= BufferBytes:
// non-contiguous: normalize into a small stack buffer
// (sub-commands are short, so anything longer cannot match)
Span<byte> tmp = stackalloc byte[BufferBytes];
var len = value.CopyTo(tmp);
return TryParse(tmp.Slice(0, len), out subCommand);
// numeric / null / unknown are never a sub-command (e.g. it is never `123`);
// if that ever changes, revisit
}
subCommand = SubCommand.Unknown;
return false;
}
}
}
}
18 changes: 18 additions & 0 deletions src/StackExchange.Redis/RedisDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5613,6 +5613,24 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy)
return slot;
}
public override int ArgCount => _args.Count;

protected override bool TryGetSubCommand(out SubCommand subCommand)
{
// the sub-command (if any) is the first argument after the command itself,
// e.g. CLIENT [GETNAME]; ad-hoc Execute args are boxed objects, so normalize
// the first one to a RedisValue before probing it against the known sub-commands
foreach (object arg in _args)
{
var value = RedisValue.TryParse(arg, out var valid);
if (valid)
{
return SubCommandMetadata.TryGetSubCommand(value, out subCommand);
}
break; // only the first argument is a sub-command candidate
}
subCommand = SubCommand.Unknown;
return false;
}
}

private sealed class ScriptEvalMessage : Message, IMultiMessage
Expand Down
54 changes: 54 additions & 0 deletions tests/StackExchange.Redis.Tests/LibraryNameSuffixAdminTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Threading.Tasks;
using Xunit;

namespace StackExchange.Redis.Tests;

public class LibraryNameSuffixAdminTests(InProcServerFixture fixture)
{
private ConfigurationOptions NoAdminConfig()
{
// start from the shared in-process server config, but explicitly *disable* admin mode
var options = fixture.Config.Clone();
options.AllowAdmin = false;
Assert.False(options.AllowAdmin);
return options;
}

[Fact]
public async Task AddLibraryNameSuffixWorksWithoutAdmin()
{
await using var conn = await ConnectionMultiplexer.ConnectAsync(NoAdminConfig());

// internally this fixes up connected servers via CLIENT SETINFO (best-effort); the
// CLIENT SETINFO sub-command is not admin; don't report it (telemetry, etc)
conn.AddLibraryNameSuffix("mysuffix");
}

[Fact]
public async Task ClientSubCommandsViaExecuteDoNotRequireAdmin()
{
await using var conn = await ConnectionMultiplexer.ConnectAsync(NoAdminConfig());
var server = conn.GetServer(conn.GetEndPoints()[0]);

// none of these CLIENT sub-commands are admin, so they must not trip the admin-mode guard
// even though AllowAdmin is disabled (regression: the ad-hoc ExecuteMessage previously did
// not expose its sub-command to Message.IsAdmin, so CLIENT was treated as wholesale-admin)
var id = server.Execute("CLIENT", "ID");
Assert.True((long)id > 0);

Assert.Equal("OK", (string?)server.Execute("CLIENT", "SETNAME", "roundtrip"));
Assert.Equal("roundtrip", (string?)server.Execute("CLIENT", "GETNAME"));
}

[Fact]
public async Task AdminClientSubCommandsStillRequireAdmin()
{
await using var conn = await ConnectionMultiplexer.ConnectAsync(NoAdminConfig());
var server = conn.GetServer(conn.GetEndPoints()[0]);

// CLIENT LIST is a genuine admin sub-command (not in the allow-list), so it must still be
// blocked when AllowAdmin is disabled - the fix must not blanket-allow every CLIENT usage
var ex = Assert.Throws<RedisCommandException>(() => server.Execute("CLIENT", "LIST"));
Assert.Contains("admin mode", ex.Message);
}
}
Loading