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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user