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,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);
}
}
}