Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions test/Classes/HomeControllerCasUrlTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using System.Net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using NSubstitute;
using Viper.Classes;
using Viper.Classes.SQLContext;
using Viper.Controllers;
using Web.Authorization;

namespace Viper.test.Classes;

/// <summary>
/// CAS service callbacks must be built from the configured canonical origin, never from the
/// request Host. Login covers the shared BuildRedirectUri helper that CasLogin's ticket
/// validation also uses.
/// </summary>
public class HomeControllerCasUrlTests
{
private const string CasBaseUrl = "https://ssodev.ucdavis.edu/cas/";
private const string PublicBaseUrl = "https://secure-test.vetmed.ucdavis.edu/2";
private const string ForgedHost = "attacker.example";

[Fact]
public void Login_BuildsServiceFromConfiguredOrigin_NotHostHeader()
{
var controller = CreateController(ForgedHost, pathBase: "/2");

var result = Assert.IsType<RedirectResult>(controller.Login());

Assert.DoesNotContain(ForgedHost, result.Url, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith($"{PublicBaseUrl}/CasLogin?", ServiceParameter(result.Url), StringComparison.Ordinal);
}

[Fact]
public void Login_DefaultReturnUrl_PreservesPathBase()
{
var controller = CreateController(ForgedHost, pathBase: "/2");

var result = Assert.IsType<RedirectResult>(controller.Login());

// ReturnUrl is encoded inside the service value, which is then encoded again for CAS,
// so one decode leaves the inner encoding intact.
Assert.Equal($"{PublicBaseUrl}/CasLogin?ReturnUrl={WebUtility.UrlEncode("/2")}", ServiceParameter(result.Url));
}

[Fact]
public void Login_NoPathBase_DefaultsToEmptyReturnUrl()
{
var controller = CreateController("localhost:7157", pathBase: string.Empty);

var result = Assert.IsType<RedirectResult>(controller.Login());

Assert.Equal($"{PublicBaseUrl}/CasLogin?ReturnUrl=", ServiceParameter(result.Url));
}

[Fact]
public void Login_ExplicitReturnUrl_IsPreserved()
{
var controller = CreateController(ForgedHost, pathBase: "/2");

var result = Assert.IsType<RedirectResult>(controller.Login("/2/Students/StudentClassYear"));

Assert.Equal(
$"{PublicBaseUrl}/CasLogin?ReturnUrl={WebUtility.UrlEncode("/2/Students/StudentClassYear")}",
ServiceParameter(result.Url));
}

[Fact]
public void Login_ApiReturnUrlUnderPathBase_ReturnsUnauthorized()
{
// The SPAs send ReturnUrl already prefixed with the deployed PathBase, so without
// stripping it the API guard never fired on TEST/PROD and an API caller got a CAS
// HTML redirect instead of a 401.
var controller = CreateController("secure-test.vetmed.ucdavis.edu", pathBase: "/2");

Assert.IsType<UnauthorizedResult>(controller.Login("/2/api/students/dvm"));
}

[Fact]
public void Login_ApiReturnUrlWithoutPathBase_ReturnsUnauthorized()
{
var controller = CreateController("localhost:7157", pathBase: string.Empty);

Assert.IsType<UnauthorizedResult>(controller.Login("/api/students/dvm"));
}

[Fact]
public async Task Logout_BuildsServiceFromConfiguredOrigin_NotHostHeader()
{
var controller = CreateController(ForgedHost, pathBase: "/2");

var result = Assert.IsType<RedirectResult>(await controller.Logout());

Assert.DoesNotContain(ForgedHost, result.Url, StringComparison.OrdinalIgnoreCase);
Assert.Equal($"{CasBaseUrl}logout?service={WebUtility.UrlEncode(PublicBaseUrl)}", result.Url);
}

/// <summary>
/// Pulls the decoded CAS service parameter out of the redirect so assertions read as URLs
/// rather than percent-encoded soup.
/// </summary>
private static string ServiceParameter(string redirectUrl)
{
const string marker = "service=";
int start = redirectUrl.IndexOf(marker, StringComparison.Ordinal);
Assert.True(start >= 0, $"No service parameter in '{redirectUrl}'.");

return WebUtility.UrlDecode(redirectUrl[(start + marker.Length)..]);
}

private static HomeController CreateController(string host, string pathBase)
{
var publicUrl = new PublicUrlService(
Options.Create(new PublicUrlOptions { PublicBaseUrl = PublicBaseUrl }),
Substitute.For<IHttpContextAccessor>());

var controller = new HomeController(
Substitute.For<IHttpClientFactory>(),
Options.Create(new CasSettings { CasBaseUrl = CasBaseUrl }),
publicUrl,
Substitute.For<AAUDContext>(),
Substitute.For<RAPSContext>(),
Substitute.For<VIPERContext>());

var httpContext = new DefaultHttpContext
{
RequestServices = AuthenticationServices()
};
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString(host);
httpContext.Request.PathBase = new PathString(pathBase);
httpContext.Request.Path = new PathString("/Login");

controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
return controller;
}

// Logout signs the cookie out, which resolves IAuthenticationService from the request.
private static IServiceProvider AuthenticationServices()
{
var authentication = Substitute.For<IAuthenticationService>();
var services = Substitute.For<IServiceProvider>();
services.GetService(typeof(IAuthenticationService)).Returns(authentication);
return services;
}
}
179 changes: 179 additions & 0 deletions test/Classes/PublicUrlServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Viper.Classes;

namespace Viper.test.Classes;

/// <summary>
/// The canonical public origin must come from configuration in deployed environments so a
/// forged Host header cannot influence a CAS callback. Development keeps the request-derived
/// fallback because the local port is dynamic.
/// </summary>
public class PublicUrlServiceTests
{
private const string TestBaseUrl = "https://secure-test.vetmed.ucdavis.edu/2";
private const string ProductionBaseUrl = "https://viper.vetmed.ucdavis.edu/2";

[Fact]
public void BaseUrl_ConfiguredOriginWins_OverForgedHostHeader()
{
var service = CreateService(TestBaseUrl, host: "attacker.example", pathBase: "/2");

Assert.Equal(TestBaseUrl, service.BaseUrl);
}

[Fact]
public void BuildUrl_ConfiguredOriginWins_OverForgedHostHeader()
{
var service = CreateService(ProductionBaseUrl, host: "attacker.example", pathBase: "/2");

Assert.Equal($"{ProductionBaseUrl}/CasLogin", service.BuildUrl("/CasLogin"));
Assert.DoesNotContain("attacker.example", service.BuildUrl("/CasLogin"), StringComparison.OrdinalIgnoreCase);
}

[Theory]
[InlineData("https://viper.vetmed.ucdavis.edu/2/", "https://viper.vetmed.ucdavis.edu/2")]
[InlineData(" https://viper.vetmed.ucdavis.edu/2 ", "https://viper.vetmed.ucdavis.edu/2")]
[InlineData("https://viper.vetmed.ucdavis.edu/", "https://viper.vetmed.ucdavis.edu")]
public void NormalizeBaseUrl_TrimsWhitespaceAndTrailingSlash(string configured, string expected)
{
Assert.Equal(expected, PublicUrlService.NormalizeBaseUrl(configured));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void NormalizeBaseUrl_BlankIsNull(string? configured)
{
Assert.Null(PublicUrlService.NormalizeBaseUrl(configured));
}

[Fact]
public void BuildUrl_AddsSeparator_WhenPathHasNoLeadingSlash()
{
var service = CreateService(TestBaseUrl, host: "secure-test.vetmed.ucdavis.edu", pathBase: "/2");

Assert.Equal($"{TestBaseUrl}/CasLogin", service.BuildUrl("CasLogin"));
}

[Fact]
public void BuildUrl_EmptyPath_ReturnsBaseUrl()
{
var service = CreateService(TestBaseUrl, host: "secure-test.vetmed.ucdavis.edu", pathBase: "/2");

Assert.Equal(TestBaseUrl, service.BuildUrl(string.Empty));
}

[Fact]
public void BaseUrl_Unconfigured_FallsBackToRequestIncludingPathBase()
{
// Development only: no PublicBaseUrl set, so the origin comes from the request.
var service = CreateService(configured: null, host: "localhost:7157", pathBase: "/2");

Assert.Equal("https://localhost:7157/2", service.BaseUrl);
}

[Fact]
public void BaseUrl_Unconfigured_NoPathBase_ReturnsOriginOnly()
{
var service = CreateService(configured: null, host: "localhost:7157", pathBase: string.Empty);

Assert.Equal("https://localhost:7157", service.BaseUrl);
}

[Fact]
public void BaseUrl_Unconfigured_NoRequest_FallsBackToLocalDevelopmentOrigin()
{
// Development background work (Hangfire email) has no request to derive from. Deployed
// environments never reach this because startup validation requires the configured value.
var accessor = Substitute.For<IHttpContextAccessor>();
accessor.HttpContext.ReturnsNull();
var service = new PublicUrlService(Options.Create(new PublicUrlOptions()), accessor);

string expectedPort = Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORT") ?? "7157";

Assert.Equal($"https://localhost:{expectedPort}", service.BaseUrl);
}

[Fact]
public void BaseUrl_Configured_NoRequest_StillUsesTheCanonicalOrigin()
{
// The email path must not pick up the local development origin in a deployed environment.
var accessor = Substitute.For<IHttpContextAccessor>();
accessor.HttpContext.ReturnsNull();
var service = new PublicUrlService(Options.Create(new PublicUrlOptions { PublicBaseUrl = ProductionBaseUrl }), accessor);

Assert.Equal(ProductionBaseUrl, service.BaseUrl);
}

#region Startup validation

[Theory]
[InlineData(TestBaseUrl)]
[InlineData(ProductionBaseUrl)]
[InlineData("https://viper.vetmed.ucdavis.edu")]
public void Validate_AcceptsCanonicalDeployedUrls(string configured)
{
Assert.True(PublicUrlOptionsValidator.ValidateBaseUrl(configured, isDevelopment: false).Succeeded);
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void Validate_MissingOutsideDevelopment_FailsStartup(string? configured)
{
var result = PublicUrlOptionsValidator.ValidateBaseUrl(configured, isDevelopment: false);

Assert.True(result.Failed);
Assert.Contains("Application:PublicBaseUrl", result.FailureMessage, StringComparison.Ordinal);
}

[Fact]
public void Validate_MissingInDevelopment_Succeeds()
{
// Development derives the origin from the request so dynamic local ports keep working.
Assert.True(PublicUrlOptionsValidator.ValidateBaseUrl(null, isDevelopment: true).Succeeded);
}

[Fact]
public void Validate_HttpOutsideDevelopment_Fails()
{
Assert.True(PublicUrlOptionsValidator.ValidateBaseUrl("http://viper.vetmed.ucdavis.edu/2", isDevelopment: false).Failed);
}

[Fact]
public void Validate_HttpInDevelopment_Succeeds()
{
Assert.True(PublicUrlOptionsValidator.ValidateBaseUrl("http://localhost:5000", isDevelopment: true).Succeeded);
}

[Theory]
[InlineData("/2")]
[InlineData("viper.vetmed.ucdavis.edu/2")]
[InlineData("https://user:pass@viper.vetmed.ucdavis.edu/2")]
[InlineData("https://viper.vetmed.ucdavis.edu/2?next=x")]
[InlineData("https://viper.vetmed.ucdavis.edu/2#frag")]
public void Validate_RejectsMalformedOrUnsafeValues(string configured)
{
Assert.True(PublicUrlOptionsValidator.ValidateBaseUrl(configured, isDevelopment: false).Failed);
}

#endregion

private static PublicUrlService CreateService(string? configured, string host, string pathBase)
{
var context = new DefaultHttpContext();
context.Request.Scheme = "https";
context.Request.Host = new HostString(host);
context.Request.PathBase = new PathString(pathBase);
context.Request.Path = new PathString("/CasLogin");

var accessor = Substitute.For<IHttpContextAccessor>();
accessor.HttpContext.Returns(context);

return new PublicUrlService(Options.Create(new PublicUrlOptions { PublicBaseUrl = configured }), accessor);
}
}
13 changes: 7 additions & 6 deletions test/ClinicalScheduler/EmailNotificationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using NSubstitute.ExceptionExtensions;
using Viper.Areas.ClinicalScheduler.EmailTemplates.Models;
using Viper.Areas.ClinicalScheduler.Services;
using Viper.Classes;
using Viper.Classes.SQLContext;
using Viper.EmailTemplates.Services;
using Viper.Models.ClinicalScheduler;
Expand Down Expand Up @@ -79,8 +80,8 @@ public EmailNotificationTest()
.Returns(currentYear);

// Setup email settings
var mockEmailSettingsOptions = Substitute.For<IOptions<EmailSettings>>();
mockEmailSettingsOptions.Value.Returns(new EmailSettings { BaseUrl = "https://test.example.com" });
var mockPublicUrl = Substitute.For<IPublicUrlService>();
mockPublicUrl.BaseUrl.Returns("https://test.example.com");

// Setup audit service
_mockAuditService.LogInstructorRemovedAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
Expand All @@ -92,7 +93,7 @@ public EmailNotificationTest()
_mockLogger,
_mockEmailService,
_mockEmailNotificationOptions,
mockEmailSettingsOptions,
mockPublicUrl,
_mockGradYearService,
_mockPermissionValidator,
_mockEmailTemplateRenderer);
Expand Down Expand Up @@ -560,8 +561,8 @@ public async Task RemoveInstructorScheduleAsync_MultipleEmailRecipients_SendsToA
}
};
_mockEmailNotificationOptions.Value.Returns(emailNotificationSettings);
var mockEmailSettingsOptions = Substitute.For<IOptions<EmailSettings>>();
mockEmailSettingsOptions.Value.Returns(new EmailSettings { BaseUrl = "https://test.example.com" });
var mockPublicUrl = Substitute.For<IPublicUrlService>();
mockPublicUrl.BaseUrl.Returns("https://test.example.com");

// Create a new service instance with the updated configuration
var serviceWithMultipleRecipients = new TestableScheduleEditService(
Expand All @@ -570,7 +571,7 @@ public async Task RemoveInstructorScheduleAsync_MultipleEmailRecipients_SendsToA
_mockLogger,
_mockEmailService,
_mockEmailNotificationOptions,
mockEmailSettingsOptions,
mockPublicUrl,
_mockGradYearService,
_mockPermissionValidator,
_mockEmailTemplateRenderer);
Expand Down
Loading
Loading