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..6da56a940 --- /dev/null +++ b/test/Classes/PublicUrlServiceTests.cs @@ -0,0 +1,164 @@ +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_ReturnsEmpty() + { + var accessor = Substitute.For(); + accessor.HttpContext.ReturnsNull(); + var service = new PublicUrlService(Options.Create(new PublicUrlOptions()), accessor); + + Assert.Equal(string.Empty, 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/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..cf93f9b7c --- /dev/null +++ b/web/Classes/PublicUrlService.cs @@ -0,0 +1,151 @@ +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 => _configuredBaseUrl ?? FromRequest(_httpContextAccessor.HttpContext?.Request); + + 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('/'); + } + + /// + /// 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..831bc147f 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 => { @@ -522,7 +530,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/appsettings.Production.json b/web/appsettings.Production.json index 8b96ae2a0..7c66ec76e 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": "", diff --git a/web/appsettings.Test.json b/web/appsettings.Test.json index c8de916ae..48786fdf8 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": "",