From 5b9c0316560fcf629f795048c17cad78a3792c58 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Mon, 17 Aug 2026 13:34:26 -0700 Subject: [PATCH] fix(auth): build CAS callbacks from a configured canonical origin CAS login, ticket validation and logout derived their service URL from HttpHelper.GetRootURL(), which reads the request Host, and AllowedHosts was "*" in every environment. A Host header that got past the proxies could therefore poison a CAS callback. - Application:PublicBaseUrl per environment, validated on start so a deployed environment fails fast rather than falling back to the request - AllowedHosts narrowed to the real TEST/PROD hostnames plus localhost - GetRootURL() returns the canonical origin when configured, so the sitemap and emulation links stop being request-derived too - Login's /api guard strips the PathBase, so an API ReturnUrl gets a 401 instead of a CAS HTML redirect under the deployed /2 sub-app - Retire EmailSettings:BaseUrl, which held the same public origin under an email-specific name. Email links, the health-check collector and CAS now read one setting, so the two cannot drift --- test/Classes/HomeControllerCasUrlTests.cs | 148 +++++++++++++++ test/Classes/PublicUrlServiceTests.cs | 179 ++++++++++++++++++ .../EmailNotificationTest.cs | 13 +- .../ControllerServiceIntegrationTest.cs | 8 +- .../ScheduleEditServiceRollbackTest.cs | 7 +- .../ScheduleEditServiceTest.cs | 7 +- .../TestableScheduleEditService.cs | 5 +- test/Effort/VerificationServiceTests.cs | 19 +- .../Services/ScheduleEditService.cs | 9 +- .../Effort/Services/VerificationService.cs | 17 +- .../HealthChecks/HealthCheckExtensions.cs | 4 +- web/Classes/HttpHelper.cs | 38 ++-- web/Classes/PublicUrlService.cs | 176 +++++++++++++++++ web/Controllers/HomeController.cs | 39 +++- web/Program.cs | 26 +-- web/Services/EmailService.cs | 6 - web/appsettings.Production.json | 11 +- web/appsettings.Test.json | 9 +- 18 files changed, 619 insertions(+), 102 deletions(-) create mode 100644 test/Classes/HomeControllerCasUrlTests.cs create mode 100644 test/Classes/PublicUrlServiceTests.cs create mode 100644 web/Classes/PublicUrlService.cs diff --git a/test/Classes/HomeControllerCasUrlTests.cs b/test/Classes/HomeControllerCasUrlTests.cs new file mode 100644 index 000000000..bcb823d39 --- /dev/null +++ b/test/Classes/HomeControllerCasUrlTests.cs @@ -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; + +/// +/// 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. +/// +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(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(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(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(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(controller.Login("/2/api/students/dvm")); + } + + [Fact] + public void Login_ApiReturnUrlWithoutPathBase_ReturnsUnauthorized() + { + var controller = CreateController("localhost:7157", pathBase: string.Empty); + + Assert.IsType(controller.Login("/api/students/dvm")); + } + + [Fact] + public async Task Logout_BuildsServiceFromConfiguredOrigin_NotHostHeader() + { + var controller = CreateController(ForgedHost, pathBase: "/2"); + + var result = Assert.IsType(await controller.Logout()); + + Assert.DoesNotContain(ForgedHost, result.Url, StringComparison.OrdinalIgnoreCase); + Assert.Equal($"{CasBaseUrl}logout?service={WebUtility.UrlEncode(PublicBaseUrl)}", result.Url); + } + + /// + /// Pulls the decoded CAS service parameter out of the redirect so assertions read as URLs + /// rather than percent-encoded soup. + /// + 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()); + + var controller = new HomeController( + Substitute.For(), + Options.Create(new CasSettings { CasBaseUrl = CasBaseUrl }), + publicUrl, + Substitute.For(), + Substitute.For(), + Substitute.For()); + + 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(); + var services = Substitute.For(); + services.GetService(typeof(IAuthenticationService)).Returns(authentication); + return services; + } +} diff --git a/test/Classes/PublicUrlServiceTests.cs b/test/Classes/PublicUrlServiceTests.cs new file mode 100644 index 000000000..c964244ac --- /dev/null +++ b/test/Classes/PublicUrlServiceTests.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Viper.Classes; + +namespace Viper.test.Classes; + +/// +/// 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. +/// +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(); + 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(); + 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(); + accessor.HttpContext.Returns(context); + + return new PublicUrlService(Options.Create(new PublicUrlOptions { PublicBaseUrl = configured }), accessor); + } +} diff --git a/test/ClinicalScheduler/EmailNotificationTest.cs b/test/ClinicalScheduler/EmailNotificationTest.cs index 7dd97c420..06baafbe4 100644 --- a/test/ClinicalScheduler/EmailNotificationTest.cs +++ b/test/ClinicalScheduler/EmailNotificationTest.cs @@ -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; @@ -79,8 +80,8 @@ public EmailNotificationTest() .Returns(currentYear); // Setup email settings - var mockEmailSettingsOptions = Substitute.For>(); - mockEmailSettingsOptions.Value.Returns(new EmailSettings { BaseUrl = "https://test.example.com" }); + var mockPublicUrl = Substitute.For(); + mockPublicUrl.BaseUrl.Returns("https://test.example.com"); // Setup audit service _mockAuditService.LogInstructorRemovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -92,7 +93,7 @@ public EmailNotificationTest() _mockLogger, _mockEmailService, _mockEmailNotificationOptions, - mockEmailSettingsOptions, + mockPublicUrl, _mockGradYearService, _mockPermissionValidator, _mockEmailTemplateRenderer); @@ -560,8 +561,8 @@ public async Task RemoveInstructorScheduleAsync_MultipleEmailRecipients_SendsToA } }; _mockEmailNotificationOptions.Value.Returns(emailNotificationSettings); - var mockEmailSettingsOptions = Substitute.For>(); - mockEmailSettingsOptions.Value.Returns(new EmailSettings { BaseUrl = "https://test.example.com" }); + var mockPublicUrl = Substitute.For(); + mockPublicUrl.BaseUrl.Returns("https://test.example.com"); // Create a new service instance with the updated configuration var serviceWithMultipleRecipients = new TestableScheduleEditService( @@ -570,7 +571,7 @@ public async Task RemoveInstructorScheduleAsync_MultipleEmailRecipients_SendsToA _mockLogger, _mockEmailService, _mockEmailNotificationOptions, - mockEmailSettingsOptions, + mockPublicUrl, _mockGradYearService, _mockPermissionValidator, _mockEmailTemplateRenderer); diff --git a/test/ClinicalScheduler/Integration/ControllerServiceIntegrationTest.cs b/test/ClinicalScheduler/Integration/ControllerServiceIntegrationTest.cs index 9bb83e89c..d7bf6692d 100644 --- a/test/ClinicalScheduler/Integration/ControllerServiceIntegrationTest.cs +++ b/test/ClinicalScheduler/Integration/ControllerServiceIntegrationTest.cs @@ -14,6 +14,8 @@ using Viper.Services; using CS = Viper.Models.ClinicalScheduler; +using Viper.Classes; + namespace Viper.test.ClinicalScheduler.Integration { /// @@ -55,8 +57,8 @@ public ControllerServiceIntegrationTest() var mockEmailService = Substitute.For(); var mockEmailNotificationSettings = Substitute.For>(); mockEmailNotificationSettings.Value.Returns(new EmailNotificationSettings()); - var mockEmailSettings = Substitute.For>(); - mockEmailSettings.Value.Returns(new EmailSettings()); + var mockPublicUrl = Substitute.For(); + mockPublicUrl.BaseUrl.Returns("https://test.example.com"); var mockGradYearService = Substitute.For(); var mockPermissionValidator = Substitute.For(); var mockEmailTemplateRenderer = Substitute.For(); @@ -67,7 +69,7 @@ public ControllerServiceIntegrationTest() scheduleEditLogger, mockEmailService, mockEmailNotificationSettings, - mockEmailSettings, + mockPublicUrl, mockGradYearService, mockPermissionValidator, mockEmailTemplateRenderer); diff --git a/test/ClinicalScheduler/ScheduleEditServiceRollbackTest.cs b/test/ClinicalScheduler/ScheduleEditServiceRollbackTest.cs index 7d08884f6..dd3a09fc5 100644 --- a/test/ClinicalScheduler/ScheduleEditServiceRollbackTest.cs +++ b/test/ClinicalScheduler/ScheduleEditServiceRollbackTest.cs @@ -5,6 +5,7 @@ using NSubstitute; using NSubstitute.ExceptionExtensions; using Viper.Areas.ClinicalScheduler.Services; +using Viper.Classes; using Viper.Classes.SQLContext; using Viper.EmailTemplates.Services; using Viper.Services; @@ -56,8 +57,8 @@ public ScheduleEditServiceRollbackTest() var emailNotificationOptions = Substitute.For>(); emailNotificationOptions.Value.Returns(new EmailNotificationSettings()); - var emailSettingsOptions = Substitute.For>(); - emailSettingsOptions.Value.Returns(new EmailSettings()); + var publicUrl = Substitute.For(); + publicUrl.BaseUrl.Returns("https://test.example.com"); _service = new ScheduleEditService( _context, @@ -65,7 +66,7 @@ public ScheduleEditServiceRollbackTest() Substitute.For>(), Substitute.For(), emailNotificationOptions, - emailSettingsOptions, + publicUrl, gradYearService, permissionValidator, Substitute.For()); diff --git a/test/ClinicalScheduler/ScheduleEditServiceTest.cs b/test/ClinicalScheduler/ScheduleEditServiceTest.cs index a05887886..08488427d 100644 --- a/test/ClinicalScheduler/ScheduleEditServiceTest.cs +++ b/test/ClinicalScheduler/ScheduleEditServiceTest.cs @@ -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; @@ -96,8 +97,8 @@ public ScheduleEditServiceTest() SeedTestData(); // Setup email settings - var mockEmailSettingsOptions = Substitute.For>(); - mockEmailSettingsOptions.Value.Returns(new EmailSettings { BaseUrl = "https://test.example.com" }); + var mockPublicUrl = Substitute.For(); + mockPublicUrl.BaseUrl.Returns("https://test.example.com"); _service = new TestableScheduleEditService( _context, @@ -105,7 +106,7 @@ public ScheduleEditServiceTest() _mockLogger, _mockEmailService, _mockEmailNotificationOptions, - mockEmailSettingsOptions, + mockPublicUrl, _mockGradYearService, _mockPermissionValidator, _mockEmailTemplateRenderer); diff --git a/test/ClinicalScheduler/TestableScheduleEditService.cs b/test/ClinicalScheduler/TestableScheduleEditService.cs index 51a2443fc..b79b65152 100644 --- a/test/ClinicalScheduler/TestableScheduleEditService.cs +++ b/test/ClinicalScheduler/TestableScheduleEditService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Viper.Areas.ClinicalScheduler.Services; +using Viper.Classes; using Viper.Classes.SQLContext; using Viper.EmailTemplates.Services; using Viper.Services; @@ -19,11 +20,11 @@ public TestableScheduleEditService( ILogger logger, IEmailService emailService, IOptions emailNotificationOptions, - IOptions emailSettingsOptions, + IPublicUrlService publicUrl, IGradYearService gradYearService, IPermissionValidator permissionValidator, IEmailTemplateRenderer emailTemplateRenderer) - : base(context, auditService, logger, emailService, emailNotificationOptions, emailSettingsOptions, gradYearService, permissionValidator, emailTemplateRenderer) + : base(context, auditService, logger, emailService, emailNotificationOptions, publicUrl, gradYearService, permissionValidator, emailTemplateRenderer) { } diff --git a/test/Effort/VerificationServiceTests.cs b/test/Effort/VerificationServiceTests.cs index 3d6273af7..18b79ea58 100644 --- a/test/Effort/VerificationServiceTests.cs +++ b/test/Effort/VerificationServiceTests.cs @@ -10,6 +10,7 @@ using Viper.Areas.Effort.Models.DTOs.Responses; using Viper.Areas.Effort.Models.Entities; using Viper.Areas.Effort.Services; +using Viper.Classes; using Viper.Classes.SQLContext; using Viper.EmailTemplates.Services; using Viper.Models.VIPER; @@ -66,11 +67,8 @@ public VerificationServiceTests() }; var settingsOptions = Options.Create(_settings); - var emailSettings = new EmailSettings - { - BaseUrl = "https://test.example.com" - }; - var emailSettingsOptions = Options.Create(emailSettings); + var publicUrl = Substitute.For(); + publicUrl.BaseUrl.Returns("https://test.example.com"); _emailTemplateRendererMock = Substitute.For(); _emailTemplateRendererMock @@ -102,7 +100,7 @@ public VerificationServiceTests() _classificationServiceMock, _loggerMock, settingsOptions, - emailSettingsOptions, + publicUrl, _emailTemplateRendererMock); SeedTestData(); @@ -670,10 +668,9 @@ public async Task SendVerificationEmailAsync_ReturnsError_WhenBaseUrlNotConfigur VerificationEmailSubject = "Please Verify Your Effort", VerificationReplyDays = 7 }; - var badEmailSettings = new EmailSettings - { - BaseUrl = "" // Missing/empty BaseUrl - }; + // Missing canonical origin: Application:PublicBaseUrl unset + var badPublicUrl = Substitute.For(); + badPublicUrl.BaseUrl.Returns(string.Empty); var serviceWithBadConfig = new VerificationService( _context, @@ -685,7 +682,7 @@ public async Task SendVerificationEmailAsync_ReturnsError_WhenBaseUrlNotConfigur _classificationServiceMock, _loggerMock, Options.Create(badSettings), - Options.Create(badEmailSettings), + badPublicUrl, _emailTemplateRendererMock); _permissionServiceMock.GetCurrentUserEmail().Returns("sender@ucdavis.edu"); diff --git a/web/Areas/ClinicalScheduler/Services/ScheduleEditService.cs b/web/Areas/ClinicalScheduler/Services/ScheduleEditService.cs index 9f9643b3c..b7d8ea606 100644 --- a/web/Areas/ClinicalScheduler/Services/ScheduleEditService.cs +++ b/web/Areas/ClinicalScheduler/Services/ScheduleEditService.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Viper.Areas.ClinicalScheduler.EmailTemplates.Models; +using Viper.Classes; using Viper.Classes.SQLContext; using Viper.Classes.Utilities; using Viper.EmailTemplates.Services; @@ -21,7 +22,7 @@ public class ScheduleEditService : IScheduleEditService private readonly ILogger _logger; private readonly IEmailService _emailService; private readonly EmailNotificationSettings _emailNotificationSettings; - private readonly EmailSettings _emailSettings; + private readonly IPublicUrlService _publicUrl; private readonly IGradYearService _gradYearService; private readonly IPermissionValidator _permissionValidator; private readonly IEmailTemplateRenderer _emailTemplateRenderer; @@ -32,7 +33,7 @@ public ScheduleEditService( ILogger logger, IEmailService emailService, IOptions emailNotificationOptions, - IOptions emailSettingsOptions, + IPublicUrlService publicUrl, IGradYearService gradYearService, IPermissionValidator permissionValidator, IEmailTemplateRenderer emailTemplateRenderer) @@ -42,7 +43,7 @@ public ScheduleEditService( _logger = logger; _emailService = emailService; _emailNotificationSettings = emailNotificationOptions.Value; - _emailSettings = emailSettingsOptions.Value; + _publicUrl = publicUrl; _gradYearService = gradYearService; _permissionValidator = permissionValidator; _emailTemplateRenderer = emailTemplateRenderer; @@ -656,7 +657,7 @@ private async Task SendPrimaryEvaluatorRemovedNotificationAsync(InstructorSchedu return; } // Get base URL for links - var baseUrl = string.IsNullOrWhiteSpace(_emailSettings.BaseUrl) ? null : _emailSettings.BaseUrl; + var baseUrl = string.IsNullOrWhiteSpace(_publicUrl.BaseUrl) ? null : _publicUrl.BaseUrl; // Get instructor information var instructorName = "Unknown Instructor"; diff --git a/web/Areas/Effort/Services/VerificationService.cs b/web/Areas/Effort/Services/VerificationService.cs index 5ab438658..12ad6aba0 100644 --- a/web/Areas/Effort/Services/VerificationService.cs +++ b/web/Areas/Effort/Services/VerificationService.cs @@ -7,6 +7,7 @@ using Viper.Areas.Effort.Models; using Viper.Areas.Effort.Models.DTOs.Responses; using Viper.Areas.Effort.Models.Entities; +using Viper.Classes; using Viper.Classes.SQLContext; using Viper.Classes.Utilities; using Viper.EmailTemplates.Services; @@ -29,7 +30,7 @@ public class VerificationService : IVerificationService private readonly ICourseClassificationService _classificationService; private readonly ILogger _logger; private readonly EffortSettings _settings; - private readonly EmailSettings _emailSettings; + private readonly IPublicUrlService _publicUrl; private readonly IEmailTemplateRenderer _emailTemplateRenderer; public VerificationService( @@ -42,7 +43,7 @@ public VerificationService( ICourseClassificationService classificationService, ILogger logger, IOptions settings, - IOptions emailSettings, + IPublicUrlService publicUrl, IEmailTemplateRenderer emailTemplateRenderer) { _context = context; @@ -54,7 +55,7 @@ public VerificationService( _classificationService = classificationService; _logger = logger; _settings = settings.Value; - _emailSettings = emailSettings.Value; + _publicUrl = publicUrl; _emailTemplateRenderer = emailTemplateRenderer; } @@ -719,15 +720,15 @@ private async Task> GetCourseRelationshipsAsync( private string BuildVerificationUrl(int termCode) { // Require configured base URL to avoid Host header injection - if (string.IsNullOrWhiteSpace(_emailSettings.BaseUrl)) + if (string.IsNullOrWhiteSpace(_publicUrl.BaseUrl)) { - throw new InvalidOperationException("EmailSettings:BaseUrl must be configured for verification emails."); + throw new InvalidOperationException("Application:PublicBaseUrl must be configured for verification emails."); } - var baseUrlNormalized = _emailSettings.BaseUrl.TrimEnd('/') + "/"; + var baseUrlNormalized = _publicUrl.BaseUrl.TrimEnd('/') + "/"; if (!Uri.TryCreate(baseUrlNormalized, UriKind.Absolute, out var baseUri)) { - throw new InvalidOperationException($"EmailSettings:BaseUrl value '{_emailSettings.BaseUrl}' is not a valid absolute URL."); + throw new InvalidOperationException($"Configured public base URL '{_publicUrl.BaseUrl}' is not a valid absolute URL."); } return new Uri(baseUri, $"Effort/{termCode}/my-effort").ToString(); @@ -846,7 +847,7 @@ private VerificationReminderViewModel BuildVerificationEmailViewModel( return new VerificationReminderViewModel { - BaseUrl = _emailSettings.BaseUrl ?? "", + BaseUrl = _publicUrl.BaseUrl, TermDescription = termDescription, TermStartDate = termStartDate, TermEndDate = termEndDate, diff --git a/web/Classes/HealthChecks/HealthCheckExtensions.cs b/web/Classes/HealthChecks/HealthCheckExtensions.cs index d11df6c7d..1ed3767b7 100644 --- a/web/Classes/HealthChecks/HealthCheckExtensions.cs +++ b/web/Classes/HealthChecks/HealthCheckExtensions.cs @@ -232,8 +232,8 @@ public static IServiceCollection AddViperHealthChecks( // UseApiEndpointDelegatingHandler below) so the endpoint filter // can recognize the self-call without widening the IP allowlist // to cover whatever NAT'd source IP the loop-out produces. - // Dev has no BaseUrl configured, so fall back to a relative URL. - var baseUrl = configuration["EmailSettings:BaseUrl"]?.TrimEnd('/'); + // Dev leaves the canonical origin unset, so fall back to a relative URL. + var baseUrl = configuration["Application:PublicBaseUrl"]?.TrimEnd('/'); var healthEndpointUrl = string.IsNullOrWhiteSpace(baseUrl) ? "/health/detail" : $"{baseUrl}/health/detail"; diff --git a/web/Classes/HttpHelper.cs b/web/Classes/HttpHelper.cs index ffde52032..987f9d618 100644 --- a/web/Classes/HttpHelper.cs +++ b/web/Classes/HttpHelper.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using NLog; +using Viper.Classes; namespace Viper { @@ -16,18 +16,20 @@ public static class HttpHelper private static IHttpContextAccessor? httpContextAccessor; private static IAuthorizationService? authorizationService; private static IDataProtectionProvider? dataProtectionProvider; + private static IPublicUrlService? publicUrlService; /// - /// Configures the helper with system-wide services (memory cache, configuration, environment, context accessor, authorization, data protection) + /// Configures the helper with system-wide services (memory cache, configuration, environment, context accessor, authorization, data protection, public URL) /// - public static void Configure(IMemoryCache? memoryCache, IConfiguration? configurationSettings, IWebHostEnvironment env, IHttpContextAccessor? httpContextAccessor, IAuthorizationService? authorizationService, IDataProtectionProvider? dataProtectionProvider) + public static void Configure(IMemoryCache? memoryCache, IConfiguration? configurationSettings, IWebHostEnvironment env, IHttpContextAccessor? contextAccessor, IAuthorizationService? authService, IDataProtectionProvider? dataProtection, IPublicUrlService? publicUrl = null) { Cache = memoryCache; Settings = configurationSettings; Environment = env; - HttpHelper.httpContextAccessor = httpContextAccessor; - HttpHelper.authorizationService = authorizationService; - HttpHelper.dataProtectionProvider = dataProtectionProvider; + httpContextAccessor = contextAccessor; + authorizationService = authService; + dataProtectionProvider = dataProtection; + publicUrlService = publicUrl; } /// @@ -77,27 +79,15 @@ public static HttpContext? HttpContext public static IDataProtectionProvider? DataProtectionProvider { get { return dataProtectionProvider; } } /// - /// Gets the root URL including protocol and port for Viper.Net + /// Gets the root URL including protocol and port for Viper.Net. Deployed environments + /// return the configured canonical origin (Application:PublicBaseUrl); Development + /// derives it from the request. See . /// public static string GetRootURL() { - string rootURL = String.Empty; - - HttpRequest? thisRequest = httpContextAccessor?.HttpContext?.Request; - - if (thisRequest != null) - { - Uri url = new(thisRequest.GetDisplayUrl()); - rootURL = url.GetLeftPart(UriPartial.Authority); - - if (url.AbsolutePath.StartsWith("/2/")) - { - rootURL += "/2"; - } - - } - - return rootURL ?? String.Empty; + return publicUrlService != null + ? publicUrlService.BaseUrl + : PublicUrlService.FromRequest(httpContextAccessor?.HttpContext?.Request); } /// /// Gets the root URL for ColdFusion Viper based off the enviroment diff --git a/web/Classes/PublicUrlService.cs b/web/Classes/PublicUrlService.cs new file mode 100644 index 000000000..7c2af55eb --- /dev/null +++ b/web/Classes/PublicUrlService.cs @@ -0,0 +1,176 @@ +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.Options; + +namespace Viper.Classes +{ + /// + /// Canonical public origin for this deployment, bound from the "Application" configuration + /// section. Deployed environments must set it; Development derives the origin from the + /// request so the dynamic local port keeps working. + /// + public class PublicUrlOptions + { + public const string SectionName = "Application"; + + /// + /// Absolute base URL including scheme, host, optional port and PathBase, e.g. + /// "https://viper.vetmed.ucdavis.edu/2". + /// + public string? PublicBaseUrl { get; set; } + } + + /// + /// Supplies the origin for URLs that leave the application (CAS service callbacks, sitemap + /// entries, emulation links). Deployed environments read it from configuration so a forged + /// Host header cannot influence a security callback. + /// + public interface IPublicUrlService + { + /// + /// Canonical base URL with no trailing slash, e.g. "https://viper.vetmed.ucdavis.edu/2". + /// + string BaseUrl { get; } + + /// + /// Canonical base URL plus an application-relative path, e.g. BuildUrl("/CasLogin"). + /// + string BuildUrl(string relativePath); + } + + /// + public class PublicUrlService : IPublicUrlService + { + private readonly string? _configuredBaseUrl; + private readonly IHttpContextAccessor _httpContextAccessor; + + public PublicUrlService(IOptions options, IHttpContextAccessor httpContextAccessor) + { + _configuredBaseUrl = NormalizeBaseUrl(options.Value.PublicBaseUrl); + _httpContextAccessor = httpContextAccessor; + } + + public string BaseUrl + { + get + { + if (_configuredBaseUrl != null) + { + return _configuredBaseUrl; + } + + string fromRequest = FromRequest(_httpContextAccessor.HttpContext?.Request); + return fromRequest.Length > 0 ? fromRequest : LocalDevelopmentOrigin(); + } + } + + public string BuildUrl(string relativePath) + { + if (string.IsNullOrEmpty(relativePath)) + { + return BaseUrl; + } + + return BaseUrl + (relativePath.StartsWith('/') ? relativePath : "/" + relativePath); + } + + /// + /// Trims whitespace and any trailing slash so callers can append "/Path" unconditionally. + /// Returns null when nothing is configured. + /// + public static string? NormalizeBaseUrl(string? configured) + { + return string.IsNullOrWhiteSpace(configured) ? null : configured.Trim().TrimEnd('/'); + } + + /// + /// Last resort for Development work that has no request to derive from, such as email + /// sent from a background job. Deployed environments never reach it because + /// PublicUrlOptionsValidator fails startup when the canonical origin is missing. + /// + private static string LocalDevelopmentOrigin() + { + string httpsPort = System.Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORT") ?? "7157"; + return int.TryParse(httpsPort, out int port) && port > 0 && port < 65536 + ? $"https://localhost:{port}" + : string.Empty; + } + + /// + /// Development fallback: derive the origin from the current request, preserving the + /// PathBase. Deployed environments never reach this because PublicUrlOptionsValidator + /// fails startup when the setting is missing. + /// + public static string FromRequest(HttpRequest? request) + { + if (request == null) + { + return string.Empty; + } + + string origin = new Uri(request.GetDisplayUrl()).GetLeftPart(UriPartial.Authority); + return origin + request.PathBase.Value?.TrimEnd('/'); + } + } + + /// + /// Fails startup when a deployed environment has no usable canonical origin, so the app + /// cannot silently fall back to request-derived URLs for CAS callbacks. + /// + public class PublicUrlOptionsValidator : IValidateOptions + { + private readonly IWebHostEnvironment _environment; + + public PublicUrlOptionsValidator(IWebHostEnvironment environment) + { + _environment = environment; + } + + public ValidateOptionsResult Validate(string? name, PublicUrlOptions options) + { + return ValidateBaseUrl(options.PublicBaseUrl, _environment.IsDevelopment()); + } + + /// + /// Exposed for tests: applies the same rules the startup validator uses. + /// + public static ValidateOptionsResult ValidateBaseUrl(string? configured, bool isDevelopment) + { + const string setting = "Application:PublicBaseUrl"; + string? normalized = PublicUrlService.NormalizeBaseUrl(configured); + + if (normalized == null) + { + return isDevelopment + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail($"{setting} is required outside Development. Set it to the canonical public URL, for example https://viper.vetmed.ucdavis.edu/2."); + } + + if (!Uri.TryCreate(normalized, UriKind.Absolute, out Uri? uri)) + { + return ValidateOptionsResult.Fail($"{setting} must be an absolute URL."); + } + + if (uri.Scheme != Uri.UriSchemeHttps && !(isDevelopment && uri.Scheme == Uri.UriSchemeHttp)) + { + return ValidateOptionsResult.Fail($"{setting} must use https outside Development."); + } + + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + return ValidateOptionsResult.Fail($"{setting} must not contain user information."); + } + + if (!string.IsNullOrEmpty(uri.Query)) + { + return ValidateOptionsResult.Fail($"{setting} must not contain a query string."); + } + + if (!string.IsNullOrEmpty(uri.Fragment)) + { + return ValidateOptionsResult.Fail($"{setting} must not contain a fragment."); + } + + return ValidateOptionsResult.Success; + } + } +} diff --git a/web/Controllers/HomeController.cs b/web/Controllers/HomeController.cs index 1f7d613b7..8fd4053c5 100644 --- a/web/Controllers/HomeController.cs +++ b/web/Controllers/HomeController.cs @@ -8,7 +8,6 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Caching.Memory; @@ -36,13 +35,15 @@ public class HomeController : AreaController #pragma warning restore S5332 private readonly IHttpClientFactory _clientFactory; private readonly CasSettings _settings; + private readonly IPublicUrlService _publicUrl; private readonly List _casAttributesToCapture = new() { "authenticationDate", "credentialType" }; private readonly IUserHelper _userHelper; - public HomeController(IHttpClientFactory clientFactory, IOptions settingsOptions, AAUDContext aAUDContext, RAPSContext rapsContext, VIPERContext viperContext) + public HomeController(IHttpClientFactory clientFactory, IOptions settingsOptions, IPublicUrlService publicUrl, AAUDContext aAUDContext, RAPSContext rapsContext, VIPERContext viperContext) { this._clientFactory = clientFactory; this._settings = settingsOptions.Value; + this._publicUrl = publicUrl; this._aAUDContext = aAUDContext; this._rapsContext = rapsContext; this._viperContext = viperContext; @@ -94,16 +95,15 @@ private NavMenu Nav() [SearchExclude] public IActionResult Login([FromQuery] string? ReturnUrl = null) { - Uri url = new(Request.GetDisplayUrl()); - string baseURl = url.GetLeftPart(UriPartial.Authority); - string returnURL = HttpHelper.GetRootURL().Replace(baseURl, ""); + // Default to the application root under the deployed PathBase ("" locally, "/2" on TEST/PROD). + string returnURL = Request.PathBase.Value ?? string.Empty; if (!string.IsNullOrEmpty(ReturnUrl)) { returnURL = ReturnUrl; } - if (returnURL.StartsWith("/api")) + if (IsApiPath(returnURL)) { return Unauthorized(); } @@ -113,6 +113,24 @@ public IActionResult Login([FromQuery] string? ReturnUrl = null) return new RedirectResult(authorizationEndpoint); } + /// + /// The SPAs send ReturnUrl already prefixed with the deployed PathBase ("/2/api/..."), + /// so the base has to come off before testing for an API path or the guard never fires + /// on TEST/PROD and an API caller gets a CAS HTML redirect instead of a 401. + /// + private bool IsApiPath(string returnUrl) + { + string path = returnUrl; + string? basePath = Request.PathBase.Value; + + if (!string.IsNullOrEmpty(basePath) && path.StartsWith(basePath, StringComparison.OrdinalIgnoreCase)) + { + path = path[basePath.Length..]; + } + + return path.StartsWith("/api", StringComparison.OrdinalIgnoreCase); + } + [Route("/[action]")] [SearchExclude] public IActionResult RefreshSession() @@ -260,7 +278,7 @@ public async Task Logout() await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); // Send homepage link after CAS logout - var returnUrl = WebUtility.UrlEncode(HttpHelper.GetRootURL()); + var returnUrl = WebUtility.UrlEncode(_publicUrl.BaseUrl); return new RedirectResult(_settings.CasBaseUrl + "logout?service=" + returnUrl); } @@ -287,13 +305,14 @@ public IActionResult MyPermissions() /// - /// Utility function for creating redirect URLs + /// Utility function for creating redirect URLs. Built from the configured canonical + /// origin, never the request Host, so a forged Host cannot poison a CAS callback. /// /// /// Compiled URL - private static string BuildRedirectUri(string targetPath) + private string BuildRedirectUri(string targetPath) { - return HttpHelper.GetRootURL() + targetPath; + return _publicUrl.BuildUrl(targetPath); } /// diff --git a/web/Program.cs b/web/Program.cs index 0b80fcd7c..8a6673221 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -149,6 +149,14 @@ // Add CAS settings from appSettings configuration builder.Services.Configure(builder.Configuration.GetSection("Cas")); + // Canonical public origin for CAS callbacks and other outward-facing links. Validated on + // start so a deployed environment fails fast instead of falling back to the request Host. + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(PublicUrlOptions.SectionName)) + .ValidateOnStart(); + builder.Services.AddSingleton, PublicUrlOptionsValidator>(); + builder.Services.AddSingleton(); + // Define authorization policies builder.Services.AddAuthorization(options => { @@ -232,22 +240,6 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db builder.Services.Configure(builder.Configuration.GetSection("EffortSettings")); - // In development, derive BaseUrl from ASPNETCORE_HTTPS_PORT if not explicitly configured - if (builder.Environment.IsDevelopment()) - { - builder.Services.PostConfigure(settings => - { - if (string.IsNullOrWhiteSpace(settings.BaseUrl)) - { - var httpsPort = Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORT") ?? "7157"; - if (int.TryParse(httpsPort, out var port) && port > 0 && port < 65536) - { - settings.BaseUrl = $"https://localhost:{port}"; - } - } - }); - } - // Harvest phases (order matters for DI resolution, but phases self-order via Order property) builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -522,7 +514,7 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db pattern: "{controller=Home}/{action=Index}").RequireAuthorization(); // Setup the memory cache so we can use it via a simple static method - HttpHelper.Configure(app.Services.GetService(), app.Services.GetService(), app.Environment, app.Services.GetService(), app.Services.GetService(), app.Services.GetService()); + HttpHelper.Configure(app.Services.GetService(), app.Services.GetService(), app.Environment, app.Services.GetService(), app.Services.GetService(), app.Services.GetService(), app.Services.GetRequiredService()); #pragma warning disable S6966 // app.Run() is appropriate for main entry point, not app.RunAsync() app.Run(); diff --git a/web/Services/EmailService.cs b/web/Services/EmailService.cs index 8c91b75d1..4b1e4e308 100644 --- a/web/Services/EmailService.cs +++ b/web/Services/EmailService.cs @@ -311,12 +311,6 @@ public class EmailSettings public string DefaultFromAddress { get; set; } = "noreply@example.com"; public bool UseMailpit { get; set; } = false; - /// - /// Base URL for links in emails (e.g., "https://viper.vetmed.ucdavis.edu/2"). - /// Used to construct absolute URLs for email content. - /// - public string? BaseUrl { get; set; } - /// /// When true, all emails are redirected to the logged-in user's email address. /// Use for non-production environments to allow testers to see emails their actions generate. diff --git a/web/appsettings.Production.json b/web/appsettings.Production.json index 8b96ae2a0..8277341d6 100644 --- a/web/appsettings.Production.json +++ b/web/appsettings.Production.json @@ -7,6 +7,14 @@ } }, "LoggingPath": "s:\\nlog", + // Only the hostnames this environment is actually reached by. Defence in depth behind + // Cloudflare/F5/IIS: CAS callbacks come from Application:PublicBaseUrl, not the Host header. + // localhost is kept so on-server probes and IIS itself are not rejected. + "AllowedHosts": "viper.vetmed.ucdavis.edu;localhost", + "Application": { + // Canonical public origin, including the /2 PathBase of the IIS sub-app. + "PublicBaseUrl": "https://viper.vetmed.ucdavis.edu/2" + }, "ConnectionStrings": { "AAUD": "", "Courses": "", @@ -29,8 +37,7 @@ "SmtpPort": 25, "EnableSsl": true, "DefaultFromAddress": "svmithelp@ucdavis.edu", - "UseMailpit": false, - "BaseUrl": "https://viper.vetmed.ucdavis.edu/2" + "UseMailpit": false }, "Hangfire": { "DashboardAppPath": "/2/Computing" diff --git a/web/appsettings.Test.json b/web/appsettings.Test.json index c8de916ae..d365db58e 100644 --- a/web/appsettings.Test.json +++ b/web/appsettings.Test.json @@ -7,6 +7,14 @@ } }, "LoggingPath": "s:\\nlog", + // Only the hostnames this environment is actually reached by. Defence in depth behind + // Cloudflare/F5/IIS: CAS callbacks come from Application:PublicBaseUrl, not the Host header. + // localhost is kept so on-server probes and IIS itself are not rejected. + "AllowedHosts": "secure-test.vetmed.ucdavis.edu;localhost", + "Application": { + // Canonical public origin, including the /2 PathBase of the IIS sub-app. + "PublicBaseUrl": "https://secure-test.vetmed.ucdavis.edu/2" + }, "ConnectionStrings": { "AAUD": "", "Courses": "", @@ -33,7 +41,6 @@ "EnableSsl": true, "DefaultFromAddress": "svmithelp@ucdavis.edu", "UseMailpit": false, - "BaseUrl": "https://secure-test.vetmed.ucdavis.edu/2", "RedirectToCurrentUser": true }, "AWS": {