From 3de688f8d61278432a43e00a90680b81324f3a19 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 22 May 2026 06:27:11 -0400 Subject: [PATCH] fix(admin): resolve High code-review findings (Admin-003, Admin-004, Admin-005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- code-reviews/Admin/findings.md | 14 +- .../Components/Pages/Login.razor | 114 +++-------- .../ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs | 6 + .../Hubs/FleetStatusHub.cs | 7 + .../Hubs/ScriptLogHub.cs | 6 + src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs | 36 ++-- .../Security/AuthEndpoints.cs | 101 ++++++++++ .../ZB.MOM.WW.OtOpcUa.Admin.csproj | 3 + .../ZB.MOM.WW.OtOpcUa.Admin/appsettings.json | 5 +- .../AppSettingsSecretHygieneTests.cs | 79 ++++++++ .../AuthEndpointsTests.cs | 184 ++++++++++++++++++ 11 files changed, 447 insertions(+), 108 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/AuthEndpoints.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/AppSettingsSecretHygieneTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/AuthEndpointsTests.cs diff --git a/code-reviews/Admin/findings.md b/code-reviews/Admin/findings.md index 57a8645..1833584 100644 --- a/code-reviews/Admin/findings.md +++ b/code-reviews/Admin/findings.md @@ -7,7 +7,7 @@ | Review date | 2026-05-22 | | Commit reviewed | `76d35d1` | | Status | Reviewed | -| Open findings | 10 | +| Open findings | 7 | ## Checklist coverage @@ -63,13 +63,13 @@ | Severity | High | | Category | Security | | Location | `Program.cs:137-139`, `Hubs/FleetStatusHub.cs:11`, `Hubs/AlertHub.cs:10`, `Hubs/ScriptLogHub.cs:30` | -| Status | Open | +| Status | Resolved | **Description:** All three SignalR hubs (`/hubs/fleet`, `/hubs/alerts`, `/hubs/script-log`) are mapped with no `[Authorize]` attribute and no `.RequireAuthorization()` on the `MapHub` call. Any unauthenticated client can open a hub connection: `FleetStatusHub.SubscribeFleet()` streams every node generation/role/resilience state, `AlertHub` pushes all fleet alerts (including failure detail text), and `ScriptLogHub.TailLogAsync` streams the contents of the server `scripts-*.log` files. This is an unauthenticated information-disclosure channel that bypasses the (already broken — see Admin-001) page auth entirely. **Recommendation:** Add `[Authorize]` to each `Hub` class, or chain `.RequireAuthorization()` onto each `MapHub(...)` call in `Program.cs`. The hub `SubscribeCluster`/`TailLogAsync` methods should additionally validate that the caller claims permit the requested cluster/script scope. -**Resolution:** _(open)_ +**Resolution:** Resolved 2026-05-22 — `[Authorize]` added to `FleetStatusHub`, `AlertHub` and `ScriptLogHub`, and `.RequireAuthorization()` chained onto all three `MapHub(...)` calls in `Program.cs` as a belt-and-braces backstop, so an anonymous client can no longer open any hub connection. Covered by `AuthEndpointsTests.Anonymous_hub_negotiate_is_rejected`. ### Admin-004 @@ -78,13 +78,13 @@ | Severity | High | | Category | Security | | Location | `appsettings.json:3,13-14` | -| Status | Open | +| Status | Resolved | **Description:** The checked-in `appsettings.json` contains live-looking secrets in plaintext: the `ConfigDb` connection string with `User Id=sa;Password=OtOpcUaDev_2026!` and the LDAP `ServiceAccountPassword: "serviceaccount123"`. It also sets `Encrypt=False` and `AllowInsecureLdap: true`, so the SQL and LDAP credentials travel unencrypted on the wire. Committing the `sa` account password and a service-account password to source control is a credential-exposure risk; `sa` additionally grants full server control, conflicting with the `ClusterService` doc comment that production should connect with a least-privilege grant. **Recommendation:** Move all secrets out of the committed file — use user-secrets for dev and environment variables / a secret store for production; leave only non-secret placeholders in `appsettings.json`. Use a least-privilege SQL login rather than `sa`. Enable TLS for both SQL (`Encrypt=True`) and LDAP (`UseTls=true`, `AllowInsecureLdap=false`) for any non-loopback deployment, and document the dev-only exception. -**Resolution:** _(open)_ +**Resolution:** Resolved 2026-05-22 — the `sa` connection string and the LDAP `ServiceAccountPassword` were replaced with empty placeholders in `appsettings.json`; a `_secrets` note documents that they are supplied via user-secrets (dev) or the `ConnectionStrings__ConfigDb` / `Authentication__Ldap__ServiceAccountPassword` environment variables (prod), and that the connection string must use `Encrypt=True` and a least-privilege SQL login. A `UserSecretsId` was added to the Admin csproj, and `Program.cs` now fails fast with a clear message when `ConfigDb` is empty/missing. Covered by `AppSettingsSecretHygieneTests`. ### Admin-005 @@ -93,13 +93,13 @@ | Severity | High | | Category | Correctness & logic bugs | | Location | `Components/Pages/Login.razor:15,107-110` | -| Status | Open | +| Status | Resolved | **Description:** `Login.razor` is an interactive component (the project default render mode is interactive server; the page declares no `@rendermode` but uses `EditForm`/`InputText` interactive binding and runs `SignInAsync` from an event handler). It calls `HttpContext.SignInAsync(...)` followed by `ctx.Response.Redirect("/")` from within a SignalR circuit callback. Writing auth cookies and HTTP redirect headers requires a live, unstarted HTTP response; in an interactive circuit the original HTTP response has long completed, so the cookie is typically not emitted and the redirect is ineffective (or throws "response has already started"). `admin-ui.md` section "Operator authentication" explicitly specifies the login as a static server-rendered HTML form POSTing to a `/auth/login` minimal-API endpoint with `data-enhance="false"` — that endpoint is not implemented and is not mapped in `Program.cs`. **Recommendation:** Implement the login as designed: a static-rendered form (`@rendermode` none, `data-enhance="false"`) posting to a `MapPost("/auth/login", ...)` minimal-API handler that does the LDAP bind, grant resolution, `SignInAsync` and redirect while the HTTP response is still owned by the endpoint. Do not perform `SignInAsync` from an interactive circuit. -**Resolution:** _(open)_ +**Resolution:** Resolved 2026-05-22 — `Login.razor` rewritten as a static-rendered plain HTML `
` (no `@rendermode`, no `EditForm`/`SignInAsync` in a circuit); the LDAP bind, grant resolution, cookie `SignInAsync` and redirect now run in a new `AuthEndpoints.MapAuthEndpoints()` minimal-API handler (`/auth/login`, `/auth/logout`) while the endpoint still owns the HTTP response. The handler is `AllowAnonymous`, carries an open-redirect guard on `returnUrl`, and surfaces bind errors back to the login page via a query-string. Covered by `AuthEndpointsTests` (valid login issues the cookie, invalid login redirects with error, open-redirect rejected, logout clears the cookie). ### Admin-006 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor index cec8f2a..b0e6b30 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor @@ -2,38 +2,40 @@ @* The login page must stay anonymously reachable — otherwise the fallback authorization policy (Admin-001) would lock operators out of the only way in. *@ @attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous] -@using System.Security.Claims -@using Microsoft.AspNetCore.Authentication -@using Microsoft.AspNetCore.Authentication.Cookies -@using ZB.MOM.WW.OtOpcUa.Admin.Security -@inject IHttpContextAccessor Http -@inject ILdapAuthService LdapAuth -@inject IAdminRoleGrantResolver GrantResolver -@inject NavigationManager Nav + +@* Admin-005: this page is static server-rendered (no @rendermode). It is a plain HTML + form that POSTs to the /auth/login minimal-API endpoint with data-enhance="false", so + the LDAP bind, cookie SignInAsync and redirect all run while the endpoint still owns + an unstarted HTTP response. SignInAsync must NOT be called from an interactive Blazor + circuit — by then the original HTTP response has long completed. *@