fix(admin): resolve High code-review findings (Admin-003, Admin-004, Admin-005)

Admin-003 — SignalR hubs were anonymously reachable: an unauthenticated
client could open /hubs/fleet, /hubs/alerts and /hubs/script-log and
stream fleet state, alert detail text and server script-log contents.
Added [Authorize] to FleetStatusHub, AlertHub and ScriptLogHub, and
chained .RequireAuthorization() onto all three MapHub() calls as a
belt-and-braces backstop.

Admin-004 — appsettings.json committed live-looking secrets (the `sa`
ConfigDb password and the LDAP ServiceAccountPassword) in plaintext.
Replaced both with empty placeholders sourced from user-secrets (dev) or
the ConnectionStrings__ConfigDb / Authentication__Ldap__ServiceAccountPassword
environment variables (prod); added a UserSecretsId to the Admin csproj
and a fail-fast guard in Program.cs when ConfigDb is empty/missing.

Admin-005 — Login.razor performed SignInAsync from an interactive Blazor
circuit, where the original HTTP response has long completed so the auth
cookie was not emitted. Rewrote it as a static-rendered plain HTML form
(data-enhance="false") posting to a new AuthEndpoints.MapAuthEndpoints()
minimal-API handler (/auth/login, /auth/logout) that does the LDAP bind,
grant resolution, cookie SignInAsync and redirect while the endpoint
still owns the response. Includes an open-redirect guard on returnUrl.

Added xUnit + Shouldly regression tests: AuthEndpointsTests (login cookie
issuance, failed-bind redirect, open-redirect rejection, logout, anonymous
hub negotiate rejection) and AppSettingsSecretHygieneTests (no committed
secrets). All 26 auth-related tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 06:27:11 -04:00
parent abbf49141c
commit 3de688f8d6
11 changed files with 447 additions and 108 deletions

View File

