140 lines
5.4 KiB
C#
140 lines
5.4 KiB
C#
using System.Security.Claims;
|
|
using System.Net;
|
|
using JobTrackerApi.Controllers;
|
|
using JobTrackerApi.Services;
|
|
using JobTrackerApi.Tests.TestSupport;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class ExternalOriginTests
|
|
{
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData("http://jobs.example.test")]
|
|
[InlineData("https://user@jobs.example.test")]
|
|
[InlineData("https://jobs.example.test/path")]
|
|
[InlineData("https://jobs.example.test?query=1")]
|
|
[InlineData("https://jobs.example.test#fragment")]
|
|
public void Production_requires_one_clean_https_origin(string? value)
|
|
{
|
|
Assert.Throws<InvalidOperationException>(() => ExternalOrigin.Parse(value, production: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void Development_defaults_to_local_frontend()
|
|
{
|
|
Assert.Equal("http://localhost:3000", ExternalOrigin.Parse(null, production: false).BaseUrl);
|
|
}
|
|
|
|
[Fact]
|
|
public void Canonical_host_matching_includes_the_configured_port()
|
|
{
|
|
var origin = ExternalOrigin.Parse("https://jobs.example.test:8443/", production: true);
|
|
|
|
Assert.True(origin.Matches(new HostString("jobs.example.test", 8443)));
|
|
Assert.False(origin.Matches(new HostString("jobs.example.test")));
|
|
Assert.False(origin.Matches(new HostString("attacker.example.test", 8443)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Internal_host_is_allowed_only_for_liveness()
|
|
{
|
|
var origin = ExternalOrigin.Parse("https://jobs.example.test", production: true);
|
|
|
|
Assert.True(origin.AllowsRequest(new HostString("jobs.example.test"), "/api/auth/config"));
|
|
Assert.True(origin.AllowsRequest(new HostString("localhost", 8080), "/health"));
|
|
Assert.False(origin.AllowsRequest(new HostString("localhost", 8080), "/api/auth/config"));
|
|
Assert.False(origin.AllowsRequest(new HostString("attacker.example.test"), "/health"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Forwarded_headers_require_an_explicit_proxy_network()
|
|
{
|
|
var missing = BuildConfig(new Dictionary<string, string?>());
|
|
var invalid = BuildConfig(new Dictionary<string, string?> { ["Proxy:KnownNetworks:0"] = "anywhere" });
|
|
|
|
Assert.Throws<InvalidOperationException>(() => ForwardedProxyConfiguration.Build(missing));
|
|
Assert.Throws<InvalidOperationException>(() => ForwardedProxyConfiguration.Build(invalid));
|
|
}
|
|
|
|
[Fact]
|
|
public void Forwarded_headers_trust_one_hop_from_the_configured_network_only()
|
|
{
|
|
var config = BuildConfig(new Dictionary<string, string?> { ["Proxy:KnownNetworks:0"] = "172.31.250.0/29" });
|
|
|
|
var options = ForwardedProxyConfiguration.Build(config);
|
|
|
|
Assert.Equal(1, options.ForwardLimit);
|
|
var network = Assert.Single(options.KnownNetworks);
|
|
Assert.True(network.Contains(IPAddress.Parse("172.31.250.2")));
|
|
Assert.False(network.Contains(IPAddress.Parse("172.31.251.2")));
|
|
Assert.Empty(options.KnownProxies);
|
|
}
|
|
|
|
[Fact]
|
|
public void OAuth_callback_ignores_request_host_and_legacy_redirect_override()
|
|
{
|
|
var config = BuildConfig(new Dictionary<string, string?>
|
|
{
|
|
["App:PublicBaseUrl"] = "https://jobs.example.test",
|
|
["Microsoft:RedirectUri"] = "https://attacker.example.test/callback",
|
|
});
|
|
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
|
graph.Setup(x => x.BuildAuthorizationUrl("user-1", "https://jobs.example.test/api/microsoft-graph/oauth/callback"))
|
|
.Returns("https://login.microsoftonline.com/authorize");
|
|
var controller = new MicrosoftGraphController(graph.Object, config)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, "user-1"),
|
|
}, "test")),
|
|
},
|
|
},
|
|
};
|
|
controller.Request.Host = new HostString("attacker.example.test");
|
|
|
|
controller.ConnectUrl();
|
|
|
|
graph.VerifyAll();
|
|
}
|
|
|
|
[Fact]
|
|
public void Csrf_cookie_security_comes_from_canonical_origin()
|
|
{
|
|
var config = BuildConfig(new Dictionary<string, string?> { ["App:PublicBaseUrl"] = "https://jobs.example.test" });
|
|
var controller = new AuthController(
|
|
config,
|
|
TestHostFactory.CreateUserManager().Object,
|
|
Mock.Of<ITokenService>(),
|
|
Mock.Of<IAppEmailSender>(),
|
|
Mock.Of<IGoogleTokenValidator>(),
|
|
Mock.Of<IMicrosoftTokenValidator>(),
|
|
NullLogger<AuthController>.Instance,
|
|
Mock.Of<ITwoFactorPendingTokenService>(),
|
|
TestHostFactory.CreateInMemoryDb())
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
|
};
|
|
controller.Request.Scheme = "http";
|
|
controller.Request.Headers["X-Forwarded-Proto"] = "http";
|
|
|
|
controller.EnsureCsrfCookie();
|
|
|
|
Assert.Contains("secure", controller.Response.Headers.SetCookie.ToString(), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static IConfiguration BuildConfig(IDictionary<string, string?> values) =>
|
|
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
|
}
|