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
5 changes: 5 additions & 0 deletions libs/host/Configuration/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ internal sealed class Options : ICloneable
[Option("acl-file", Required = false, HelpText = "External ACL user file.")]
public string AclFile { get; set; }

[OptionValidation]
[Option("acl-strict-custom-commands", Required = false, HelpText = "If true, the server refuses to start when an ACL rule references a custom (extension) command name that no loaded module has registered. If false (default), unresolved names are loaded as-is and logged as warnings.")]
public bool? AclStrictCustomCommands { get; set; }

[Option("aad-authority", Required = false, HelpText = "The authority of AAD authentication.")]
public string AadAuthority { get; set; }

Expand Down Expand Up @@ -847,6 +851,7 @@ endpoint is IPEndPoint listenEp && clusterAnnounceEndpoint[0] is IPEndPoint anno
ParallelMigrateTaskCount = ParallelMigrateTaskCount,
FastMigrate = FastMigrate.GetValueOrDefault(),
AuthSettings = GetAuthenticationSettings(logger),
AclStrictCustomCommands = AclStrictCustomCommands.GetValueOrDefault(),
EnableAOF = EnableAOF.GetValueOrDefault(),
EnableLua = EnableLua.GetValueOrDefault(),
LuaTransactionMode = LuaTransactionMode.GetValueOrDefault(),
Expand Down
56 changes: 56 additions & 0 deletions libs/host/GarnetServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
Expand Down Expand Up @@ -302,6 +303,61 @@ private void InitializeServer()
servers[i].Register(WireFormat.ASCII, Provider);

LoadModules(customCommandManager);

// ACL rules are parsed before modules load, so per-name custom-command entries land
// on users unresolved. Sweep them now against the registered modules.
ValidateCustomCommandACLs(customCommandManager);
}

private void ValidateCustomCommandACLs(CustomCommandManager customCommandManager)
{
if (opts.AuthSettings is not AclAuthenticationSettings)
{
return;
}

var acl = storeWrapper.accessControlList;
if (acl == null)
{
return;
}

var unresolved = new List<(string user, string name)>();
foreach (var kv in acl.GetUserHandles())
{
var u = kv.Value.User;
foreach (var name in u.CustomCommandsAllowed)
{
if (!customCommandManager.IsCustomCommandRegistered(name))
{
unresolved.Add((kv.Key, name));
}
}
foreach (var name in u.CustomCommandsDenied)
{
if (!customCommandManager.IsCustomCommandRegistered(name))
{
unresolved.Add((kv.Key, name));
}
Comment on lines +326 to +341
}
}

if (unresolved.Count == 0)
{
return;
}

foreach (var (user, name) in unresolved)
{
logger?.LogWarning("ACL rule references custom command '{name}' for user '{user}' which is not registered with any loaded module", name, user);
}

if (opts.AclStrictCustomCommands)
{
// Strict mode: fail closed so operators can't accidentally ship an ACL with typos
// that would silently match no command (and therefore deny by default at dispatch).
throw new GarnetException($"ACL strict mode: {unresolved.Count} unresolved (user, custom-command) entries in ACL rules. Disable acl-strict-custom-commands or load the appropriate module(s).");
}
Comment on lines +355 to +360
}

