fix(config): reject credential and cache paths inside the application directory

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.
This commit is contained in:
Joseph Doherty
2026-08-11 08:42:16 -04:00
parent c69a1c441b
commit 882c7ca3cd
8 changed files with 325 additions and 25 deletions
@@ -70,6 +70,8 @@ public static class GatewayApplication
});
StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration);
ApplyDefaultSecretsStorePath(builder.Configuration);
// Resolve ${secret:...} references in configuration BEFORE any config consumer (TLS, Kestrel,
// GatewayOptions/Ldap/Galaxy validators) reads a value, using a standalone secrets provider
// (envelope-decrypted via the master key). A token referencing a missing secret fails fast
@@ -186,6 +188,60 @@ public static class GatewayApplication
});
}
/// <summary>
/// Supplies the default location of the encrypted secrets store when nothing configured one.
/// </summary>
/// <remarks>
/// <para>
/// The store used to default to a bare relative <c>mxgateway-secrets.db</c>, which resolves
/// against the working directory and therefore normally lands inside the application directory.
/// That is the shape that lost every API key on a production host: the upgrade procedure renames
/// the application directory away, the store goes with it, and a fresh empty one appears in its
/// place with no error. In development the same default writes a database into the source tree.
/// </para>
/// <para>
/// This sets a default for an <em>unset</em> key; it never relocates a value someone configured.
/// That distinction matters — <see cref="Configuration.GatewayConfigPathRules"/> deliberately
/// rejects bad configured paths rather than quietly moving them, because silently relocating a
/// credential store is worse than a boot error. Choosing where to put a value nobody specified
/// is a different act from overriding one they did.
/// </para>
/// <para>
/// The location mirrors <c>AuthenticationOptions.SqlitePath</c> so both gateway stores sit
/// together, and the mechanism is the one SEC-33 already used for
/// <c>MxGateway:Galaxy:SnapshotCachePath</c> below — same problem, same fix, same file. It also
/// matches what <c>docs/GatewayConfiguration.md</c> already tells operators to
/// pass to the <c>secret</c> CLI — an absolute default also removes the CLI/gateway divergence
/// that a working-directory-relative path can cause. On non-Windows hosts
/// <see cref="Environment.SpecialFolder.CommonApplicationData"/> is typically not writable by a
/// normal user, so a local run there must set <c>Secrets__SqlitePath</c> explicitly, exactly as
/// it already must for the auth store.
/// </para>
/// <para>
/// <b>This deliberately differs from the <c>ZB.MOM.WW.Secrets</c> library default</b>, which is
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/>-derived so the family's
/// cross-platform apps still boot locally without an override. The gateway keeps
/// <c>CommonApplicationData</c> because it runs as a machine-wide Windows service and its other
/// two stores — the auth database and the Galaxy snapshot — already live there; splitting them
/// would be the greater inconsistency. The value set here always wins, so the library default is
/// unreachable in this app. Do not "fix" the difference by deleting this method: that would
/// silently move the store, which is the failure this whole rule exists to prevent.
/// </para>
/// </remarks>
/// <param name="configuration">The configuration to supply the default into.</param>
private static void ApplyDefaultSecretsStorePath(IConfiguration configuration)
{
if (!string.IsNullOrWhiteSpace(configuration["Secrets:SqlitePath"]))
{
return;
}
configuration["Secrets:SqlitePath"] = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"MxGateway",
"mxgateway-secrets.db");
}
private static void ConfigureSelfSignedTls(WebApplicationBuilder builder)
{
if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration))