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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user