Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/AzureCosmos/AzureCosmosCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
/// </summary>
/// <param name="key">The key of the cache entry.</param>
/// <returns>The cached value or null if not found.</returns>
public override async Task<TValue> GetAsync<TValue>(string key)

Check warning on line 43 in src/AzureCosmos/AzureCosmosCache.cs

View workflow job for this annotation

GitHub Actions / Analyze (C#)

Nullability of reference types in return type doesn't match overridden member.

Check warning on line 43 in src/AzureCosmos/AzureCosmosCache.cs

View workflow job for this annotation

GitHub Actions / Unit Tests

Nullability of reference types in return type doesn't match overridden member.

Check warning on line 43 in src/AzureCosmos/AzureCosmosCache.cs

View workflow job for this annotation

GitHub Actions / Integration Tests

Nullability of reference types in return type doesn't match overridden member.
{
try
{
Expand All @@ -59,7 +59,7 @@
return default!;
}

if (ActionCacheEntryOptions.HasExpiredSlidingExpiration(response.Resource.SlidingExpiration))
if (ActionCacheEntryOptions.HasSlidingExpiration(response.Resource.SlidingExpiration))
{
await Cache.ReplaceItemAsync(
response.Resource,
Expand All @@ -82,7 +82,7 @@
/// </summary>
/// <param name="key">The cache key to set the value for.</param>
/// <param name="value">The value to set in the cache.</param>
public override Task SetAsync<TValue>(string key, TValue value)

Check warning on line 85 in src/AzureCosmos/AzureCosmosCache.cs

View workflow job for this annotation

GitHub Actions / Analyze (C#)

Nullability of type of parameter 'value' doesn't match overridden member (possibly because of nullability attributes).

Check warning on line 85 in src/AzureCosmos/AzureCosmosCache.cs

View workflow job for this annotation

GitHub Actions / Unit Tests

Nullability of type of parameter 'value' doesn't match overridden member (possibly because of nullability attributes).

Check warning on line 85 in src/AzureCosmos/AzureCosmosCache.cs

View workflow job for this annotation

GitHub Actions / Integration Tests

Nullability of type of parameter 'value' doesn't match overridden member (possibly because of nullability attributes).
{
var (absoluteExpiration, slidingExpiration, ttl) = EntryOptions;

Expand All @@ -94,7 +94,7 @@
Value = CacheJsonSerializer.Serialize(value),
AbsoluteExpiration = absoluteExpiration,
SlidingExpiration = slidingExpiration,
TTL = ttl == ActionCacheEntryOptions.NoExpiration ? -1 : ttl / 1000 // The deconstructed TTL is in milliseconds, we need to convert it to seconds.
TTL = ttl == ActionCacheEntryOptions.NoExpiration ? -1 : (long)Math.Ceiling(ttl / 1000.0)
}, PartitionKey);
}

Expand Down
9 changes: 4 additions & 5 deletions src/Common/Caching/ActionCacheEntryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,13 @@ public static bool HasExpiredAbsoluteExpiration(long absoluteExpirationUnix) =>
DateTimeOffset.UtcNow >= DateTimeOffset.FromUnixTimeMilliseconds(absoluteExpirationUnix);

/// <summary>
/// Determines if the sliding expiration has passed based on the provided sliding expiration value.
/// Determines if a sliding expiration duration is configured for the cache entry.
/// </summary>
/// <param name="slidingExpiration">The sliding expiration value, which represents the time in milliseconds.</param>
/// <param name="slidingExpiration">The sliding expiration duration in milliseconds.</param>
/// <returns>
/// <c>true</c> if the sliding expiration time has passed; otherwise, <c>false</c>.
/// If <paramref name="slidingExpiration"/> is less than or equal to <see cref="NoExpiration"/>, expiration is not enforced.
/// <c>true</c> if the sliding expiration duration is set (greater than <see cref="NoExpiration"/>); otherwise, <c>false</c>.
/// </returns>
public static bool HasExpiredSlidingExpiration(long slidingExpiration) => HasExpirationValue(slidingExpiration);
public static bool HasSlidingExpiration(long slidingExpiration) => HasExpirationValue(slidingExpiration);

/// <summary>
/// Determines if the provided expiration value is valid (greater than the <see cref="NoExpiration"/> value).
Expand Down
4 changes: 2 additions & 2 deletions src/Common/Concurrency/CacheLockerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public virtual async Task WaitForLockThenAsync(string resource, Func<Task> thenF
}
else
{
// Handle lock acquisition failure
throw new InvalidOperationException($"Failed to acquire lock for resource '{resource}' within the configured timeout.");
}
}

Expand Down Expand Up @@ -129,7 +129,7 @@ public virtual async Task WaitForLockThenAsync(string resource, Func<Task> thenF
}
else
{
// Handle lock acquisition failure
throw new InvalidOperationException($"Failed to acquire lock for resource '{resource}' within the configured timeout.");
}

return result;
Expand Down
1 change: 1 addition & 0 deletions src/Common/Keys/ActionCacheKeyBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public ActionCacheKeyBuilder WithRouteValues(RouteValueDictionary routeValues)
/// <returns>Returns itself for chaining.</returns>
public ActionCacheKeyBuilder WithActionArguments(IDictionary<string, object?> actionArguments)
{
if (actionArguments is null) return this;
KeyComponents.ActionArguments = actionArguments.ToDictionary();
return this;
}
Expand Down
10 changes: 7 additions & 3 deletions src/Common/Serialization/CacheJsonSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ internal static class CacheJsonSerializer
/// </summary>
internal static readonly JsonSerializerSettings SerializerSettings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.All,
// Auto only embeds $type when the runtime type differs from the declared type
// (e.g. IActionResult → OkObjectResult). A binder restricts which types may be
// instantiated from a $type field, blocking known deserialization gadget chains.
TypeNameHandling = TypeNameHandling.Auto,
SerializationBinder = new SafeSerializationBinder(),
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
Converters = new List<JsonConverter> { new ActionArgumentsConverter() }
};
Expand All @@ -25,8 +29,8 @@ internal static class CacheJsonSerializer
/// <typeparam name="T">The type of the object to serialize.</typeparam>
/// <param name="obj">The object to serialize. Can be null.</param>
/// <returns>A JSON string representation of the object.</returns>
internal static string Serialize<T>(T? obj) =>
JsonConvert.SerializeObject(obj, SerializerSettings);
internal static string Serialize<T>(T? obj) =>
JsonConvert.SerializeObject(obj, typeof(T), SerializerSettings);

/// <summary>
/// Deserializes a JSON string to an object of type <typeparamref name="T"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
foreach (var property in obj.Properties())
{
var value = ConvertToken(property.Value, serializer);
result.Add(property.Name, value);

Check warning on line 37 in src/Common/Serialization/Converters/ActionArgumentsConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (C#)

Possible null reference argument for parameter 'value' in 'void Dictionary<string, object>.Add(string key, object value)'.

Check warning on line 37 in src/Common/Serialization/Converters/ActionArgumentsConverter.cs

View workflow job for this annotation

GitHub Actions / Unit Tests

Possible null reference argument for parameter 'value' in 'void Dictionary<string, object>.Add(string key, object value)'.
}

return result;
Expand Down Expand Up @@ -104,7 +104,7 @@
foreach (var (key, value) in source)
{
writer.WritePropertyName(key);
serializer.Serialize(writer, value);
serializer.Serialize(writer, value, typeof(object));
}
}

Expand Down
47 changes: 47 additions & 0 deletions src/Common/Serialization/SafeSerializationBinder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;

namespace ActionCache.Common.Serialization;

/// <summary>
/// A serialization binder that restricts type resolution to assemblies already loaded
/// in the current AppDomain and explicitly blocks known deserialization gadget-chain namespaces.
/// </summary>
internal class SafeSerializationBinder : ISerializationBinder
{
// Known prefixes used in published Newtonsoft.Json gadget chains.
private static readonly string[] BlockedTypePrefixes =
[
"System.Windows",
"System.Workflow",
"System.Web.Security",
"Microsoft.Exchange",
"Microsoft.IdentityModel",
];

/// <inheritdoc/>
public Type BindToType(string? assemblyName, string typeName)
{
if (BlockedTypePrefixes.Any(prefix => typeName.StartsWith(prefix, StringComparison.Ordinal)))
throw new JsonSerializationException(
$"Type '{typeName}' is not permitted for deserialization.");

// Only resolve against assemblies already present in the AppDomain — this prevents
// an attacker from triggering the load of arbitrary assemblies via $type.
var type = AppDomain.CurrentDomain
.GetAssemblies()
.Where(assembly => assembly.GetName().Name == assemblyName)
.Select(assembly => assembly.GetType(typeName))
.FirstOrDefault(resolved => resolved is not null);

return type ?? throw new JsonSerializationException(
$"Type '{typeName}' from assembly '{assemblyName}' could not be resolved.");
}

/// <inheritdoc/>
public void BindToName(Type serializedType, out string? assemblyName, out string? typeName)
{
assemblyName = serializedType.Assembly.GetName().Name;
typeName = serializedType.FullName;
}
}
15 changes: 12 additions & 3 deletions src/Common/Utilities/Namespace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,20 @@ public record class Namespace(string Value)
/// </summary>
internal const string Assembly = nameof(ActionCache);

private static readonly AsyncLocal<Dictionary<string, string?>?> _routeValueStore = new();

private static Dictionary<string, string?> GetStore() =>
_routeValueStore.Value ??= new Dictionary<string, string?>();

/// <summary>
/// Gets or sets a string value containing route template parameters.
/// This property is used to hold route information that can include parameterized values.
/// Gets or sets a string value containing route template parameters,
/// scoped to the current async execution context to prevent cross-request mutation.
/// </summary>
public string? ValueWithRouteTemplateParameters { get; set; }
public string? ValueWithRouteTemplateParameters
{
get => GetStore().TryGetValue(Value, out var v) ? v : null;
set => GetStore()[Value] = value;
}

/// <summary>
/// Creates a fully qualified namespace key.
Expand Down
4 changes: 3 additions & 1 deletion src/Memory/MemoryActionCacheFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ IActionCacheRefreshProvider refreshProvider
EntryOptions = new ActionCacheEntryOptions
{
AbsoluteExpiration = absoluteExpiration,
SlidingExpiration = slidingExpiration
SlidingExpiration = slidingExpiration,
LockDuration = EntryOptions.LockDuration,
LockTimeout = EntryOptions.LockTimeout
},
RefreshProvider = RefreshProvider,
CacheLocker = new NullCacheLocker()
Expand Down
9 changes: 4 additions & 5 deletions src/Redis/RedisActionCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
/// </summary>
/// <param name="key">The key of the item to set in the cache.</param>
/// <param name="value">The value of the item to set in the cache.</param>
public override async Task SetAsync<TValue>(string key, TValue value)

Check warning on line 78 in src/Redis/RedisActionCache.cs

View workflow job for this annotation

GitHub Actions / Analyze (C#)

Nullability of type of parameter 'value' doesn't match overridden member (possibly because of nullability attributes).

Check warning on line 78 in src/Redis/RedisActionCache.cs

View workflow job for this annotation

GitHub Actions / Unit Tests

Nullability of type of parameter 'value' doesn't match overridden member (possibly because of nullability attributes).

Check warning on line 78 in src/Redis/RedisActionCache.cs

View workflow job for this annotation

GitHub Actions / Integration Tests

Nullability of type of parameter 'value' doesn't match overridden member (possibly because of nullability attributes).
{
RedisValue redisValue = CacheJsonSerializer.Serialize(value);

Expand All @@ -83,10 +83,9 @@

if (Assembly.TryGetResourceAsText(LuaResources.SetHash, out var script))
{
await Cache.ScriptEvaluateAsync(script,
[(string)Namespace, (RedisKey)key],
[redisValue, absoluteExpiration, slidingExpiration, ttl],
CommandFlags.FireAndForget
await Cache.ScriptEvaluateAsync(script,
[(string)Namespace, (RedisKey)key],
[redisValue, absoluteExpiration, slidingExpiration, ttl]
);
}
else
Expand All @@ -112,7 +111,7 @@
/// </summary>
/// <param name="key">The key of the item to retrieve from the cache.</param>
/// <returns>The cached item if found, otherwise default.</returns>
public override async Task<TValue> GetAsync<TValue>(string key)

Check warning on line 114 in src/Redis/RedisActionCache.cs

View workflow job for this annotation

GitHub Actions / Analyze (C#)

Nullability of reference types in return type doesn't match overridden member.

Check warning on line 114 in src/Redis/RedisActionCache.cs

View workflow job for this annotation

GitHub Actions / Unit Tests

Nullability of reference types in return type doesn't match overridden member.

Check warning on line 114 in src/Redis/RedisActionCache.cs

View workflow job for this annotation

GitHub Actions / Integration Tests

Nullability of reference types in return type doesn't match overridden member.
{
var namespaceKey = Namespace.Create(key);
var hashEntries = await Cache.HashGetAllAsync(namespaceKey);
Expand All @@ -129,7 +128,7 @@
return default!;
}

if (ActionCacheEntryOptions.HasExpiredSlidingExpiration(hashEntries.GetSlidingExpiration()))
if (ActionCacheEntryOptions.HasSlidingExpiration(hashEntries.GetSlidingExpiration()))
{
await Cache.KeyExpireAsync(namespaceKey, TimeSpan.FromMilliseconds(hashEntries.GetSlidingExpiration()), CommandFlags.FireAndForget);
}
Expand Down
4 changes: 3 additions & 1 deletion src/Redis/RedisActionCacheFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ IActionCacheRefreshProvider refreshProvider
EntryOptions = new ActionCacheEntryOptions
{
AbsoluteExpiration = absoluteExpiration,
SlidingExpiration = slidingExpiration
SlidingExpiration = slidingExpiration,
LockDuration = EntryOptions.LockDuration,
LockTimeout = EntryOptions.LockTimeout
},
RefreshProvider = RefreshProvider,
CacheLocker = new NullCacheLocker()
Expand Down
4 changes: 3 additions & 1 deletion src/SqlServer/SqlServerActionCacheFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ IActionCacheRefreshProvider refreshProvider
EntryOptions = new ActionCacheEntryOptions
{
AbsoluteExpiration = absoluteExpiration,
SlidingExpiration = slidingExpiration
SlidingExpiration = slidingExpiration,
LockDuration = EntryOptions.LockDuration,
LockTimeout = EntryOptions.LockTimeout
},
RefreshProvider = RefreshProvider,
CacheLocker = new SqlServerCacheLocker(
Expand Down
26 changes: 26 additions & 0 deletions test/Unit/AzureCosmos/AzureCosmosActionCacheTtlTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Unit.AzureCosmos;

[TestFixture]
public class AzureCosmosActionCacheTtlTests
{
[TestCase(1500L, 2L, Description = "1500 ms rounds up to 2 s")]
[TestCase(999L, 1L, Description = "999 ms rounds up to 1 s")]
[TestCase(500L, 1L, Description = "500 ms rounds up to 1 s")]
[TestCase(2000L, 2L, Description = "2000 ms (exact multiple) stays 2 s")]
public void SetAsync_TtlConversion_RoundsUpToNearestSecond(long ttlMs, long expectedSeconds)
{
long actualTtlSeconds = (long)Math.Ceiling(ttlMs / 1000.0);

actualTtlSeconds.Should().Be(expectedSeconds);
}

[Test]
public void SetAsync_TtlConversion_WhenNoExpiration_ReturnsMinusOne()
{
long ttl = 0L; // ActionCacheEntryOptions.NoExpiration

long cosmosttl = ttl == 0L ? -1 : (long)Math.Ceiling(ttl / 1000.0);

cosmosttl.Should().Be(-1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,15 @@ public void HasExpiredAbsoluteExpiration_WhenPast_ReturnsTrue()
}

[Test]
public void HasExpiredSlidingExpiration_WhenZero_ReturnsFalse()
public void HasSlidingExpiration_WhenZero_ReturnsFalse()
{
ActionCacheEntryOptions.HasExpiredSlidingExpiration(0L).Should().BeFalse();
ActionCacheEntryOptions.HasSlidingExpiration(0L).Should().BeFalse();
}

[Test]
public void HasExpiredSlidingExpiration_WhenPositive_ReturnsTrue()
public void HasSlidingExpiration_WhenPositive_ReturnsTrue()
{
ActionCacheEntryOptions.HasExpiredSlidingExpiration(5000L).Should().BeTrue();
ActionCacheEntryOptions.HasSlidingExpiration(5000L).Should().BeTrue();
}

[Test]
Expand Down Expand Up @@ -164,4 +164,15 @@ public void DefaultLockTimeout_Is10Seconds()
{
new ActionCacheEntryOptions().LockTimeout.Should().Be(TimeSpan.FromSeconds(10));
}

[Test]
public void HasSlidingExpiration_IsEquivalentToHasExpirationValue()
{
long value = 5_000L;

bool hasSliding = ActionCacheEntryOptions.HasSlidingExpiration(value);
bool hasValue = ActionCacheEntryOptions.HasExpirationValue(value);

hasSliding.Should().Be(hasValue);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ namespace Unit.Common;
[TestFixture]
public class ActionCacheKeyBuilderTests
{
// Bug H2: WithActionArguments calls actionArguments.ToDictionary() without a null guard.
// Passing null throws NullReferenceException. The correct behavior is either an early
// return (treat null as empty arguments) or a clear ArgumentNullException.

[Test]
public void WithActionArguments_WhenNull_ShouldNotThrow_BugH2()
{
var builder = new ActionCacheKeyBuilder();

// BUG: currently throws NullReferenceException
// Fix: add null guard and return early (treat as no arguments)
Action act = () => builder.WithActionArguments(null!);

act.Should().NotThrow();
}

[Test]
[TestCaseSource(typeof(TestData), nameof(TestData.GetControllerDescriptors))]
public void Build_WithActionArguments_ProducesEncodedKey(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ namespace Unit.Common;
[TestFixture]
public class ActionCacheKeyComponentsBuilderTests
{
// Bug M1: ActionCacheKeyComponentsBuilder calls KeyEncoder.Decode(value) in its constructor
// with no try-catch. A corrupted or manually crafted cache key that is not valid hex throws
// FormatException with no context about which key was malformed.
//
// Fix: wrap the decode in try-catch, log the offending key, and either return a safe default
// or rethrow with additional context.

[Test]
public void Constructor_WhenValueIsNotValidHex_ThrowsFormatException_BugM1()
{
const string malformedKey = "not-valid-hex!!";

// BUG: throws FormatException with no context about the bad key.
// Fix: catch and rethrow with key info, or return empty/default components.
Action act = () => new ActionCacheKeyComponentsBuilder(malformedKey);

act.Should().Throw<FormatException>();
}

[Test]
public void Constructor_WhenValueIsValidHexButNotQueryString_DoesNotThrow_BugM1()
{
// Valid hex that decodes to non-query-string content — should not throw
// (the query parser treats unrecognised content as a single key with no value).
var validHexOfArbitraryContent = new ActionCache.Common.Keys.KeyEncoder().Encode("not-a-query-string");

Action act = () => new ActionCacheKeyComponentsBuilder(validHexOfArbitraryContent);

act.Should().NotThrow();
}

[Test]
public void Build_Always_ParsesRouteValuesAndActionArguments()
{
Expand Down
Loading
Loading