fix(security): throttle DebugStreamHub LDAP bind via ManagementAuthenticator + correct coverage doc (plan R2-07 T1)

This commit is contained in:
Joseph Doherty
2026-07-13 10:36:32 -04:00
parent c83b51fe9c
commit e1a692016e
3 changed files with 112 additions and 58 deletions
@@ -1,4 +1,3 @@
using System.Text;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -6,7 +5,6 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
@@ -75,73 +73,35 @@ public class DebugStreamHub : Hub
return;
}
// Dev/test: when login is disabled, auto-authenticate the debug stream as the configured
// dev user (ALL roles incl. Deployer, system-wide) — mirroring the cookie/management
// bypass so the CLI debug stream is usable in a login-disabled (e.g. no-LDAP) deployment.
// See ManagementAuthenticator.
var bypassUser = ManagementAuthenticator.TryDisableLoginUser(httpContext);
if (bypassUser is not null)
// Authenticate via the SAME shared Basic→LDAP flow as POST /management and the
// audit REST (arch-review R2 N1): ManagementAuthenticator applies the DisableLogin
// bypass, the LoginThrottle lockout check BEFORE the bind, and RecordFailure/
// RecordSuccess bookkeeping — so hub connection attempts can no longer be used as
// an unthrottled LDAP-bind oracle, and hub failures feed the shared lockout.
var outcome = await ManagementAuthenticator.AuthenticateAsync(httpContext);
if (outcome.User is null)
{
Context.Items[RolesKey] = bypassUser.Roles;
Context.Items[PermittedSiteIdsKey] = bypassUser.PermittedSiteIds;
_logger.LogInformation(
"DebugStreamHub connection established for {Username} (login disabled)", bypassUser.Username);
await base.OnConnectedAsync();
return;
}
// Extract Basic Auth credentials
var authHeader = httpContext.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("DebugStreamHub connection rejected: missing Basic Auth header");
_logger.LogWarning("DebugStreamHub connection rejected: authentication failed or throttled");
Context.Abort();
return;
}
var user = outcome.User;
string username, password;
try
// Role check — Deployer role required (unchanged policy).
if (!user.Roles.Contains(Roles.Deployer))
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..]));
var colon = decoded.IndexOf(':');
if (colon < 0) throw new FormatException();
username = decoded[..colon];
password = decoded[(colon + 1)..];
}
catch
{
_logger.LogWarning("DebugStreamHub connection rejected: malformed Basic Auth");
Context.Abort();
return;
}
// LDAP authentication
var ldapAuth = httpContext.RequestServices.GetRequiredService<ILdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, Context.ConnectionAborted);
if (!authResult.Succeeded)
{
_logger.LogWarning("DebugStreamHub connection rejected: LDAP auth failed for {Username}", username);
Context.Abort();
return;
}
// Role check — Deployer role required
var roleMapper = httpContext.RequestServices.GetRequiredService<RoleMapper>();
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, Context.ConnectionAborted);
if (!mappingResult.Roles.Contains(Roles.Deployer))
{
_logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployer role", username);
_logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployer role", user.Username);
Context.Abort();
return;
}
// Persist the resolved identity on the connection so per-instance site-scope
// enforcement can be applied to SubscribeInstance calls.
Context.Items[RolesKey] = mappingResult.Roles.ToArray();
Context.Items[PermittedSiteIdsKey] = mappingResult.PermittedSiteIds.ToArray();
// enforcement can be applied to SubscribeInstance calls. PermittedSiteIds is the
// authenticator's normalized form (empty == system-wide), same as every other surface.
Context.Items[RolesKey] = user.Roles;
Context.Items[PermittedSiteIdsKey] = user.PermittedSiteIds;
_logger.LogInformation("DebugStreamHub connection established for {Username}", username);
_logger.LogInformation("DebugStreamHub connection established for {Username}", user.Username);
await base.OnConnectedAsync();
}