882c7ca3cd
Rooted is not the same as safe, and the gap between the two cost a production
host every one of its API keys on 2026-08-09.
MxGateway:Authentication:SqlitePath was set to an absolute path inside the
directory the upgrade procedure renames to Server.bak.*. That passes the
existing rooted check cleanly. The deploy renamed the directory away, the store
went with it, and the gateway created a fresh empty one at the same path — no
error, no log line. No gRPC consumer could authenticate for two days. The deploy
itself was correct: the binaries were the point of the rename and the store was
collateral.
GatewayConfigPathRules gains AddIfUnderContentRoot, applied to the auth store
and the Galaxy snapshot. Both are written by the running process and both are
lost the same way. The rule compares resolved full paths and requires a
directory-separator boundary, so a sibling directory whose name merely starts
with the content root's ("/srv/app-data" against "/srv/app") is not treated as
inside it — on a fail-closed startup rule, that false positive would be a
gateway that refuses to boot on a legitimate path. Case sensitivity follows the
running OS rather than assuming case-insensitivity everywhere, which would
reject /srv/App as under /srv/app on Linux where they are different directories.
The rule is not exempted in Development. An environment-conditional guard is
never exercised where the mistake is made, and what failed in production was a
config that looked fine.
Secrets:SqlitePath is the same defect one layer down: it shipped as a bare
relative "mxgateway-secrets.db", which is how a stray database landed in
src/…Server/ and tripped the repository's tree-hygiene test. It is bound by the
shared ZB.MOM.WW.Secrets package, so appsettings.json now ships no value and the
default is computed from CommonApplicationData in code — the same mechanism
SEC-33 already used for the Galaxy snapshot, ten lines away, for the same reason.
Setting a default for an unset key is deliberately not the same act as
relocating a value someone configured, which these rules still refuse to do.
Note the migration edge this creates: a host relying on the old repo default now
looks somewhere new, finds nothing, and creates an empty store — this bug
re-introduced by its own fix. Deployed hosts are safe because they set the path
explicitly, in appsettings copied forward or in the service environment. The
latter is the more robust of the two, since it cannot be lost by a missed
preserve step.
126 lines
5.3 KiB
C#
126 lines
5.3 KiB
C#
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 an absolute snapshot path inside the application directory fails. Rooted is not the
|
|
/// same as safe: the upgrade procedure renames that directory, so a snapshot cached there is
|
|
/// discarded on every deploy and the gateway starts cold each time.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Validate_Fails_WhenSnapshotPathIsUnderContentRoot()
|
|
{
|
|
string contentRoot = Path.Combine(Path.GetTempPath(), $"mxgw-root-{Guid.NewGuid():N}");
|
|
GalaxyRepositoryOptions options = new()
|
|
{
|
|
PersistSnapshot = true,
|
|
SnapshotCachePath = Path.Combine(contentRoot, "galaxy-snapshot.json"),
|
|
};
|
|
|
|
ValidateOptionsResult result =
|
|
new GalaxyRepositoryOptionsValidator(contentRoot).Validate(null, options);
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains(
|
|
result.Failures!,
|
|
f => f.Contains("MxGateway:Galaxy:SnapshotCachePath")
|
|
&& f.Contains("must not be inside the application directory"));
|
|
}
|
|
|
|
/// <summary>Verifies a snapshot path outside the application directory still passes.</summary>
|
|
[Fact]
|
|
public void Validate_Succeeds_WhenSnapshotPathIsOutsideContentRoot()
|
|
{
|
|
string contentRoot = Path.Combine(Path.GetTempPath(), $"mxgw-root-{Guid.NewGuid():N}");
|
|
GalaxyRepositoryOptions options = new()
|
|
{
|
|
PersistSnapshot = true,
|
|
SnapshotCachePath =
|
|
Path.Combine(Path.GetTempPath(), $"mxgw-data-{Guid.NewGuid():N}", "galaxy-snapshot.json"),
|
|
};
|
|
|
|
ValidateOptionsResult result =
|
|
new GalaxyRepositoryOptionsValidator(contentRoot).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));
|
|
}
|
|
}
|