129 lines
4.9 KiB
C#
129 lines
4.9 KiB
C#
using MemberCenter.Infrastructure.Configuration;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Xunit;
|
|
|
|
namespace MemberCenter.Security.Tests;
|
|
|
|
public sealed class SecurityConfigurationTests
|
|
{
|
|
[Theory]
|
|
[InlineData("https://example.com/app", true)]
|
|
[InlineData("https://example.com/app/callback?code=1", true)]
|
|
[InlineData("https://example.com/application", false)]
|
|
[InlineData("https://example.com.attacker.test/app", false)]
|
|
[InlineData("https://example.com:444/app", false)]
|
|
[InlineData("https://user@example.com/app", false)]
|
|
public void ReturnUrlRequiresMatchingOriginAndPathBoundary(string candidate, bool expected)
|
|
{
|
|
var result = ReturnUrlValidator.IsWithinAllowedPrefix(
|
|
new Uri(candidate), "https://example.com/app", allowInsecureHttp: false);
|
|
|
|
Assert.Equal(expected, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReturnUrlRequiresHttpsWhenInsecureHttpIsDisabled()
|
|
{
|
|
Assert.False(ReturnUrlValidator.IsWithinAllowedPrefix(
|
|
new Uri("http://example.com/app"), "http://example.com/app", allowInsecureHttp: false));
|
|
}
|
|
|
|
[Fact]
|
|
public void ForwardedHeadersFailClosedWithoutTrustedProxy()
|
|
{
|
|
var options = new ForwardedHeadersOptions();
|
|
TrustedForwardedHeaders.Configure(options, Configuration());
|
|
|
|
Assert.Equal(ForwardedHeaders.None, options.ForwardedHeaders);
|
|
Assert.Empty(options.KnownNetworks);
|
|
Assert.Empty(options.KnownProxies);
|
|
}
|
|
|
|
[Fact]
|
|
public void TrustedProxyAllowlistEnablesExpectedHeaders()
|
|
{
|
|
var options = new ForwardedHeadersOptions();
|
|
TrustedForwardedHeaders.Configure(options, Configuration(new()
|
|
{
|
|
["ReverseProxy:KnownProxies"] = "10.0.0.10",
|
|
["ReverseProxy:KnownNetworks"] = "10.1.0.0/16"
|
|
}));
|
|
|
|
Assert.Equal(ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, options.ForwardedHeaders);
|
|
Assert.Single(options.KnownProxies);
|
|
Assert.Single(options.KnownNetworks);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("0.0.0.0/0")]
|
|
[InlineData("::/0")]
|
|
[InlineData("not-a-network")]
|
|
[InlineData("10.0.0.0/33")]
|
|
public void InvalidOrGlobalProxyNetworkIsRejected(string network)
|
|
{
|
|
var options = new ForwardedHeadersOptions();
|
|
|
|
Assert.Throws<InvalidOperationException>(() => TrustedForwardedHeaders.Configure(
|
|
options, Configuration(new() { ["ReverseProxy:KnownNetworks"] = network })));
|
|
}
|
|
|
|
[Fact]
|
|
public void RequiredCertificateMustBeConfigured()
|
|
{
|
|
Assert.Throws<InvalidOperationException>(() => CertificateLoader.LoadFromConfiguration(
|
|
Configuration(), "Auth:Certificates:Signing", required: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingCertificateFileIsRejected()
|
|
{
|
|
Assert.Throws<InvalidOperationException>(() => CertificateLoader.LoadFromConfiguration(
|
|
Configuration(new() { ["Auth:Certificates:Signing:Path"] = "/missing/signing.pfx" }),
|
|
"Auth:Certificates:Signing", required: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void SecurityOptionDefaultsRemainStable()
|
|
{
|
|
var identity = new IdentitySecurityOptions();
|
|
Assert.Equal(8, identity.Password.RequiredLength);
|
|
Assert.True(identity.Password.RequireDigit);
|
|
Assert.True(identity.Password.RequireLowercase);
|
|
Assert.True(identity.Password.RequireUppercase);
|
|
Assert.False(identity.Password.RequireNonAlphanumeric);
|
|
Assert.Equal(5, identity.Lockout.MaxFailedAccessAttempts);
|
|
Assert.Equal(15, identity.Lockout.DefaultLockoutMinutes);
|
|
|
|
var newsletter = new NewsletterTokenOptions();
|
|
Assert.Equal(7, newsletter.ConfirmTokenLifetimeDays);
|
|
Assert.Equal(7, newsletter.UnsubscribeTokenLifetimeDays);
|
|
Assert.Equal(7, newsletter.OneClickTokenLifetimeDays);
|
|
Assert.Equal(1000, newsletter.OneClickBatchSizeLimit);
|
|
|
|
var fileAccess = new FileAccessTokenOptions();
|
|
Assert.Equal(30, fileAccess.MinimumLifetimeSeconds);
|
|
Assert.Equal(300, fileAccess.DefaultLifetimeSeconds);
|
|
Assert.Equal(900, fileAccess.MaximumLifetimeSeconds);
|
|
}
|
|
|
|
[Fact]
|
|
public void InvalidSecurityOptionsAreRejected()
|
|
{
|
|
Assert.Throws<InvalidOperationException>(() => SecurityRuntimeOptionsValidator.Validate(new IdentitySecurityOptions
|
|
{
|
|
Password = new PasswordPolicyOptions { RequiredLength = 7 }
|
|
}));
|
|
Assert.Throws<InvalidOperationException>(() => SecurityRuntimeOptionsValidator.Validate(new FileAccessTokenOptions
|
|
{
|
|
MinimumLifetimeSeconds = 60,
|
|
DefaultLifetimeSeconds = 30,
|
|
MaximumLifetimeSeconds = 900
|
|
}));
|
|
}
|
|
|
|
private static IConfiguration Configuration(Dictionary<string, string?>? values = null) =>
|
|
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
|
}
|