fix(security): resolve Security-012..015 — fail login on partial LDAP outage, escape-aware DN parsing, idle check on refresh, username normalization

This commit is contained in:
Joseph Doherty
2026-05-17 03:18:33 -04:00
parent f5199e9da9
commit a58cec5776
4 changed files with 411 additions and 35 deletions

View File

@@ -804,6 +804,236 @@ public class SecurityReviewRegressionTests3
#endregion
#region Code Review Regression Tests Security-012/013/014/015
/// <summary>
/// Regression tests for Security-012 (a partial LDAP outage during login — bind OK but
/// group search failing — silently yields a roleless authenticated session),
/// Security-013 (<c>ExtractFirstRdnValue</c> mis-parses group DNs containing an escaped
/// comma), Security-014 (<c>RefreshToken</c> re-issues a token without checking the idle
/// timeout, so an idle-expired session can be renewed indefinitely), and Security-015
/// (a username with leading/trailing whitespace is not trimmed before use).
/// </summary>
public class SecurityReviewRegressionTests4
{
private static SecurityOptions JwtOptions() => new()
{
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
};
private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
new(Options.Create(options ?? JwtOptions()), NullLogger<JwtTokenService>.Instance);
// --- Security-014: RefreshToken must reject an idle-expired principal ---
[Fact]
public void RefreshToken_IdleExpiredPrincipal_ReturnsNull()
{
// A user idle for 31 minutes (past the 30-minute idle window). Even though the
// DisplayName/Username claims are present, the refresh must be refused so an
// idle-expired session cannot be renewed by a background request.
var service = CreateJwtService();
var idleActivity = DateTimeOffset.UtcNow.AddMinutes(-31);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o"))
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
Assert.Null(refreshed);
}
[Fact]
public void RefreshToken_ActiveSessionWithinIdleWindow_StillRefreshes()
{
// A user idle for 10 minutes (well inside the 30-minute window): refresh must
// still succeed — the idle-timeout guard must not break the normal sliding path.
var service = CreateJwtService();
var recentActivity = DateTimeOffset.UtcNow.AddMinutes(-10);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o"))
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
Assert.NotNull(refreshed);
}
[Fact]
public void RefreshToken_MissingLastActivityClaim_ReturnsNull()
{
// No LastActivity claim — IsIdleTimedOut treats this as timed out, so the
// refresh must be refused rather than minting a fresh session out of nothing.
var service = CreateJwtService();
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user")
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
Assert.Null(refreshed);
}
// --- Security-013: ExtractFirstRdnValue must honour RFC 4514 escaped commas ---
[Fact]
public void ExtractFirstRdnValue_EscapedComma_KeepsWholeGroupName()
{
// A CN that legitimately contains a comma is RFC 4514 backslash-escaped in the
// memberOf DN. The extracted group name must be the full unescaped CN value,
// not the fragment before the escaped comma.
var name = LdapAuthService.ExtractFirstRdnValue(
@"cn=Acme\, Inc Operators,ou=groups,dc=example,dc=com");
Assert.Equal("Acme, Inc Operators", name);
}
[Fact]
public void ExtractFirstRdnValue_PlainDn_ReturnsFirstRdnValue()
{
var name = LdapAuthService.ExtractFirstRdnValue("ou=SCADA-Admins,ou=groups,dc=example,dc=com");
Assert.Equal("SCADA-Admins", name);
}
[Fact]
public void ExtractFirstRdnValue_SingleRdn_ReturnsValue()
{
var name = LdapAuthService.ExtractFirstRdnValue("cn=Operators");
Assert.Equal("Operators", name);
}
[Fact]
public void ExtractFirstRdnValue_EscapedSpecials_AreUnescaped()
{
// RFC 4514 escape sequences (escaped '+', '"', '\\') must be unescaped in the
// returned value so it matches the configured LdapGroupName verbatim.
var name = LdapAuthService.ExtractFirstRdnValue(
@"cn=A\+B\\C,ou=groups,dc=example,dc=com");
Assert.Equal(@"A+B\C", name);
}
// --- Security-015: username must be trimmed before use ---
[Fact]
public void BuildFallbackUserDn_TrimmedUsername_NoLeadingTrailingSpace()
{
// The whitespace-edge escaping in EscapeLdapDn only fires when whitespace is NOT
// trimmed. AuthenticateAsync trims first; this asserts the trimmed value yields
// a clean DN with no escaped edge spaces.
var dn = LdapAuthService.BuildFallbackUserDn("alice".Trim(), "dc=example,dc=com", "uid");
Assert.Equal("uid=alice,dc=example,dc=com", dn);
}
[Fact]
public async Task AuthenticateAsync_UsernameWithSurroundingWhitespace_StillRejectedForInsecure()
{
// Sanity guard: a padded but otherwise-valid username is not rejected by the
// IsNullOrWhiteSpace guard — it passes through to the (here, insecure-LDAP) path.
var options = new SecurityOptions
{
LdapServer = "ldap.example.com",
LdapPort = 389,
LdapTransport = LdapTransport.None,
AllowInsecureLdap = false
};
var service = new LdapAuthService(Options.Create(options), NullLogger<LdapAuthService>.Instance);
var result = await service.AuthenticateAsync(" alice ", "password");
// Reaches the insecure-LDAP guard (not the empty-username guard) — proves the
// padded username is treated as a real, non-empty username.
Assert.False(result.Success);
Assert.Contains("Insecure LDAP", result.ErrorMessage);
}
[Fact]
public void NormalizeUsername_TrimsLeadingAndTrailingWhitespace()
{
Assert.Equal("alice", LdapAuthService.NormalizeUsername(" alice "));
Assert.Equal("alice", LdapAuthService.NormalizeUsername("alice"));
Assert.Equal("alice", LdapAuthService.NormalizeUsername("\talice\n"));
}
}
#endregion
#region Code Review Regression Tests Security-012 (partial LDAP outage)
/// <summary>
/// Regression tests for Security-012: a partial LDAP outage during login (the user bind
/// succeeds but the subsequent group/attribute search fails) must fail the login per the
/// design's LDAP-failure rule, rather than returning an authenticated session with zero
/// roles. These exercise the seam through a stubbed group-lookup so the bind itself can
/// be treated as successful.
/// </summary>
public class Security012GroupLookupFailureTests
{
private static SecurityOptions Options() => new()
{
LdapServer = "ldap.example.com",
LdapPort = 636,
LdapTransport = LdapTransport.Ldaps,
LdapSearchBase = "dc=example,dc=com"
};
[Fact]
public void BuildAuthResultFromGroupLookup_LookupFailed_FailsTheLogin()
{
// When the group lookup failed (directory partially unavailable mid-login) the
// result must be a FAILED login — not a success with an empty group list.
var result = LdapAuthService.BuildAuthResultFromGroupLookup(
username: "alice",
displayName: "Alice",
groups: new List<string>(),
groupLookupSucceeded: false);
Assert.False(result.Success);
Assert.Null(result.Groups);
Assert.False(string.IsNullOrEmpty(result.ErrorMessage));
}
[Fact]
public void BuildAuthResultFromGroupLookup_LookupSucceededNoGroups_IsAuthenticated()
{
// A genuine "user belongs to no mapped groups" outcome must remain a successful
// login — it must be distinguishable from a failed lookup.
var result = LdapAuthService.BuildAuthResultFromGroupLookup(
username: "alice",
displayName: "Alice",
groups: new List<string>(),
groupLookupSucceeded: true);
Assert.True(result.Success);
Assert.NotNull(result.Groups);
Assert.Empty(result.Groups!);
}
[Fact]
public void BuildAuthResultFromGroupLookup_LookupSucceededWithGroups_CarriesGroups()
{
var result = LdapAuthService.BuildAuthResultFromGroupLookup(
username: "alice",
displayName: "Alice",
groups: new List<string> { "SCADA-Admins" },
groupLookupSucceeded: true);
Assert.True(result.Success);
Assert.Equal(new[] { "SCADA-Admins" }, result.Groups);
}
}
#endregion
#region WP-9: Authorization Policy Tests
public class AuthorizationPolicyTests