Files
jobtrackingapp/JobTrackerApi.Tests/ExternalOriginTests.cs
T

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();
}