private GarnetDatabase CreateDatabase(int dbId, GarnetServerOptions serverOptions, ClusterFactory clusterFactory,
Expand Down
3 changes: 3 additions & 0 deletions libs/host/defaults.conf
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@
/* External ACL user file. */
"AclFile" : null,

/* Refuse to start when an ACL rule references a custom (extension) command name that no loaded module has registered. If false (default), unresolved names are loaded as-is and logged as warnings. */
"AclStrictCustomCommands" : false,

/* The authority of AAD authentication. */
"AadAuthority" : "https://login.microsoftonline.com",

Expand Down
70 changes: 64 additions & 6 deletions libs/server/ACL/ACLParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,18 +216,33 @@ public static void ApplyACLOpToUser(ref User user, string op)
// Individual commands or command|subcommand pairs
string commandName = op.Substring(1);

if (!TryParseCommandForAcl(commandName, out RespCommand command))
if (TryParseCommandForAcl(commandName, out RespCommand command))
{
throw new AclCommandDoesNotExistException(commandName);
if (op[0] == '-')
{
user.RemoveCommand(command);
}
else
{
user.AddCommand(command);
}
}

if (op[0] == '-')
else if (IsValidCustomCommandName(commandName))
{
user.RemoveCommand(command);
// Modules may not be loaded yet (ACL file is parsed before LoadModules), so we
// store the name on the user and resolve it later (startup pass, SETUSER, dispatch).
if (op[0] == '-')
{
user.RemoveCustomCommand(commandName);
}
else
{
user.AddCustomCommand(commandName);
}
}
else
{
user.AddCommand(command);
throw new AclCommandDoesNotExistException(commandName);
}
}
else if (op.Equals("~*", StringComparison.Ordinal) || op.Equals("ALLKEYS", StringComparison.OrdinalIgnoreCase))
Expand Down Expand Up @@ -304,6 +319,49 @@ static bool IsInvalidCommandToAcl(RespCommand command)
=> command == RespCommand.INVALID || command == RespCommand.NONE || command.NormalizeForACLs() != command;
}

/// <summary>
/// Maximum length (in chars) of a custom command name accepted in an ACL rule.
/// </summary>
internal const int MaxCustomCommandNameLength = 64;

/// <summary>
/// Returns true if <paramref name="name"/> is a syntactically valid custom command name.
/// Strict validation prevents the unknown-name fallback from accepting RESP-meta bytes
/// or whitespace-bearing junk. Allowed: ASCII letters/digits and '.', '_', '-', '|';
/// first character must be alphanumeric.
/// </summary>
internal static bool IsValidCustomCommandName(string name)
{
if (string.IsNullOrEmpty(name) || name.Length > MaxCustomCommandNameLength)
{
return false;
}

char first = name[0];
bool firstOk = (first >= 'A' && first <= 'Z')
|| (first >= 'a' && first <= 'z')
|| (first >= '0' && first <= '9');
if (!firstOk)
{
return false;
}

foreach (char c in name)
{
bool ok = (c >= 'A' && c <= 'Z')
|| (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
// '|' is permitted so custom names can mirror built-in subcommand notation (e.g. CLIENT|GETNAME).
|| c == '.' || c == '_' || c == '-' || c == '|';
if (!ok)
{
return false;
}
}

return true;
}

/// <summary>
/// Lookup the <see cref="RespAclCategories"/> by equivalent string.
/// </summary>
Expand Down
106 changes: 104 additions & 2 deletions libs/server/ACL/CommandPermissionSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Licensed under the MIT license.

using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;

Expand All @@ -21,6 +23,12 @@
// Each bit corresponds to RespCommand + subcommand
private readonly ulong[] _commandList;

// Per-name allow/deny sets for custom (extension) commands. These names live outside
// the bitmap range because custom RespCommand IDs are assigned dynamically above
// LastValidCommand. OrdinalIgnoreCase matches CustomCommandManager's normalization.
private FrozenSet<string> _customAllowed = FrozenSet<string>.Empty;

Check failure on line 29 in libs/server/ACL/CommandPermissionSet.cs

View workflow job for this annotation

GitHub Actions / Format Garnet

Collection initialization can be simplified

Check failure on line 29 in libs/server/ACL/CommandPermissionSet.cs

View workflow job for this annotation

GitHub Actions / Format Garnet

Collection initialization can be simplified
private FrozenSet<string> _customDenied = FrozenSet<string>.Empty;

Check failure on line 30 in libs/server/ACL/CommandPermissionSet.cs

View workflow job for this annotation

GitHub Actions / Format Garnet

Collection initialization can be simplified

Check failure on line 30 in libs/server/ACL/CommandPermissionSet.cs

View workflow job for this annotation

GitHub Actions / Format Garnet

Collection initialization can be simplified

private CommandPermissionSet(string description)
: this(new ulong[CommandListLength], description)
{
Expand Down Expand Up @@ -62,6 +70,36 @@
return (_commandList[ulongIndex] & (1UL << bitIndex)) != 0;
}

/// <summary>
/// Returns true if the given custom (extension) command can be run.
/// Deny precedence: an explicit -name beats any +@category that would otherwise allow it.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool CanRunCustomCommand(RespCommand genericCmd, string customName)
{
if (this == All)
{
return true;
}

if (_customDenied.Contains(customName))
{
return false;
}

if (_customAllowed.Contains(customName))
{
return true;
}

// Fall back to the generic bitmap bit (set by +@custom/+@all/+CustomRawStringCmd).
int index = (int)genericCmd;
int ulongIndex = index / 64;
int bitIndex = index % 64;

return (_commandList[ulongIndex] & (1UL << bitIndex)) != 0;
}

/// <summary>
/// Copy this permission set.
/// </summary>
Expand All @@ -78,9 +116,65 @@
Array.Copy(this._commandList, copy, this._commandList.Length);
}

