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:
@@ -105,6 +105,8 @@ authorization code consumes.
|
||||
|
||||
The gateway keeps API key state in a dedicated SQLite database. SQLite is sufficient because credential volume is small, the gateway runs as a single process, and the file is straightforward to back up and rotate independently of the main application data.
|
||||
|
||||
The database path is `GatewayOptions.Authentication.SqlitePath`. Its code default is derived from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` (`C:\ProgramData\MxGateway\gateway-auth.db` on Windows, `/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) so the credential store is never written relative to the launch working directory on a non-Windows host. The production hosts pin the explicit Windows path in `appsettings.json`. `GatewayOptionsValidator` rejects a non-rooted (relative) `SqlitePath` so a bad override fails fast at startup rather than scattering the store by launch CWD (SEC-01).
|
||||
|
||||
### Connection factory
|
||||
|
||||
`AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and builds a connection string in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning. Connection pooling is enabled and the connection string carries a non-zero `DefaultTimeout`:
|
||||
|
||||
@@ -93,12 +93,15 @@ Environment variables use the normal .NET double-underscore form. For example,
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Authentication:Mode` | `ApiKey` | Selects public gRPC authentication. Supported values are `ApiKey` and `Disabled`. `Disabled` bypasses API-key verification and is for local development only. |
|
||||
| `MxGateway:Authentication:SqlitePath` | `C:\ProgramData\MxGateway\gateway-auth.db` | SQLite database path for API-key records and audit rows when API-key authentication is enabled. |
|
||||
| `MxGateway:Authentication:SqlitePath` | derived from `CommonApplicationData` (`C:\ProgramData\MxGateway\gateway-auth.db` on Windows, `/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) | SQLite database path for API-key records and audit rows when API-key authentication is enabled. The code default is built from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` so the credential store never lands in the launch working directory on a non-Windows host; the production hosts pin the explicit Windows path in `appsettings.json`, which overrides the code default. |
|
||||
| `MxGateway:Authentication:PepperSecretName` | `MxGateway:ApiKeyPepper` | Configuration key used to read the HMAC pepper for API-key secret hashing. The dashboard effective configuration redacts this value. |
|
||||
| `MxGateway:Authentication:RunMigrationsOnStartup` | `true` | Runs SQLite auth schema migrations at gateway startup when API-key authentication is enabled. |
|
||||
|
||||
When `Mode` is `ApiKey`, `SqlitePath` and `PepperSecretName` must be present.
|
||||
`SqlitePath` must be a valid filesystem path.
|
||||
`SqlitePath` must be a valid filesystem path and must be **rooted** (absolute):
|
||||
the validator rejects a non-rooted path so a relative override cannot silently
|
||||
resolve against the working directory and scatter the credential store by launch
|
||||
CWD (SEC-01).
|
||||
|
||||
## Worker Options
|
||||
|
||||
@@ -177,7 +180,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
|
||||
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
||||
| `MxGateway:Dashboard:ShowTagValues` | `false` | Reserved display control for tag values. The dashboard does not show full tag values by default. |
|
||||
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
|
||||
| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. |
|
||||
| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. |
|
||||
| `MxGateway:Dashboard:AutoLoginUser` | `(null)` | Username stamped on the synthetic principal when `DisableLogin` is `true`. Default `(null)` — a null or blank value falls back to `multi-role`. Has no effect when `DisableLogin` is `false`. |
|
||||
|
||||
`SnapshotIntervalMilliseconds` must be greater than zero. `RecentFaultLimit`
|
||||
@@ -219,6 +222,31 @@ When the dashboard is enabled, three hubs are mapped under `/hubs/*`:
|
||||
token for the calling user; the Blazor pages use it via
|
||||
`DashboardHubConnectionFactory` to authenticate the SignalR connection.
|
||||
|
||||
## LDAP Options (Dashboard Login)
|
||||
|
||||
The `MxGateway:Ldap` section configures the LDAP bind used by dashboard `/login`
|
||||
(the gRPC API-key model is separate). The shipped defaults are the shared
|
||||
dev/test GLAuth posture (`glauth.md`), not a production posture.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Ldap:Enabled` | `true` | Enables LDAP-backed dashboard login. |
|
||||
| `MxGateway:Ldap:Server` | `localhost` | LDAP server host. Dev points at the shared GLAuth instance (`10.100.0.35`). |
|
||||
| `MxGateway:Ldap:Port` | `3893` | LDAP server port. Must be between `1` and `65535`. |
|
||||
| `MxGateway:Ldap:Transport` | `None` | Connection transport: `None` (plaintext), `StartTls` (upgrade), or `Ldaps` (implicit TLS). The dev default is `None` because the shared GLAuth instance has LDAPS disabled and binds send cleartext. **Production hard-stop (SEC-06):** when the host runs in the `Production` environment, `Transport` must not be `None` — startup validation fails otherwise. Deployed hosts must set `Ldaps` or `StartTls`. |
|
||||
| `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. |
|
||||
| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. |
|
||||
| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. |
|
||||
| `MxGateway:Ldap:ServiceAccountPassword` | `serviceaccount123` | Search-account password. **Dev-only committed value.** On the deployed hosts, do not ship this in `appsettings.json`; supply it out-of-band via the env-var override `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form, as set on the NSSM-wrapped services). The committed `serviceaccount123` is a shared dev credential for the local GLAuth instance and should be rotated; production must use a secret it does not share with dev. |
|
||||
| `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. |
|
||||
| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. |
|
||||
| `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). |
|
||||
|
||||
When LDAP is enabled, `Server`, `SearchBase`, `ServiceAccountDn`,
|
||||
`ServiceAccountPassword`, and the attribute names must be non-blank, and `Port`
|
||||
must be in range. See `glauth.md` for the shared dev instance and the
|
||||
dev→production hardening posture.
|
||||
|
||||
## Protocol Options
|
||||
|
||||
| Option | Default | Description |
|
||||
@@ -238,7 +266,7 @@ at startup.
|
||||
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout for all Galaxy browse RPCs. |
|
||||
| `MxGateway:Galaxy:DashboardRefreshIntervalSeconds` | `30` | Interval between background refreshes of the dashboard Galaxy summary cache. SQL is hit at most once per interval regardless of dashboard render rate. |
|
||||
| `MxGateway:Galaxy:PersistSnapshot` | `true` | Persists the latest successful Galaxy browse dataset to disk. When `true`, the cache reloads that snapshot at startup so clients can still browse last-known data while the Galaxy database is unreachable. The restored data is served with `Stale` status until a live query confirms it. |
|
||||
| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). |
|
||||
| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). Set an **absolute** path — this option is bound by the shared `ZB.MOM.WW.GalaxyRepository` package (not by `GatewayOptions`), so the gateway validator does not enforce rooting on it; a relative value would resolve against the launch working directory (SEC-01). |
|
||||
|
||||
See [Galaxy Repository Browse](./GalaxyRepository.md) for the RPC surface and
|
||||
behavior.
|
||||
@@ -442,7 +470,7 @@ them.
|
||||
|
||||
| Option | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated certificate is persisted |
|
||||
| `Tls:SelfSignedCertPath` | derived from `CommonApplicationData` (`C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` on Windows, `/usr/share/MxGateway/certs/gateway-selfsigned.pfx` or the container equivalent elsewhere) | Where the generated certificate is persisted. The code default is built from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` so a generated private key never lands in the launch working directory on a non-Windows host (SEC-01). Must be **rooted** (absolute); the validator rejects a non-rooted override. |
|
||||
| `Tls:ValidityYears` | `10` | Lifetime of the generated certificate (validated 1–100) |
|
||||
| `Tls:AdditionalDnsNames` | `[]` | Extra DNS SANs (e.g. a load-balancer name) |
|
||||
| `Tls:RegenerateIfExpired` | `true` | Replace an expired persisted certificate instead of failing |
|
||||
|
||||
@@ -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