@@ -0,0 +1,79 @@
using System.Text.Json;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Regression coverage for Admin-004 — the committed <c>appsettings.json</c> must carry no
/// plaintext secrets. The <c>ConfigDb</c> connection string and the LDAP
/// <c>ServiceAccountPassword</c> are supplied at runtime via user-secrets (dev) or
/// environment variables (prod); the checked-in file holds only empty placeholders.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AppSettingsSecretHygieneTests
{
private static JsonDocument LoadAdminAppSettings()
{
// Walk up from the test assembly to the repo root (the dir holding the .slnx) and
// read the SOURCE appsettings.json — not a bin/ copy — so the test asserts on what
// is actually committed.
var dir = AppContext.BaseDirectory;
while (dir is not null && !File.Exists(Path.Combine(dir, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = Path.GetDirectoryName(dir);
dir.ShouldNotBeNull("could not locate the repo root (ZB.MOM.WW.OtOpcUa.slnx)");
var path = Path.Combine(dir, "src", "Server", "ZB.MOM.WW.OtOpcUa.Admin", "appsettings.json");
File.Exists(path).ShouldBeTrue($"Admin appsettings.json not found at {path}");
return JsonDocument.Parse(File.ReadAllText(path));
}
[Fact]
public void ConfigDb_connection_string_is_an_empty_placeholder()
{
using var doc = LoadAdminAppSettings();
var connectionString = doc.RootElement
.GetProperty("ConnectionStrings")
.GetProperty("ConfigDb")
.GetString();
connectionString.ShouldBeNullOrEmpty(
"the ConfigDb connection string must not be committed — supply it via user-secrets " +
"or the ConnectionStrings__ConfigDb environment variable (Admin-004)");
}
[Fact]
public void Ldap_service_account_password_is_an_empty_placeholder()
{
using var doc = LoadAdminAppSettings();
var password = doc.RootElement
.GetProperty("Authentication")
.GetProperty("Ldap")
.GetProperty("ServiceAccountPassword")
.GetString();
password.ShouldBeNullOrEmpty(
"the LDAP ServiceAccountPassword must not be committed (Admin-004)");
}
[Fact]
public void No_known_dev_secret_literals_appear_anywhere_in_appsettings()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !File.Exists(Path.Combine(dir, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = Path.GetDirectoryName(dir);
dir.ShouldNotBeNull();
var raw = File.ReadAllText(Path.Combine(
dir, "src", "Server", "ZB.MOM.WW.OtOpcUa.Admin", "appsettings.json"));
// The exact secret literals the review (Admin-004) flagged must be gone entirely —
// not relocated to another key, not present as a comment.
raw.ShouldNotContain("OtOpcUaDev_2026!");
raw.ShouldNotContain("serviceaccount123");
raw.ShouldNotContain("User Id=sa");
}
}

View File

@@ -0,0 +1,184 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Security;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Regression coverage for Admin-003 / Admin-005.
///
/// Admin-005 — the login is a static-rendered form posting to the <c>/auth/login</c>
/// minimal-API endpoint, which performs the LDAP bind, cookie <c>SignInAsync</c> and
/// redirect while it still owns the HTTP response (no interactive Blazor circuit).
///
/// Admin-003 — the three SignalR hubs reject anonymous connections.
///
/// These are HTTP-pipeline tests with a stubbed <see cref="ILdapAuthService"/>, so they
/// run without LDAP or the central SQL Server.
/// </summary>
public sealed class AuthEndpointsTests : IClassFixture<AuthEndpointsTests.StubbedAuthAppFactory>
{
private readonly StubbedAuthAppFactory _factory;
public AuthEndpointsTests(StubbedAuthAppFactory factory) => _factory = factory;
/// <summary>
/// Admin app host with the LDAP service stubbed (a fixed-credential pass/fail) and the
/// background poller removed so the host starts clean without DB or directory access.
/// </summary>
public sealed class StubbedAuthAppFactory : WebApplicationFactory<Program>
{
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureServices(services =>
{
var poller = services.SingleOrDefault(d =>
d.ImplementationType?.Name == "FleetStatusPoller");
if (poller is not null) services.Remove(poller);
var ldap = services.SingleOrDefault(d => d.ServiceType == typeof(ILdapAuthService));
if (ldap is not null) services.Remove(ldap);
services.AddScoped<ILdapAuthService, StubLdapAuthService>();
var resolver = services.SingleOrDefault(d => d.ServiceType == typeof(IAdminRoleGrantResolver));
if (resolver is not null) services.Remove(resolver);
services.AddScoped<IAdminRoleGrantResolver, StubRoleGrantResolver>();
});
return base.CreateHost(builder);
}
public HttpClient CreateNonRedirectingClient() =>
CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
}
/// <summary>Stub LDAP: <c>good</c>/<c>pw</c> binds; anything else fails.</summary>
private sealed class StubLdapAuthService : ILdapAuthService
{
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default) =>
Task.FromResult(username == "good" && password == "pw"
? new LdapAuthResult(true, "Good Operator", "good", ["FleetAdmins"], ["FleetAdmin"], null)
: new LdapAuthResult(false, null, username, [], [], "Invalid username or password"));
}
/// <summary>Stub resolver: any non-empty group set yields a FleetAdmin grant.</summary>
private sealed class StubRoleGrantResolver : IAdminRoleGrantResolver
{
public Task<AdminRoleGrants> ResolveAsync(IReadOnlyList<string> ldapGroups, CancellationToken cancellationToken) =>
Task.FromResult(ldapGroups.Count == 0
? AdminRoleGrants.Empty
: new AdminRoleGrants([AdminRoles.FleetAdmin], []));
}
private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) =>
new(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
// ── Admin-005: /auth/login endpoint ─────────────────────────────────────────
[Fact]
public async Task Valid_login_issues_the_auth_cookie_and_redirects_home()
{
using var client = _factory.CreateNonRedirectingClient();
var response = await client.PostAsync("/auth/login",
Form(("username", "good"), ("password", "pw")));
// The endpoint owns the response, so the Set-Cookie header is actually emitted.
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
response.Headers.Location!.OriginalString.ShouldBe("/");
response.Headers.TryGetValues("Set-Cookie", out var cookies).ShouldBeTrue(
"a successful /auth/login must emit the auth cookie");
string.Join(';', cookies!).ShouldContain("OtOpcUa.Admin");
}
[Fact]
public async Task Invalid_login_redirects_back_to_login_with_an_error()
{
using var client = _factory.CreateNonRedirectingClient();
var response = await client.PostAsync("/auth/login",
Form(("username", "bad"), ("password", "wrong")));
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
response.Headers.Location!.OriginalString.ShouldContain("/login");
response.Headers.Location!.OriginalString.ShouldContain("error");
response.Headers.TryGetValues("Set-Cookie", out var cookies);
(cookies is null || !string.Join(';', cookies).Contains("OtOpcUa.Admin")).ShouldBeTrue(
"a failed bind must not issue the auth cookie");
}
[Fact]
public async Task Login_with_missing_credentials_redirects_back_to_login()
{
using var client = _factory.CreateNonRedirectingClient();
var response = await client.PostAsync("/auth/login", Form(("username", ""), ("password", "")));
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
response.Headers.Location!.OriginalString.ShouldContain("/login");
}
[Fact]
public async Task Login_redirect_target_is_open_redirect_safe()
{
using var client = _factory.CreateNonRedirectingClient();
// A returnUrl pointing off-site must be ignored — the post lands at the site root.
var response = await client.PostAsync("/auth/login",
Form(("username", "good"), ("password", "pw"), ("returnUrl", "https://evil.example.com/")));
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
response.Headers.Location!.OriginalString.ShouldBe("/");
}
[Fact]
public async Task Login_honours_a_local_return_url()
{
using var client = _factory.CreateNonRedirectingClient();
var response = await client.PostAsync("/auth/login",
Form(("username", "good"), ("password", "pw"), ("returnUrl", "/fleet")));
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
response.Headers.Location!.OriginalString.ShouldBe("/fleet");
}
[Fact]
public async Task Logout_endpoint_clears_the_cookie_and_redirects_to_login()
{
using var client = _factory.CreateNonRedirectingClient();
var response = await client.PostAsync("/auth/logout",
new FormUrlEncodedContent(Array.Empty<KeyValuePair<string, string>>()));
// No antiforgery 400 — the endpoint opts out (Admin-006 note in AuthEndpoints).
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
response.Headers.Location!.OriginalString.ShouldContain("/login");
}
// ── Admin-003: SignalR hubs reject anonymous connections ────────────────────
[Theory]
[InlineData("/hubs/fleet")]
[InlineData("/hubs/alerts")]
[InlineData("/hubs/script-log")]
public async Task Anonymous_hub_negotiate_is_rejected(string hubPath)
{
using var client = _factory.CreateNonRedirectingClient();
// The SignalR negotiate handshake is a POST to <hub>/negotiate. An [Authorize]'d hub
// must refuse it for an unauthenticated caller (302 to login or 401).
var response = await client.PostAsync($"{hubPath}/negotiate",
new FormUrlEncodedContent(Array.Empty<KeyValuePair<string, string>>()));
response.StatusCode.ShouldNotBe(HttpStatusCode.OK,
$"anonymous negotiate of {hubPath} must not succeed — the hub is [Authorize]-gated");
response.StatusCode.ShouldBeOneOf(
HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden,
HttpStatusCode.Redirect, HttpStatusCode.Found);
}
}