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": "",