fix: address 8 bugs from security and correctness audit#5
Merged
Conversation
C1 — TypeNameHandling.None in CacheJsonSerializer (RCE risk) TypeNameHandling.All allowed an attacker with cache-write access to plant a gadget-chain payload and trigger arbitrary code execution on the next deserialization. Switched to TypeNameHandling.None; the strongly-typed generic parameter T in Deserialize<T> provides all needed type information. C4 — AsyncLocal-scoped ValueWithRouteTemplateParameters in Namespace The mutable property was set directly on a shared Namespace instance, causing a last-writer-wins race between concurrent requests. The property is now backed by an AsyncLocal<Dictionary<string,string?>> keyed by the namespace Value, isolating each async execution context. C5 — Rename HasExpiredSlidingExpiration → HasSlidingExpiration The old name implied an expiration time-check, but the method only tests whether a sliding window is configured (value > 0). Renamed to remove the misleading implication and updated all call sites in RedisActionCache and AzureCosmosCache. H2 — Null guard in ActionCacheKeyBuilder.WithActionArguments Passing null threw NullReferenceException on actionArguments.ToDictionary(). Added an early return so null is treated as "no arguments provided". H4 — Remove CommandFlags.FireAndForget from RedisActionCache.SetAsync The primary Lua-script path discarded the result and any errors silently, making it impossible to detect network failures, OOM, or Lua errors. Removed the flag so the awaited result surfaces exceptions normally. H5 — Ceiling division for Cosmos TTL milliseconds → seconds conversion Integer division (ttl / 1000) silently truncated sub-second values: a 1500 ms TTL became 1 s instead of 2 s, shortening cache lifetimes. Changed to (long)Math.Ceiling(ttl / 1000.0). H7 — Throw InvalidOperationException when lock acquisition fails in CacheLockerBase Both WaitForLockThenAsync overloads had empty else branches, silently skipping the protected operation when a lock could not be acquired. Callers had no indication that a cache write was dropped. Now throws InvalidOperationException with the resource name so callers can handle it. M7 — Preserve LockDuration/LockTimeout in factory Create(ns, abs, slide) overloads All three factories (Memory, Redis, SqlServer) constructed a fresh ActionCacheEntryOptions with only expiration fields, dropping any configured LockDuration and LockTimeout back to their 5 s / 10 s defaults. The overloads now copy both lock settings from the global EntryOptions. Tests: added/updated unit tests confirming each bug (failing before fix, passing after) across CacheJsonSerializerTests, NamespaceTests, ActionCacheEntryOptionsTests, CacheLockerBaseTests, AzureCosmosActionCacheTtlTests, RedisActionCacheTests, RedisActionCacheFactoryTests, SqlServerActionCacheFactoryTests, and ActionCacheKeyBuilderTests.
…of None TypeNameHandling.None broke deserialization of polymorphic types the library legitimately needs: IActionResult in the MVC filter and object? values inside action-argument dictionaries used by the refresh provider. Switch to TypeNameHandling.Auto, which only embeds $type when the runtime type differs from the declared type, paired with SafeSerializationBinder that restricts type resolution to already-loaded assemblies and explicitly blocks known gadget-chain namespaces (System.Windows, System.Workflow, System.Web.Security, Microsoft.Exchange, Microsoft.IdentityModel). Pass typeof(T) to SerializeObject so the declared type is always visible to the serializer, and pass typeof(object) in ActionArgumentsConverter so complex dictionary values receive $type when their runtime type differs. Significant improvement over the original TypeNameHandling.All with no binder: $type is emitted far less often, and every $type read during deserialization is validated against the binder before any type is instantiated.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR resolves 8 bugs identified in a security and correctness audit across all four cache backends. Each bug has a corresponding unit test confirming the issue before the fix and passing after.
Bugs Fixed
C1 — Deserialization gadget-chain risk in
CacheJsonSerializerFile:
src/Common/Serialization/CacheJsonSerializer.cs,SafeSerializationBinder.cs,ActionArgumentsConverter.csTypeNameHandling.Allembedded$typein every payload and honored it unconditionally on deserialization. An attacker with write access to the cache backend could plant a Newtonsoft.Json gadget-chain payload and trigger arbitrary code execution on the nextGetAsynccall.Fix: Switched to
TypeNameHandling.Auto(only embeds$typewhen the runtime type genuinely differs from the declared type — e.g.IActionResult → OkObjectResult) combined with aSafeSerializationBinderthat restricts deserialization to already-loaded assemblies and explicitly blocks known gadget-chain namespaces (System.Windows,System.Workflow,System.Web.Security,Microsoft.Exchange,Microsoft.IdentityModel). Also passestypeof(T)toSerializeObjectandtypeof(object)inActionArgumentsConverter.WriteJsonso the declared type is always visible to the serializer. This is a substantial improvement over the original:$typeis emitted far less often, and every$typeread during deserialization is validated before any type is instantiated.C4 — Race condition: shared mutable
Namespace.ValueWithRouteTemplateParametersFile:
src/Common/Utilities/Namespace.csValueWithRouteTemplateParameterswas a plain{ get; set; }on arecord class. When aNamespaceinstance is shared across concurrent requests, one request'sAttachRouteValuescall could overwrite another's resolved route value — last writer wins — producing wrong cache keys or evicting the wrong resource.Fix: The property is now backed by
AsyncLocal<Dictionary<string, string?>>keyed by namespaceValue. Each async execution context (i.e. each request) gets its own resolved value; concurrent requests cannot interfere.C5 — Misleading name:
HasExpiredSlidingExpirationdoes not check expirationFile:
src/Common/Caching/ActionCacheEntryOptions.csThe method only called
HasExpirationValue(value)— checking whether a sliding window is configured (> 0), not whether it had elapsed. The name implied a time comparison symmetrical withHasExpiredAbsoluteExpiration, which would mislead future maintainers.Fix: Renamed to
HasSlidingExpiration. Updated both call sites inRedisActionCache.csandAzureCosmosCache.cs.H2 — Null reference:
ActionCacheKeyBuilder.WithActionArgumentsFile:
src/Common/Keys/ActionCacheKeyBuilder.csPassing
nullasactionArgumentscaused an immediateNullReferenceExceptiononactionArguments.ToDictionary().Fix: Added
if (actionArguments is null) return this;— null is treated as "no arguments provided".H4 — Silent write failures:
CommandFlags.FireAndForgetinRedisActionCache.SetAsyncFile:
src/Redis/RedisActionCache.csThe primary
SetAsyncpath passedCommandFlags.FireAndForgettoScriptEvaluateAsync. Any network error, Redis OOM, or Lua error was silently discarded — callers had no way to know the write failed.Fix: Removed
CommandFlags.FireAndForget. The awaited result now surfaces failures normally.H5 — TTL truncation: integer division in
AzureCosmosActionCache.SetAsyncFile:
src/AzureCosmos/AzureCosmosCache.csttl / 1000(ms → s) used integer division, silently truncating sub-second values: 1 500 ms → 1 s, 999 ms → 0 s (no expiry). Cache entries expired earlier than configured.Fix: Changed to
(long)Math.Ceiling(ttl / 1000.0).H7 — Silent lock failures: empty
elsebranches inCacheLockerBaseFile:
src/Common/Concurrency/CacheLockerBase.csBoth
WaitForLockThenAsyncoverloads had emptyelse { // Handle lock acquisition failure }branches. A failed lock acquisition silently skipped the protected operation — callers could not distinguish a successful write from one that never happened.Fix: Both branches now throw
InvalidOperationExceptionwith the resource name so callers can apply their own recovery strategy.M7 — Lock settings dropped by factory
Create(ns, abs, slide)overloadsFiles:
src/Memory/MemoryActionCacheFactory.cs,src/Redis/RedisActionCacheFactory.cs,src/SqlServer/SqlServerActionCacheFactory.csAll three factories constructed a fresh
ActionCacheEntryOptionswith only expiration fields set, silently resetting any configuredLockDurationandLockTimeoutback to their 5 s / 10 s defaults.Fix: The overloads now copy
LockDurationandLockTimeoutfrom the globalEntryOptions.Test Plan
dotnet test test/Unit/Unit.csproj) onnet8.0andnet9.0net8.0; 4Test_ActionCache_Expiration_Slidingfailures onnet10.0are pre-existing (unrelated to this PR — none of the changed files touch the sliding expiration reset path)CacheJsonSerializerTests,CacheLockerBaseTests,AzureCosmosActionCacheTtlTestsActionCacheEntryOptionsTests,NamespaceTests,RedisActionCacheTests,RedisActionCacheFactoryTests,SqlServerActionCacheFactoryTests,ActionCacheKeyBuilderTests