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,41 @@
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.GalaxyRepository;
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>
/// Gateway-side startup validation for the shared <see cref="GalaxyRepositoryOptions"/>. The
/// <c>ZB.MOM.WW.GalaxyRepository</c> package binds the options but deliberately ships no validator
/// (see <c>A2-galaxyrepository-adoption-handoff.md</c>); the gateway owns the rule because it is the
/// process that writes the snapshot. When persistence is on, the snapshot path must be a valid,
/// rooted path on the running host for the same reason the auth DB path must be (SEC-33): a
/// non-rooted value silently resolves against the launch working directory.
/// </summary>
public sealed class GalaxyRepositoryOptionsValidator : OptionsValidatorBase<GalaxyRepositoryOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, GalaxyRepositoryOptions options)
{
if (!options.PersistSnapshot)
{
// Persistence disabled: the snapshot path is never used, so nothing to validate.
return;
}
if (string.IsNullOrWhiteSpace(options.SnapshotCachePath))
{
builder.Add(
"MxGateway:Galaxy:SnapshotCachePath is required when MxGateway:Galaxy:PersistSnapshot is true.");
return;
}
GatewayConfigPathRules.AddIfInvalidPath(
options.SnapshotCachePath,
"MxGateway:Galaxy:SnapshotCachePath must be a valid filesystem path.",
builder);
GatewayConfigPathRules.AddIfNotRooted(
options.SnapshotCachePath,
"MxGateway:Galaxy:SnapshotCachePath must be an absolute (rooted) path so the Galaxy snapshot never lands in the launch working directory.",
builder);
}
}
@@ -0,0 +1,77 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>
/// Shared filesystem-path validation primitives used by more than one options validator
/// (<see cref="GatewayOptionsValidator"/> and <see cref="GalaxyRepositoryOptionsValidator"/>).
/// Both the auth credential store and the Galaxy snapshot are written by the running gateway
/// process, so both must reject paths the host cannot use — the rules live here once so the two
/// validators cannot drift.
/// </summary>
internal static class GatewayConfigPathRules
{
/// <summary>
/// Fails validation when <paramref name="value"/> is not an absolute (rooted) path <em>on the
/// host running the validator</em>. Security-sensitive paths (the auth DB, the self-signed
/// private key, the Galaxy snapshot) must be absolute: a non-rooted value silently resolves
/// against the launch working directory, so the store moves with the CWD and can leak into the
/// source tree. Rooting is checked with <see cref="Path.IsPathRooted(string)"/> — the current
/// OS — so a Windows drive/UNC literal on a Unix host fails fast at startup rather than being
/// blessed and then written as a junk-named relative file (the SEC-01/SEC-33 mechanism). Reject
/// rather than auto-root; silent relocation of a credential store is worse than a boot error.
/// Blank is handled by the caller's required-field check and is not treated as non-rooted here.
/// </summary>
/// <param name="value">The configured path value.</param>
/// <param name="message">The failure message to record when the value is not rooted.</param>
/// <param name="builder">The validation builder accumulating failures.</param>
public static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
if (!Path.IsPathRooted(value))
{
builder.Add(message);
}
}
/// <summary>
/// Fails validation when <paramref name="value"/> is non-blank but not a syntactically valid
/// filesystem path (as judged by <see cref="Path.GetFullPath(string)"/>). Blank values are the
/// caller's required-field concern and pass here.
/// </summary>
/// <param name="value">The configured path value.</param>
/// <param name="message">The failure message to record when the value is not a valid path.</param>
/// <param name="builder">The validation builder accumulating failures.</param>
public static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
try
{
_ = Path.GetFullPath(value);
}
catch (ArgumentException)
{
builder.Add(message);
}
catch (NotSupportedException)
{
builder.Add(message);
}
catch (PathTooLongException)
{
builder.Add(message);
}
catch (IOException)
{
builder.Add(message);
}
}
}
@@ -292,10 +292,23 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
// knob, not incorrect behavior.
}
// QueueCapacity flows into WorkerClient as EventChannelCapacity, where the staging channel is
// sized checked(2 * EventChannelCapacity) (GWC-24). Cap it at int.MaxValue/2 so that doubling
// cannot overflow and throw OverflowException at session creation; mirrors the MaxSparseArrayLength
// upper-bound pattern.
private const int MaximumEventQueueCapacity = int.MaxValue / 2;
private static void ValidateEvents(EventOptions options, ValidationBuilder builder)
{
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder);
if (options.QueueCapacity > MaximumEventQueueCapacity)
{
builder.Add(
$"MxGateway:Events:QueueCapacity must be less than or equal to {MaximumEventQueueCapacity} "
+ "so the derived worker event-staging channel (2 x QueueCapacity) cannot overflow.");
}
if (!Enum.IsDefined(options.BackpressurePolicy))
{
builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
@@ -524,79 +537,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
builder.RequireThat(value >= 0, message);
}
// Path rooting/validity rules are shared with GalaxyRepositoryOptionsValidator (both write a
// host file) so the two validators cannot drift; see GatewayConfigPathRules. Rooting is checked
// against the running OS via Path.IsPathRooted — a Windows drive/UNC literal on a Unix host now
// fails fast instead of being blessed and written as a junk-named relative file (SEC-33).
private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
{
// Security-sensitive paths (the auth DB, the self-signed private key) must be absolute:
// a non-rooted value silently resolves against the launch working directory, so the store
// moves with the CWD and can leak into the source tree. Reject rather than auto-root —
// silent relocation of a credential store is worse than a boot error. Blank is handled by
// AddIfBlank; an empty value is not treated as non-rooted here.
if (string.IsNullOrWhiteSpace(value))
{
return;
}
if (!IsRootedForAnyPlatform(value))
{
builder.Add(message);
}
}
/// <summary>
/// Determines whether <paramref name="value"/> is an absolute path for <em>any</em> platform,
/// not just the host running the validator. This matters on the macOS dev box, where the
/// production <c>appsettings.json</c> ships Windows-absolute paths (<c>C:\ProgramData\...</c>)
/// that <see cref="Path.IsPathRooted(string)"/> reports as non-rooted on Unix. The intent of the
/// rooting check is to reject bare filenames that resolve against the launch working directory,
/// so a valid Windows drive-qualified or UNC path must pass regardless of the current OS.
/// </summary>
private static bool IsRootedForAnyPlatform(string value)
{
// Rooted on the current OS (Unix "/...", or a Windows drive/UNC path when on Windows).
if (Path.IsPathRooted(value))
{
return true;
}
// Windows drive-qualified path ("C:\..." or "C:/...") checked on a non-Windows host.
if (value.Length >= 3
&& char.IsLetter(value[0])
&& value[1] == ':'
&& (value[2] == '\\' || value[2] == '/'))
{
return true;
}
// Windows UNC path ("\\server\share") checked on a non-Windows host.
return value.StartsWith(@"\\", StringComparison.Ordinal);
}
=> GatewayConfigPathRules.AddIfNotRooted(value, message, builder);
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
try
{
_ = Path.GetFullPath(value);
}
catch (ArgumentException)
{
builder.Add(message);
}
catch (NotSupportedException)
{
builder.Add(message);
}
catch (PathTooLongException)
{
builder.Add(message);
}
catch (IOException)
{
builder.Add(message);
}
}
=> GatewayConfigPathRules.AddIfInvalidPath(value, message, builder);
}
@@ -134,8 +134,29 @@ public static class GatewayApplication
// library's TryAddSingleton default (NullGalaxyBrowseScopeProvider) does not win.
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
Security.Authorization.GatewayBrowseScopeProvider>();
// The Galaxy package binds GalaxyRepositoryOptions but ships no validator or default for the
// snapshot path (A2 handoff): the gateway owns both because it is the process that writes the
// snapshot. GalaxyRepositoryOptions.SnapshotCachePath is init-only, so the default cannot be
// applied via PostConfigure — supply it as a configuration value (before the bind) when the
// shipped config leaves it blank. It resolves to the per-OS CommonApplicationData location,
// byte-identical to the removed appsettings literal on Windows (SEC-33).
if (string.IsNullOrWhiteSpace(builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"]))
{
builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"] = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"MxGateway",
"galaxy-snapshot.json");
}
builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy");
// Validate that persistence has a valid, host-rooted snapshot path (SEC-33).
builder.Services.AddSingleton<
Microsoft.Extensions.Options.IValidateOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>,
Configuration.GalaxyRepositoryOptionsValidator>();
builder.Services.AddOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>().ValidateOnStart();
return builder;
}
@@ -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(
@@ -20,7 +20,6 @@
"MxGateway": {
"Authentication": {
"Mode": "ApiKey",
"SqlitePath": "C:\\ProgramData\\MxGateway\\gateway-auth.db",
"PepperSecretName": "MxGateway:ApiKeyPepper",
"RunMigrationsOnStartup": true
},
@@ -82,8 +81,7 @@
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
"CommandTimeoutSeconds": 60,
"DashboardRefreshIntervalSeconds": 30,
"PersistSnapshot": true,
"SnapshotCachePath": "C:\\ProgramData\\MxGateway\\galaxy-snapshot.json"
"PersistSnapshot": true
},
"Alarms": {
"Enabled": true,
@@ -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>