fix(security): resolve Security-001/002/003 — reachable StartTLS path, Secure cookie, JWT signing key validation

This commit is contained in:
Joseph Doherty
2026-05-16 19:47:17 -04:00
parent 393172f169
commit 0d9363766d
7 changed files with 222 additions and 11 deletions

View File

@@ -357,6 +357,124 @@ public class RoleMapperTests : IDisposable
#endregion
#region Code Review Regression Tests
/// <summary>
/// Regression tests for Security-001 (StartTLS dead code), Security-002 (cookie not
/// marked Secure), and Security-003 (JWT signing key length never validated).
/// </summary>
public class SecurityReviewRegressionTests
{
// --- Security-003: JWT signing key length validation ---
private static SecurityOptions JwtOptions(string signingKey) => new()
{
JwtSigningKey = signingKey,
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
};
[Fact]
public void JwtTokenService_EmptySigningKey_ThrowsAtConstruction()
{
var ex = Assert.Throws<InvalidOperationException>(() =>
new JwtTokenService(Options.Create(JwtOptions("")), NullLogger<JwtTokenService>.Instance));
Assert.Contains("JwtSigningKey", ex.Message);
}
[Fact]
public void JwtTokenService_ShortSigningKey_ThrowsAtConstruction()
{
// 31 bytes — one short of the 256-bit HMAC-SHA256 minimum.
var shortKey = new string('k', 31);
var ex = Assert.Throws<InvalidOperationException>(() =>
new JwtTokenService(Options.Create(JwtOptions(shortKey)), NullLogger<JwtTokenService>.Instance));
Assert.Contains("32", ex.Message);
}
[Fact]
public void JwtTokenService_AdequateSigningKey_ConstructsSuccessfully()
{
var key = new string('k', 32);
var service = new JwtTokenService(Options.Create(JwtOptions(key)), NullLogger<JwtTokenService>.Instance);
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
Assert.False(string.IsNullOrEmpty(token));
}
// --- Security-002: authentication cookie must be marked Secure ---
[Fact]
public void AddSecurity_AuthCookie_IsMarkedSecureAlways()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddDataProtection();
services.AddSecurity();
using var provider = services.BuildServiceProvider();
var cookieOptions = provider
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
Assert.Equal(
Microsoft.AspNetCore.Http.CookieSecurePolicy.Always,
cookieOptions.Cookie.SecurePolicy);
Assert.True(cookieOptions.Cookie.HttpOnly);
}
// --- Security-001: StartTLS transport must be reachable ---
[Fact]
public void SecurityOptions_LdapTransport_DefaultsToLdaps()
{
var options = new SecurityOptions();
Assert.Equal(LdapTransport.Ldaps, options.LdapTransport);
}
[Fact]
public async Task AuthenticateAsync_StartTlsTransport_AttemptsConnection()
{
// With StartTLS selected the service must not be blocked by the insecure-LDAP
// guard and must reach the connection stage (which fails against a dead host).
// This proves the StartTLS path is reachable rather than dead code.
var options = new SecurityOptions
{
LdapServer = "nonexistent.invalid",
LdapPort = 389,
LdapTransport = LdapTransport.StartTls,
LdapSearchBase = "dc=example,dc=com"
};
var service = new LdapAuthService(Options.Create(options), NullLogger<LdapAuthService>.Instance);
var result = await service.AuthenticateAsync("user", "password");
// Connection fails (host invalid) — but crucially NOT with the insecure-LDAP message.
Assert.False(result.Success);
Assert.DoesNotContain("Insecure LDAP", result.ErrorMessage ?? string.Empty);
}
[Fact]
public async Task AuthenticateAsync_NoTlsTransport_RejectedWithoutAllowInsecure()
{
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("user", "password");
Assert.False(result.Success);
Assert.Contains("Insecure LDAP", result.ErrorMessage);
}
}
#endregion
#region WP-9: Authorization Policy Tests
public class AuthorizationPolicyTests