fix(SEC-33,SEC-34): host-meaningful path rooting; verification-cache invalidate race
SEC-33: make rooting host-meaningful and stop shipping foreign-platform literals. - Delete IsRootedForAnyPlatform; AddIfNotRooted now uses Path.IsPathRooted (current OS). - Promote AddIfNotRooted/AddIfInvalidPath to shared GatewayConfigPathRules so the new Galaxy validator reuses them and the two validators cannot drift. - Remove Authentication:SqlitePath and Galaxy:SnapshotCachePath Windows literals from appsettings.json; the CommonApplicationData-derived code defaults take over. The Galaxy default is seeded as a configuration value before AddZbGalaxyRepository (SnapshotCachePath is init-only, so a PostConfigure mutation cannot compile). - New GalaxyRepositoryOptionsValidator (ValidateOnStart) enforces a valid, host-rooted SnapshotCachePath when PersistSnapshot is true. - Root-cause the stray junk-named auth DB: host start eagerly builds AuthSqliteConnectionFactory; under the non-rooted Windows literal on macOS SQLite wrote it relative to the test bin CWD. The three real-host-start tests now pin SqlitePath to a temp path. SEC-34: verification cache Invalidate-vs-in-flight-repopulation race closed with a per-key generation counter (bump-before-evict, snapshot-then-recheck). The expiry cap (window 2) takes the documented fallback: the library verification identity carries no ExpiresUtc, so the cache cannot cap at the key's expiry (donor-library ask). GWC-24 rider: cap MxGateway:Events:QueueCapacity at int.MaxValue/2 so the derived checked(2 * EventChannelCapacity) in WorkerClient cannot overflow at session creation. SEC-35 (doc-only): note IsProduction() env-name semantics in GatewayConfiguration.md. Docs updated same commit (GatewayConfiguration.md, Authentication.md) and tracking registers/change-log flipped (00-tracking.md, 40-security-dashboard.md).
This commit is contained in:
@@ -39,15 +39,30 @@ public interface IApiKeyCacheInvalidator
|
||||
/// on every cache miss.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Correctness on mutation is provided by two mechanisms: gateway-initiated revoke/rotate/delete
|
||||
/// call <see cref="Invalidate"/> directly (see <c>DashboardApiKeyManagementService</c>), and the
|
||||
/// short TTL is the backstop for out-of-band mutations (a direct DB edit, or a revoke issued by the
|
||||
/// separate <c>apikey</c> CLI process, whose in-memory cache is not this process's cache).
|
||||
/// Gateway-initiated revoke/rotate/delete call <see cref="Invalidate"/> directly (see
|
||||
/// <c>DashboardApiKeyManagementService</c>), which bumps a per-key generation counter <em>before</em>
|
||||
/// evicting so an in-flight verification that started under the old generation discards its own
|
||||
/// repopulation (set-then-recheck in <c>VerifyAsync</c>) — making the "gateway-initiated mutations
|
||||
/// take effect immediately" contract true even against a verify that was already in the inner library
|
||||
/// when the revoke landed (SEC-34 window 3).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The short TTL is the backstop for two remaining bounded-staleness windows. (1) Out-of-band
|
||||
/// mutations — a direct DB edit, or a revoke issued by the separate <c>apikey</c> CLI process whose
|
||||
/// in-memory cache is not this process's cache — take effect only after the TTL elapses. (2) A key
|
||||
/// whose expiry passes while cached keeps authenticating for up to the TTL: expiry is enforced by the
|
||||
/// inner library verifier, which a cache hit never reaches, and the verification identity the library
|
||||
/// returns (<c>ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity</c>) carries no expiry timestamp, so
|
||||
/// the cache cannot cap an entry at the key's <c>ExpiresUtc</c>. Capping it requires the donor library
|
||||
/// to surface expiry on the verification identity (donor-library ask); until then the TTL bounds this
|
||||
/// window and it is intentionally kept short (<see cref="SecurityOptions.ApiKeyVerificationCacheSeconds"/>,
|
||||
/// default 15 s).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalidator
|
||||
{
|
||||
private const string CacheKeyPrefix = "mxgw:apikeyverif:";
|
||||
private const string TokenPrefix = "mxgw";
|
||||
|
||||
private readonly IApiKeyVerifier _inner;
|
||||
private readonly IMemoryCache _cache;
|
||||
@@ -58,6 +73,11 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _keyIdIndex =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
// keyId -> monotonic generation, bumped by Invalidate before it evicts. A VerifyAsync snapshots
|
||||
// the generation before calling the inner verifier and only caches if it is unchanged after the
|
||||
// inner call and again after the Set — closing the revoke-vs-in-flight-repopulation race.
|
||||
private readonly ConcurrentDictionary<string, long> _generations = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CachingApiKeyVerifier"/> class.</summary>
|
||||
/// <param name="inner">The wrapped verifier (the library verifier) reached on a cache miss.</param>
|
||||
/// <param name="cache">The shared memory cache.</param>
|
||||
@@ -105,20 +125,58 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Snapshot the key's generation BEFORE the inner verify so a concurrent Invalidate that runs
|
||||
// while we are in the library is detected below and its repopulation discarded.
|
||||
string? tokenKeyId = TryParseKeyId(authorizationHeader);
|
||||
long generationAtStart = tokenKeyId is null ? 0 : ReadGeneration(tokenKeyId);
|
||||
|
||||
ApiKeyVerification result = await _inner.VerifyAsync(authorizationHeader, ct).ConfigureAwait(false);
|
||||
|
||||
if (result.Succeeded && result.Identity is not null)
|
||||
{
|
||||
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _ttl,
|
||||
});
|
||||
IndexCacheKey(result.Identity.KeyId, cacheKey);
|
||||
TryCacheSuccess(cacheKey, result, tokenKeyId, generationAtStart);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Caches a successful verification unless the key was invalidated while the inner verify was in
|
||||
// flight (SEC-34 window 3, detected via the generation snapshot). The entry lifetime stays the
|
||||
// TTL: the library verification identity carries no expiry, so the cache cannot cap at the key's
|
||||
// ExpiresUtc — that window remains TTL-bounded and documented in the class remarks.
|
||||
private void TryCacheSuccess(
|
||||
string cacheKey,
|
||||
ApiKeyVerification result,
|
||||
string? tokenKeyId,
|
||||
long generationAtStart)
|
||||
{
|
||||
// If Invalidate bumped the generation while we were verifying, the identity we hold may be
|
||||
// stale — do not cache it.
|
||||
if (tokenKeyId is not null && ReadGeneration(tokenKeyId) != generationAtStart)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string keyId = result.Identity!.KeyId;
|
||||
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _ttl,
|
||||
});
|
||||
IndexCacheKey(keyId, cacheKey);
|
||||
|
||||
// Set-then-recheck: an Invalidate that landed between the pre-Set check and IndexCacheKey
|
||||
// would have missed this entry (it was not yet indexed). If the generation has moved, evict
|
||||
// the just-written entry so the revoke still takes effect immediately.
|
||||
if (tokenKeyId is not null && ReadGeneration(tokenKeyId) != generationAtStart)
|
||||
{
|
||||
_cache.Remove(cacheKey);
|
||||
if (_keyIdIndex.TryGetValue(keyId, out ConcurrentDictionary<string, byte>? set))
|
||||
{
|
||||
set.TryRemove(cacheKey, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate(string keyId)
|
||||
{
|
||||
@@ -127,6 +185,10 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
return;
|
||||
}
|
||||
|
||||
// Bump the generation BEFORE evicting so an in-flight VerifyAsync that snapshotted the old
|
||||
// generation refuses to cache (or self-evicts) its now-stale repopulation.
|
||||
_generations.AddOrUpdate(keyId, 1, static (_, current) => current + 1);
|
||||
|
||||
if (_keyIdIndex.TryRemove(keyId, out ConcurrentDictionary<string, byte>? cacheKeys))
|
||||
{
|
||||
foreach (string cacheKey in cacheKeys.Keys)
|
||||
@@ -136,6 +198,37 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
}
|
||||
}
|
||||
|
||||
private long ReadGeneration(string keyId) => _generations.TryGetValue(keyId, out long generation)
|
||||
? generation
|
||||
: 0;
|
||||
|
||||
// Parses the key id out of a "Bearer mxgw_<keyId>_<secret>" header without any store access —
|
||||
// the same split the authorization interceptor does. Returns null for a header this cache cannot
|
||||
// attribute to a key id (in which case the generation race-guard is simply not applied).
|
||||
private static string? TryParseKeyId(string? authorizationHeader)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authorizationHeader))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ReadOnlySpan<char> header = authorizationHeader.AsSpan().Trim();
|
||||
const string bearer = "Bearer ";
|
||||
ReadOnlySpan<char> token = header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
|
||||
? header[bearer.Length..].Trim()
|
||||
: header;
|
||||
|
||||
string[] parts = token.ToString().Split('_');
|
||||
if (parts.Length < 3
|
||||
|| !string.Equals(parts[0], TokenPrefix, StringComparison.Ordinal)
|
||||
|| parts[1].Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
private void IndexCacheKey(string keyId, string cacheKey)
|
||||
{
|
||||
ConcurrentDictionary<string, byte> set = _keyIdIndex.GetOrAdd(
|
||||
|
||||
Reference in New Issue
Block a user