fix(security): resolve Security-004..007 — configurable user-id attribute, DN escaping, JWT issuer/audience validation, idle-timeout preservation
This commit is contained in:
@@ -475,6 +475,203 @@ public class SecurityReviewRegressionTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Code Review Regression Tests — Security-004/005/006/007
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Security-004 (uid/cn attribute mismatch between search filter
|
||||
/// and fallback DN), Security-005 (DN injection in the no-service-account fallback),
|
||||
/// Security-006 (JWT issuer/audience checks disabled), and Security-007 (idle-timeout
|
||||
/// claim reset on every token refresh).
|
||||
/// </summary>
|
||||
public class SecurityReviewRegressionTests2
|
||||
{
|
||||
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-004: search filter and fallback DN must use the same attribute ---
|
||||
|
||||
[Fact]
|
||||
public void BuildFallbackUserDn_UsesConfiguredUserIdAttribute()
|
||||
{
|
||||
// The default user-id attribute is "uid"; the fallback DN must use it,
|
||||
// not a hard-coded "cn", so search-then-bind and direct-bind are interchangeable.
|
||||
var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "uid");
|
||||
Assert.Equal("uid=alice,dc=example,dc=com", dn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFallbackUserDn_HonoursNonDefaultUserIdAttribute()
|
||||
{
|
||||
var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "sAMAccountName");
|
||||
Assert.Equal("sAMAccountName=alice,dc=example,dc=com", dn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecurityOptions_LdapUserIdAttribute_DefaultsToUid()
|
||||
{
|
||||
Assert.Equal("uid", new SecurityOptions().LdapUserIdAttribute);
|
||||
}
|
||||
|
||||
// --- Security-005: DN-component escaping must be applied to the username ---
|
||||
|
||||
[Fact]
|
||||
public void BuildFallbackUserDn_EscapesDnMetacharacters()
|
||||
{
|
||||
// A hostile username must not be able to alter the DN structure: the comma
|
||||
// that would otherwise start a new RDN ("ou=admins") must be escaped so the
|
||||
// whole string remains a single RDN value.
|
||||
var dn = LdapAuthService.BuildFallbackUserDn("victim,ou=admins", "dc=example,dc=com", "uid");
|
||||
Assert.Equal(@"uid=victim\,ou=admins,dc=example,dc=com", dn);
|
||||
// The comma from the username is backslash-escaped, so it does not act as an
|
||||
// RDN separator: the only unescaped comma is the one joining RDN and base DN.
|
||||
Assert.Contains(@"victim\,ou=admins", dn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscapeLdapDn_EscapesAllRfc4514Specials()
|
||||
{
|
||||
var escaped = LdapAuthService.EscapeLdapDn("a,b+c\"d\\e<f>g;h");
|
||||
Assert.Equal(@"a\,b\+c\""d\\e\<f\>g\;h", escaped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscapeLdapDn_EscapesLeadingAndTrailingSpaces()
|
||||
{
|
||||
Assert.Equal(@"\ x \ ", LdapAuthService.EscapeLdapDn(" x "));
|
||||
}
|
||||
|
||||
// --- Security-006: JWT issuer/audience must be bound and validated ---
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_SetsIssuerAndAudience()
|
||||
{
|
||||
var service = CreateJwtService();
|
||||
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
||||
|
||||
var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token);
|
||||
Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer);
|
||||
Assert.Contains(JwtTokenService.TokenAudience, jwt.Audiences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateToken_RejectsTokenWithWrongIssuer()
|
||||
{
|
||||
// A token signed with the same key but a foreign issuer must be rejected.
|
||||
var options = JwtOptions();
|
||||
var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
|
||||
System.Text.Encoding.UTF8.GetBytes(options.JwtSigningKey));
|
||||
var creds = new Microsoft.IdentityModel.Tokens.SigningCredentials(
|
||||
key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256);
|
||||
var foreign = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
|
||||
issuer: "some-other-system",
|
||||
audience: JwtTokenService.TokenAudience,
|
||||
claims: new[] { new Claim(JwtTokenService.UsernameClaimType, "user") },
|
||||
expires: DateTime.UtcNow.AddMinutes(10),
|
||||
signingCredentials: creds);
|
||||
var foreignToken = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(foreign);
|
||||
|
||||
var service = CreateJwtService(options);
|
||||
Assert.Null(service.ValidateToken(foreignToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateToken_AcceptsOwnIssuerAndAudience()
|
||||
{
|
||||
var service = CreateJwtService();
|
||||
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
||||
Assert.NotNull(service.ValidateToken(token));
|
||||
}
|
||||
|
||||
// --- Security-007: refresh must preserve the original LastActivity timestamp ---
|
||||
|
||||
[Fact]
|
||||
public void RefreshToken_PreservesOriginalLastActivityClaim()
|
||||
{
|
||||
var service = CreateJwtService();
|
||||
|
||||
// Mint a token whose LastActivity is 20 minutes in the past (still inside the
|
||||
// 30-minute idle window). Refresh must NOT move it forward to "now".
|
||||
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
||||
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
||||
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
||||
}, "test"));
|
||||
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
||||
Assert.NotNull(refreshed);
|
||||
|
||||
var refreshedPrincipal = service.ValidateToken(refreshed!);
|
||||
Assert.NotNull(refreshedPrincipal);
|
||||
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
||||
Assert.NotNull(claim);
|
||||
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
|
||||
// The carried timestamp must equal the original, not "now".
|
||||
Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2,
|
||||
$"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle()
|
||||
{
|
||||
// A user idle for 25 of the 30-minute window: a refresh fired by some background
|
||||
// request must not make IsIdleTimedOut flip back to false forever.
|
||||
var service = CreateJwtService();
|
||||
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25);
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
||||
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
||||
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
||||
}, "test"));
|
||||
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
||||
var refreshedPrincipal = service.ValidateToken(refreshed!);
|
||||
|
||||
// Still 25 min idle after refresh — not reset to 0.
|
||||
Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid
|
||||
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
||||
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
|
||||
Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20,
|
||||
"Refresh wrongly reset the idle clock to ~now");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordActivity_UpdatesLastActivityToNow()
|
||||
{
|
||||
// Genuine user activity (a real request) — distinct from a token refresh —
|
||||
// updates LastActivity to the current time.
|
||||
var service = CreateJwtService();
|
||||
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
||||
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
||||
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
||||
}, "test"));
|
||||
|
||||
var touched = service.RecordActivity(principal, new[] { "Admin" }, null);
|
||||
Assert.NotNull(touched);
|
||||
|
||||
var touchedPrincipal = service.ValidateToken(touched!);
|
||||
var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
||||
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated));
|
||||
Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5,
|
||||
"RecordActivity should set LastActivity to ~now");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WP-9: Authorization Policy Tests
|
||||
|
||||
public class AuthorizationPolicyTests
|
||||
|
||||
Reference in New Issue
Block a user