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:
Joseph Doherty
2026-08-07 06:36:01 -04:00
parent 3f854d6cbf
commit 7e7f7cad84
15 changed files with 548 additions and 112 deletions
@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.MxGateway.Server;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
/// <summary>
/// Gateway-owned validation of the shared <see cref="GalaxyRepositoryOptions"/> (SEC-33): the Galaxy
/// package binds the options but ships no validator, so the gateway rejects a persisted snapshot
/// whose path is blank, invalid, or non-rooted on the running host, and supplies a rooted per-OS
/// default when the shipped config leaves the path blank.
/// </summary>
public sealed class GalaxyRepositoryOptionsValidatorTests
{
/// <summary>Verifies a blank snapshot path with persistence enabled fails validation.</summary>
[Fact]
public void Validate_Fails_WhenPersistSnapshotAndPathBlank()
{
GalaxyRepositoryOptions options = new() { PersistSnapshot = true, SnapshotCachePath = "" };
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Galaxy:SnapshotCachePath"));
}
/// <summary>Verifies a non-rooted snapshot path with persistence enabled fails validation.</summary>
[Fact]
public void Validate_Fails_WhenPersistSnapshotAndPathNotRooted()
{
GalaxyRepositoryOptions options = new()
{
PersistSnapshot = true,
SnapshotCachePath = "galaxy-snapshot.json",
};
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Galaxy:SnapshotCachePath") && f.Contains("rooted"));
}
/// <summary>Verifies a blank path passes when persistence is disabled (the path is never used).</summary>
[Fact]
public void Validate_Succeeds_WhenPersistSnapshotDisabled()
{
GalaxyRepositoryOptions options = new() { PersistSnapshot = false, SnapshotCachePath = "" };
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>Verifies a valid, host-rooted snapshot path with persistence enabled passes.</summary>
[Fact]
public void Validate_Succeeds_WhenPersistSnapshotAndPathRooted()
{
GalaxyRepositoryOptions options = new()
{
PersistSnapshot = true,
SnapshotCachePath = Path.Combine(Path.GetTempPath(), "galaxy-snapshot.json"),
};
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>
/// Verifies the gateway supplies a rooted per-OS default when the shipped config leaves
/// SnapshotCachePath blank, so the removed appsettings literal is not needed and validation
/// passes on any host (SEC-33). Resolving the options triggers the registered validator.
/// </summary>
[Fact]
public void Build_DefaultsBlankSnapshotCachePathToRootedDefault()
{
using WebApplication app = GatewayApplication.Build([]);
GalaxyRepositoryOptions options =
app.Services.GetRequiredService<IOptions<GalaxyRepositoryOptions>>().Value;
Assert.False(string.IsNullOrWhiteSpace(options.SnapshotCachePath));
Assert.True(Path.IsPathRooted(options.SnapshotCachePath));
}
}
@@ -598,6 +598,61 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
}
/// <summary>
/// Verifies rooting is host-meaningful (SEC-33): a Windows drive-qualified literal fails on a
/// Unix host (where it is not rooted) rather than being blessed and written as a junk-named
/// relative file. On Windows the same literal is genuinely rooted and passes.
/// </summary>
[Fact]
public void Validate_SqlitePath_RootingIsHostMeaningful()
{
const string windowsLiteral = @"C:\ProgramData\MxGateway\gateway-auth.db";
GatewayOptions options = CloneWithAuthentication(
ValidOptions(),
new AuthenticationOptions { SqlitePath = windowsLiteral });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
if (OperatingSystem.IsWindows())
{
Assert.True(result.Succeeded);
}
else
{
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
}
}
/// <summary>
/// Verifies MxGateway:Events:QueueCapacity above int.MaxValue/2 fails (GWC-24 rider): the value
/// flows into WorkerClient as checked(2 * EventChannelCapacity), which would otherwise overflow.
/// </summary>
[Fact]
public void Validate_Fails_WhenQueueCapacityExceedsUpperBound()
{
GatewayOptions options = CloneWithEvents(
ValidOptions(),
new EventOptions { QueueCapacity = (int.MaxValue / 2) + 1 });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Events:QueueCapacity"));
}
/// <summary>Verifies MxGateway:Events:QueueCapacity at exactly int.MaxValue/2 passes (boundary).</summary>
[Fact]
public void Validate_Succeeds_WhenQueueCapacityAtUpperBound()
{
GatewayOptions options = CloneWithEvents(
ValidOptions(),
new EventOptions { QueueCapacity = int.MaxValue / 2 });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>Verifies a non-rooted <see cref="TlsOptions.SelfSignedCertPath"/> fails validation.</summary>
[Fact]
public void Validate_Fails_WhenSelfSignedCertPathNotRooted()
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
@@ -80,8 +81,13 @@ public sealed class GatewayApplicationTests
public async Task Build_MapsMetricsEndpoint()
{
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
// started-host test must avoid a fixed port to prevent a bind collision.
await using WebApplication app = GatewayApplication.Build(["--urls=http://127.0.0.1:0"]);
// started-host test must avoid a fixed port to prevent a bind collision. Starting the host
// eagerly opens the auth SQLite store; the shipped config no longer carries a SqlitePath, so
// override it to a writable temp path (the code default resolves under an unwritable
// /usr/share on macOS). See SEC-33.
using TempDatabaseDirectory authDir = TempDatabaseDirectory.Create(nameof(GatewayApplicationTests));
await using WebApplication app = GatewayApplication.Build(
["--urls=http://127.0.0.1:0", $"--MxGateway:Authentication:SqlitePath={authDir.DatabasePath()}"]);
await app.StartAsync();
try
{
@@ -258,9 +264,13 @@ public sealed class GatewayApplicationTests
string expectedFailure)
{
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
// WebApplication-building test must avoid a fixed port to prevent a bind collision.
// WebApplication-building test must avoid a fixed port to prevent a bind collision. Override
// the auth SqlitePath to a writable temp path: startup opens the store before the injected
// misconfiguration is validated on some paths, and the code-default path is unwritable on
// macOS (SEC-33).
using TempDatabaseDirectory authDir = TempDatabaseDirectory.Create(nameof(GatewayApplicationTests));
await using WebApplication app = GatewayApplication.Build(
[$"--{key}={value}", "--urls=http://127.0.0.1:0"]);
[$"--{key}={value}", "--urls=http://127.0.0.1:0", $"--MxGateway:Authentication:SqlitePath={authDir.DatabasePath()}"]);
OptionsValidationException exception = await Assert.ThrowsAsync<OptionsValidationException>(
() => app.StartAsync());
@@ -35,6 +35,11 @@ public sealed class GatewayTlsBootstrapTests
Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", "https://127.0.0.1:0");
Environment.SetEnvironmentVariable(
"MxGateway__Tls__SelfSignedCertPath", Path.Combine(certDir, "gw.pfx"));
// Starting the host opens the auth SQLite store; the shipped config no longer ships a
// SqlitePath and the code default is unwritable on macOS (/usr/share), so pin it to the
// writable temp dir. See SEC-33.
Environment.SetEnvironmentVariable(
"MxGateway__Authentication__SqlitePath", Path.Combine(certDir, "gateway-auth.db"));
WebApplication app = GatewayApplication.Build([]);
await app.StartAsync();
@@ -53,6 +58,8 @@ public sealed class GatewayTlsBootstrapTests
{
Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", null);
Environment.SetEnvironmentVariable("MxGateway__Tls__SelfSignedCertPath", null);
Environment.SetEnvironmentVariable("MxGateway__Authentication__SqlitePath", null);
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
Directory.Delete(certDir, recursive: true);
}
}
@@ -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>