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:
@@ -97,6 +97,36 @@ public sealed class CachingApiKeyVerifierTests
|
||||
Assert.Equal(2, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SEC-34 window 3: an <see cref="IApiKeyCacheInvalidator.Invalidate"/> that lands while a
|
||||
/// verification is in flight in the inner library must discard that verification's repopulation,
|
||||
/// so the very next request re-verifies (revoke takes effect immediately, not after the TTL).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invalidate_DuringInFlightVerification_DiscardsStaleRepopulation()
|
||||
{
|
||||
GatedVerifier inner = new(Success("operator01"));
|
||||
using MemoryCache cache = NewCache();
|
||||
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(30));
|
||||
|
||||
// Begin a verification and wait until it is parked inside the inner verifier.
|
||||
Task<ApiKeyVerification> inFlight = verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
await inner.Entered;
|
||||
|
||||
// Revoke while the verify is still awaiting the inner library.
|
||||
((IApiKeyCacheInvalidator)verifier).Invalidate("operator01");
|
||||
|
||||
// Release the inner verifier; the in-flight call completes but must NOT cache its result.
|
||||
inner.Release();
|
||||
ApiKeyVerification result = await inFlight;
|
||||
Assert.True(result.Succeeded);
|
||||
|
||||
// The follow-up request finds no cached entry and reaches the inner verifier again.
|
||||
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
Assert.Equal(2, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The store decorator coalesces repeated <c>MarkUsed</c> writes for the same key inside the
|
||||
/// window down to a single forwarded write — the ≤1/min guarantee for <c>last_used_utc</c>.
|
||||
@@ -196,6 +226,43 @@ public sealed class CachingApiKeyVerifierTests
|
||||
}
|
||||
}
|
||||
|
||||
// A verifier that parks inside VerifyAsync until Release() is called, so a test can interleave an
|
||||
// Invalidate with an in-flight verification.
|
||||
private sealed class GatedVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||
{
|
||||
private readonly TaskCompletionSource _entered =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private readonly TaskCompletionSource _release =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Gets the number of times <see cref="VerifyAsync"/> has been called.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <summary>Completes once the first call has entered and parked in the inner verifier.</summary>
|
||||
public Task Entered => _entered.Task;
|
||||
|
||||
/// <summary>Unparks the first (gated) call.</summary>
|
||||
public void Release() => _release.TrySetResult();
|
||||
|
||||
/// <summary>Records the call; the first call parks on the gate, later calls return immediately.</summary>
|
||||
/// <param name="authorizationHeader">The authorization header presented by the caller.</param>
|
||||
/// <param name="ct">A token to observe for cancellation.</param>
|
||||
/// <returns>The fixed verification result.</returns>
|
||||
public async Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||
{
|
||||
bool first = CallCount == 0;
|
||||
CallCount++;
|
||||
if (first)
|
||||
{
|
||||
_entered.TrySetResult();
|
||||
await _release.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeStore : IApiKeyStore
|
||||
{
|
||||
/// <summary>Gets the number of times <see cref="MarkUsedAsync"/> has been called.</summary>
|
||||
|
||||
Reference in New Issue
Block a user