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

Filter by extension

Filter by extension

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

namespace Viper.test.Classes;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

namespace Viper.test.Classes;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

[Fact]
public void BaseUrl_Unconfigured_NoRequest_ReturnsEmpty()
{
var accessor = Substitute.For<IHttpContextAccessor>();
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<IHttpContextAccessor>();
accessor.HttpContext.Returns(context);

return new PublicUrlService(Options.Create(new PublicUrlOptions { PublicBaseUrl = configured }), accessor);
}
}
38 changes: 14 additions & 24 deletions web/Classes/HttpHelper.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand All @@ -16,18 +16,20 @@ public static class HttpHelper
private static IHttpContextAccessor? httpContextAccessor;
private static IAuthorizationService? authorizationService;
private static IDataProtectionProvider? dataProtectionProvider;
private static IPublicUrlService? publicUrlService;

/// <summary>
/// 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)
/// </summary>
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;
}

/// <summary>
Expand Down Expand Up @@ -77,27 +79,15 @@ public static HttpContext? HttpContext
public static IDataProtectionProvider? DataProtectionProvider { get { return dataProtectionProvider; } }

/// <summary>
/// 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 <see cref="IPublicUrlService"/>.
/// </summary>
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);
}
/// <summary>
/// Gets the root URL for ColdFusion Viper based off the enviroment
Expand Down
Loading
Loading