return new(copy, Description);
// FrozenSet is immutable; sharing the reference is safe and avoids re-hashing on copy.
return new(copy, Description)
{
_customAllowed = this._customAllowed,
_customDenied = this._customDenied,
};
}

/// <summary>
/// Add a custom command name to the per-name allow list.
/// Removes any matching entry from the deny list (last-write-wins).
/// Not thread safe; callers must use the CAS pattern on User._enabledCommands.
/// </summary>
internal void AddCustomCommand(string normalizedName)
{
if (_customDenied.Contains(normalizedName))
{
var deniedCopy = new HashSet<string>(_customDenied, StringComparer.OrdinalIgnoreCase);
deniedCopy.Remove(normalizedName);
_customDenied = deniedCopy.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
}

if (!_customAllowed.Contains(normalizedName))
{
var allowedCopy = new HashSet<string>(_customAllowed, StringComparer.OrdinalIgnoreCase) { normalizedName };
_customAllowed = allowedCopy.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
}
}

/// <summary>
/// Add a custom command name to the per-name deny list.
/// Removes any matching entry from the allow list (last-write-wins).
/// Not thread safe; callers must use the CAS pattern on User._enabledCommands.
/// </summary>
internal void RemoveCustomCommand(string normalizedName)
{
if (_customAllowed.Contains(normalizedName))
{
var allowedCopy = new HashSet<string>(_customAllowed, StringComparer.OrdinalIgnoreCase);
allowedCopy.Remove(normalizedName);
_customAllowed = allowedCopy.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
}

if (!_customDenied.Contains(normalizedName))
{
var deniedCopy = new HashSet<string>(_customDenied, StringComparer.OrdinalIgnoreCase) { normalizedName };
_customDenied = deniedCopy.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
}
}

/// <summary>
/// Total count of per-name custom command entries (allow + deny).
/// </summary>
internal int CustomEntryCount => _customAllowed.Count + _customDenied.Count;

internal FrozenSet<string> CustomAllowed => _customAllowed;

internal FrozenSet<string> CustomDenied => _customDenied;

/// <summary>
/// Enable this command / sub-command pair.
///
Expand Down Expand Up @@ -157,7 +251,15 @@
}
else
{
return this._commandList.AsSpan().SequenceEqual(other._commandList);
if (!this._commandList.AsSpan().SequenceEqual(other._commandList))
{
return false;
}

// Per-name custom sets must also match for equivalence; otherwise rationalization
// could drop tokens like `-json.set` that meaningfully change runtime behavior.
return this._customAllowed.SetEquals(other._customAllowed)
&& this._customDenied.SetEquals(other._customDenied);
}
}

Expand Down
Loading
Loading