Skip to content

fix: address 8 bugs from security and correctness audit#5

Merged
jzills merged 2 commits into
developfrom
fix/bug-audit-c1-c4-c5-h2-h4-h5-h7-m7
May 4, 2026
Merged

fix: address 8 bugs from security and correctness audit#5
jzills merged 2 commits into
developfrom
fix/bug-audit-c1-c4-c5-h2-h4-h5-h7-m7

Conversation

@jzills

@jzills jzills commented May 4, 2026

Copy link
Copy Markdown
Owner

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 CacheJsonSerializer

File: src/Common/Serialization/CacheJsonSerializer.cs, SafeSerializationBinder.cs, ActionArgumentsConverter.cs

TypeNameHandling.All embedded $type in 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 next GetAsync call.

Fix: Switched to TypeNameHandling.Auto (only embeds $type when the runtime type genuinely differs from the declared type — e.g. IActionResult → OkObjectResult) combined with a SafeSerializationBinder that 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 passes typeof(T) to SerializeObject and typeof(object) in ActionArgumentsConverter.WriteJson so the declared type is always visible to the serializer. This is a substantial improvement over the original: $type is emitted far less often, and every $type read during deserialization is validated before any type is instantiated.


C4 — Race condition: shared mutable Namespace.ValueWithRouteTemplateParameters

File: src/Common/Utilities/Namespace.cs

ValueWithRouteTemplateParameters was a plain { get; set; } on a record class. When a Namespace instance is shared across concurrent requests, one request's AttachRouteValues call 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 namespace Value. Each async execution context (i.e. each request) gets its own resolved value; concurrent requests cannot interfere.


C5 — Misleading name: HasExpiredSlidingExpiration does not check expiration

File: src/Common/Caching/ActionCacheEntryOptions.cs

The 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 with HasExpiredAbsoluteExpiration, which would mislead future maintainers.

Fix: Renamed to HasSlidingExpiration. Updated both call sites in RedisActionCache.cs and AzureCosmosCache.cs.


H2 — Null reference: ActionCacheKeyBuilder.WithActionArguments

File: src/Common/Keys/ActionCacheKeyBuilder.cs

Passing null as actionArguments caused an immediate NullReferenceException on actionArguments.ToDictionary().

Fix: Added if (actionArguments is null) return this; — null is treated as "no arguments provided".


H4 — Silent write failures: CommandFlags.FireAndForget in RedisActionCache.SetAsync

File: src/Redis/RedisActionCache.cs

The primary SetAsync path passed CommandFlags.FireAndForget to ScriptEvaluateAsync. 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.SetAsync

File: src/AzureCosmos/AzureCosmosCache.cs

ttl / 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 else branches in CacheLockerBase

File: src/Common/Concurrency/CacheLockerBase.cs

Both WaitForLockThenAsync overloads had empty else { // 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 InvalidOperationException with the resource name so callers can apply their own recovery strategy.


M7 — Lock settings dropped by factory Create(ns, abs, slide) overloads

Files: src/Memory/MemoryActionCacheFactory.cs, src/Redis/RedisActionCacheFactory.cs, src/SqlServer/SqlServerActionCacheFactory.cs

All three factories constructed a fresh ActionCacheEntryOptions with only expiration fields set, silently resetting any configured LockDuration and LockTimeout back to their 5 s / 10 s defaults.

Fix: The overloads now copy LockDuration and LockTimeout from the global EntryOptions.


Test Plan

  • All 387 unit tests pass (dotnet test test/Unit/Unit.csproj) on net8.0 and net9.0
  • All 46 integration tests pass on net8.0; 4 Test_ActionCache_Expiration_Sliding failures on net10.0 are pre-existing (unrelated to this PR — none of the changed files touch the sliding expiration reset path)
  • New test files: CacheJsonSerializerTests, CacheLockerBaseTests, AzureCosmosActionCacheTtlTests
  • Updated test files: ActionCacheEntryOptionsTests, NamespaceTests, RedisActionCacheTests, RedisActionCacheFactoryTests, SqlServerActionCacheFactoryTests, ActionCacheKeyBuilderTests

jzills added 2 commits May 3, 2026 20:13
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.
@jzills jzills merged commit c761f7c into develop May 4, 2026
4 checks passed
@jzills jzills deleted the fix/bug-audit-c1-c4-c5-h2-h4-h5-h7-m7 branch May 6, 2026 02:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant