fix(archreview): security config guards (SEC-01/04/06)
- SEC-01: derive AuthenticationOptions.SqlitePath / TlsOptions.SelfSignedCertPath defaults from SpecialFolder.CommonApplicationData (was Windows-literal strings that became CWD-relative files off-Windows); validator rejects non-rooted overrides; delete the stray C:\ProgramData\...gateway-auth.db files that materialized under src/; add a tree-hygiene test failing on any *.db under src/. - SEC-04: GatewayOptionsValidator fails startup when IsProduction() && DisableLogin. - SEC-06: validator rejects LDAP Transport=None in Production; document the MxGateway__Ldap__ServiceAccountPassword env override + dev-credential rotation. Galaxy SnapshotCachePath rooting could not be validator-enforced (bound by the external GalaxyRepository package, not GatewayOptions) — documented instead. archreview: SEC-01/04/06 (P1). Verified on the Auth-0.1.4 baseline: Server build clean, GatewayOptionsValidator + GatewayTreeHygiene tests 53/53.
This commit is contained in:
@@ -5,8 +5,18 @@ public sealed class AuthenticationOptions
|
||||
/// <summary>Gets the authentication mode.</summary>
|
||||
public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey;
|
||||
|
||||
/// <summary>Gets the SQLite database path for authentication credentials.</summary>
|
||||
public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db";
|
||||
/// <summary>
|
||||
/// Gets the SQLite database path for authentication credentials. The default is
|
||||
/// derived from <see cref="Environment.SpecialFolder.CommonApplicationData"/>
|
||||
/// (<c>C:\ProgramData</c> on Windows, <c>/usr/share</c> or the container equivalent
|
||||
/// elsewhere) so the credential store never lands in the launch working directory on a
|
||||
/// non-Windows host. The production hosts override this with an explicit path in
|
||||
/// <c>appsettings.json</c>; the validator rejects a non-rooted override.
|
||||
/// </summary>
|
||||
public string SqlitePath { get; init; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"MxGateway",
|
||||
"gateway-auth.db");
|
||||
|
||||
/// <summary>Gets the secret manager name for API key pepper.</summary>
|
||||
public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper";
|
||||
|
||||
@@ -9,15 +9,42 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
private const int MinimumMaxMessageBytes = 1024;
|
||||
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
||||
|
||||
// Whether the host is running in the Production environment. Drives the production-only
|
||||
// hard-stops (dashboard login disabled, plaintext LDAP transport) that must abort startup
|
||||
// rather than merely warn. Non-production hosts keep the permissive dev posture.
|
||||
private readonly bool _isProduction;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GatewayOptionsValidator"/> class for the
|
||||
/// dependency-injection path, deriving the production posture from the host environment.
|
||||
/// </summary>
|
||||
/// <param name="environment">The host environment.</param>
|
||||
public GatewayOptionsValidator(IHostEnvironment environment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
_isProduction = environment.IsProduction();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GatewayOptionsValidator"/> class for unit
|
||||
/// tests and non-DI callers. Defaults to a non-production posture so the production-only
|
||||
/// hard-stops do not fire; pass <see langword="true"/> to exercise them.
|
||||
/// </summary>
|
||||
/// <param name="isProduction">Whether to treat the host as running in Production.</param>
|
||||
internal GatewayOptionsValidator(bool isProduction = false)
|
||||
{
|
||||
_isProduction = isProduction;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, GatewayOptions options)
|
||||
{
|
||||
ValidateAuthentication(options.Authentication, builder);
|
||||
ValidateLdap(options.Ldap, builder);
|
||||
ValidateLdap(options.Ldap, builder, _isProduction);
|
||||
ValidateWorker(options.Worker, builder);
|
||||
ValidateSessions(options.Sessions, builder);
|
||||
ValidateEvents(options.Events, builder);
|
||||
ValidateDashboard(options.Dashboard, builder);
|
||||
ValidateDashboard(options.Dashboard, builder, _isProduction);
|
||||
ValidateProtocol(options.Protocol, builder);
|
||||
ValidateAlarms(options.Alarms, builder);
|
||||
ValidateTls(options.Tls, builder);
|
||||
@@ -41,6 +68,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
options.SqlitePath,
|
||||
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
||||
builder);
|
||||
AddIfNotRooted(
|
||||
options.SqlitePath,
|
||||
"MxGateway:Authentication:SqlitePath must be an absolute (rooted) path so the credential store never lands in the launch working directory.",
|
||||
builder);
|
||||
AddIfBlank(
|
||||
options.PepperSecretName,
|
||||
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
||||
@@ -48,7 +79,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLdap(LdapOptions options, ValidationBuilder builder)
|
||||
private static void ValidateLdap(LdapOptions options, ValidationBuilder builder, bool isProduction)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
{
|
||||
@@ -83,6 +114,16 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
{
|
||||
builder.Add("MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).");
|
||||
}
|
||||
|
||||
// Production hard-stop: plaintext LDAP binds send the operator's password in the clear.
|
||||
// The permissive dev default (Transport=None against the shared GLAuth instance) is
|
||||
// acceptable off-production but must never ship to a Production host. Deployed hosts must
|
||||
// set Transport=Ldaps/StartTls; see docs/GatewayConfiguration.md and glauth.md.
|
||||
if (isProduction && options.Transport == LdapTransport.None)
|
||||
{
|
||||
builder.Add(
|
||||
"MxGateway:Ldap:Transport must not be None (plaintext) in the Production environment; use Ldaps or StartTls.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateWorker(WorkerOptions options, ValidationBuilder builder)
|
||||
@@ -224,8 +265,19 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
$"MxGateway:Events:MaxSparseArrayLength must be between 1 and {Array.MaxLength}.");
|
||||
}
|
||||
|
||||
private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder)
|
||||
private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder, bool isProduction)
|
||||
{
|
||||
// Production hard-stop: DisableLogin swaps in an auto-login handler that authenticates
|
||||
// EVERY request (remote included) as AutoLoginUser holding both roles, turning the whole
|
||||
// dashboard — API-key CRUD and worker Kill included — into an unauthenticated admin
|
||||
// surface on a 0.0.0.0-bound port. It is a dev/test-only convenience; abort startup if it
|
||||
// is set in Production. Non-production hosts keep the existing runtime warning.
|
||||
if (isProduction && options.DisableLogin)
|
||||
{
|
||||
builder.Add(
|
||||
"MxGateway:Dashboard:DisableLogin must not be true in the Production environment; it disables all dashboard authentication.");
|
||||
}
|
||||
|
||||
// GroupToRole shape is validated even when the dashboard is disabled so
|
||||
// misconfiguration surfaces at startup; emptiness is allowed, with the
|
||||
// consequence that no LDAP user can sign in (login returns "no roles
|
||||
@@ -348,6 +400,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
options.SelfSignedCertPath,
|
||||
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
|
||||
builder);
|
||||
AddIfNotRooted(
|
||||
options.SelfSignedCertPath,
|
||||
"MxGateway:Tls:SelfSignedCertPath must be an absolute (rooted) path so the generated private key never lands in the launch working directory.",
|
||||
builder);
|
||||
|
||||
foreach (string dns in options.AdditionalDnsNames)
|
||||
{
|
||||
@@ -388,6 +444,24 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
builder.RequireThat(value >= 0, message);
|
||||
}
|
||||
|
||||
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 (!Path.IsPathRooted(value))
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -7,9 +7,19 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
/// </summary>
|
||||
public sealed class TlsOptions
|
||||
{
|
||||
/// <summary>Path to the persisted self-signed PFX. Reused across restarts.</summary>
|
||||
public string SelfSignedCertPath { get; init; } =
|
||||
@"C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx";
|
||||
/// <summary>
|
||||
/// Path to the persisted self-signed PFX. Reused across restarts. The default is derived
|
||||
/// from <see cref="Environment.SpecialFolder.CommonApplicationData"/> (<c>C:\ProgramData</c>
|
||||
/// on Windows, <c>/usr/share</c> or the container equivalent elsewhere) so a generated
|
||||
/// private key never lands in the launch working directory on a non-Windows host. The
|
||||
/// production hosts override this with an explicit path; the validator rejects a non-rooted
|
||||
/// override.
|
||||
/// </summary>
|
||||
public string SelfSignedCertPath { get; init; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"MxGateway",
|
||||
"certs",
|
||||
"gateway-selfsigned.pfx");
|
||||
|
||||
/// <summary>Lifetime in years of a freshly generated certificate.</summary>
|
||||
public int ValidityYears { get; init; } = 10;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
|
||||
@@ -548,4 +549,138 @@ public sealed class GatewayOptionsValidatorTests
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SEC-01: security-sensitive paths must be rooted (absolute)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static GatewayOptions CloneWithAuthentication(GatewayOptions source, AuthenticationOptions authentication)
|
||||
=> new()
|
||||
{
|
||||
Authentication = authentication,
|
||||
Ldap = source.Ldap,
|
||||
Worker = source.Worker,
|
||||
Sessions = source.Sessions,
|
||||
Events = source.Events,
|
||||
Dashboard = source.Dashboard,
|
||||
Protocol = source.Protocol,
|
||||
Alarms = source.Alarms,
|
||||
Tls = source.Tls,
|
||||
};
|
||||
|
||||
/// <summary>Verifies the default (CommonApplicationData-derived) auth DB path is rooted and passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WithDefaultRootedSqlitePath()
|
||||
{
|
||||
// The default AuthenticationOptions.SqlitePath is derived from CommonApplicationData,
|
||||
// which is rooted on every host OS.
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions());
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-rooted <see cref="AuthenticationOptions.SqlitePath"/> fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenSqlitePathNotRooted()
|
||||
{
|
||||
GatewayOptions options = CloneWithAuthentication(
|
||||
ValidOptions(),
|
||||
new AuthenticationOptions { SqlitePath = "gateway-auth.db" });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-rooted <see cref="TlsOptions.SelfSignedCertPath"/> fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenSelfSignedCertPathNotRooted()
|
||||
{
|
||||
GatewayOptions options = CloneWithTls(
|
||||
ValidOptions(),
|
||||
new TlsOptions { SelfSignedCertPath = "gateway-selfsigned.pfx" });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Tls:SelfSignedCertPath") && f.Contains("rooted"));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SEC-04: DisableLogin production guard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static GatewayOptions CloneWithDashboard(GatewayOptions source, DashboardOptions dashboard)
|
||||
=> new()
|
||||
{
|
||||
Authentication = source.Authentication,
|
||||
Ldap = source.Ldap,
|
||||
Worker = source.Worker,
|
||||
Sessions = source.Sessions,
|
||||
Events = source.Events,
|
||||
Dashboard = dashboard,
|
||||
Protocol = source.Protocol,
|
||||
Alarms = source.Alarms,
|
||||
Tls = source.Tls,
|
||||
};
|
||||
|
||||
/// <summary>Verifies <see cref="DashboardOptions.DisableLogin"/> set to <see langword="true"/> aborts startup in Production.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenDisableLoginTrueInProduction()
|
||||
{
|
||||
GatewayOptions options = CloneWithDashboard(
|
||||
ValidOptions(),
|
||||
new DashboardOptions { DisableLogin = true });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Dashboard:DisableLogin") && f.Contains("Production"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies <see cref="DashboardOptions.DisableLogin"/> set to <see langword="true"/> is accepted outside Production.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenDisableLoginTrueInDevelopment()
|
||||
{
|
||||
GatewayOptions options = CloneWithDashboard(
|
||||
ValidOptions(),
|
||||
new DashboardOptions { DisableLogin = true });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: false).Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SEC-06: LDAP plaintext transport production guard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies plaintext LDAP transport (None) aborts startup in Production.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenLdapTransportNoneInProduction()
|
||||
{
|
||||
// The class default LDAP options ship Transport=None + AllowInsecure=true (dev posture).
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, ValidOptions());
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Ldap:Transport") && f.Contains("Production"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies plaintext LDAP transport (None) is accepted outside Production.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenLdapTransportNoneInDevelopment()
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: false).Validate(null, ValidOptions());
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies secure LDAP transport (Ldaps) passes validation in Production.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenLdapTransportLdapsInProduction()
|
||||
{
|
||||
GatewayOptions options = CloneWithLdap(
|
||||
ValidOptions(),
|
||||
new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps, AllowInsecure = false });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.ProjectStructure;
|
||||
|
||||
/// <summary>
|
||||
/// Repo-hygiene guards over the source tree. See SEC-01: a Windows-absolute default path that
|
||||
/// resolves relative to the launch working directory silently materializes a SQLite credential
|
||||
/// store inside the tree. This test fails if any such <c>*.db</c> file appears under <c>src/</c>.
|
||||
/// </summary>
|
||||
public sealed class GatewayTreeHygieneTests
|
||||
{
|
||||
/// <summary>Verifies no SQLite database files are committed/materialized under the source tree (build output excluded).</summary>
|
||||
[Fact]
|
||||
public void SourceTree_ContainsNoSqliteDatabaseFiles()
|
||||
{
|
||||
DirectoryInfo sourceRoot = FindSourceRoot();
|
||||
|
||||
string[] databaseFiles = Directory
|
||||
.EnumerateFiles(sourceRoot.FullName, "*.db", SearchOption.AllDirectories)
|
||||
.Where(path => !IsBuildOutput(path))
|
||||
.ToArray();
|
||||
|
||||
Assert.True(
|
||||
databaseFiles.Length == 0,
|
||||
"Unexpected *.db file(s) found under the source tree (build output excluded): "
|
||||
+ string.Join(", ", databaseFiles)
|
||||
+ ". A SQLite DB in the tree usually means a Windows-absolute default path resolved "
|
||||
+ "relative to the working directory; see SEC-01.");
|
||||
}
|
||||
|
||||
// Build output (bin/obj) is regenerated by test/compile runs and is gitignored, so exclude it.
|
||||
private static bool IsBuildOutput(string path)
|
||||
{
|
||||
string normalized = path.Replace('\\', '/');
|
||||
return normalized.Contains("/bin/", StringComparison.Ordinal)
|
||||
|| normalized.Contains("/obj/", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// The directory holding ZB.MOM.WW.MxGateway.slnx is the src/ root of the working tree.
|
||||
private static DirectoryInfo FindSourceRoot()
|
||||
{
|
||||
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
||||
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "ZB.MOM.WW.MxGateway.slnx")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.MxGateway.slnx from the test output directory.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user