diff --git a/src/Icons/Controllers/ChangePasswordUriController.cs b/src/Icons/Controllers/ChangePasswordUriController.cs new file mode 100644 index 0000000000..3f2bc91cf2 --- /dev/null +++ b/src/Icons/Controllers/ChangePasswordUriController.cs @@ -0,0 +1,89 @@ +using Bit.Icons.Models; +using Bit.Icons.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace Bit.Icons.Controllers; + +[Route("change-password-uri")] +public class ChangePasswordUriController : Controller +{ + private readonly IMemoryCache _memoryCache; + private readonly IDomainMappingService _domainMappingService; + private readonly IChangePasswordUriService _changePasswordService; + private readonly ChangePasswordUriSettings _changePasswordSettings; + private readonly ILogger _logger; + + public ChangePasswordUriController( + IMemoryCache memoryCache, + IDomainMappingService domainMappingService, + IChangePasswordUriService changePasswordService, + ChangePasswordUriSettings changePasswordUriSettings, + ILogger logger) + { + _memoryCache = memoryCache; + _domainMappingService = domainMappingService; + _changePasswordService = changePasswordService; + _changePasswordSettings = changePasswordUriSettings; + _logger = logger; + } + + [HttpGet("config")] + public IActionResult GetConfig() + { + return new JsonResult(new + { + _changePasswordSettings.CacheEnabled, + _changePasswordSettings.CacheHours, + _changePasswordSettings.CacheSizeLimit + }); + } + + [HttpGet] + public async Task Get([FromQuery] string uri) + { + if (string.IsNullOrWhiteSpace(uri)) + { + return new BadRequestResult(); + } + + var uriHasProtocol = uri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + uri.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + + var url = uriHasProtocol ? uri : $"https://{uri}"; + if (!Uri.TryCreate(url, UriKind.Absolute, out var validUri)) + { + return new BadRequestResult(); + } + + var domain = validUri.Host; + + var mappedDomain = _domainMappingService.MapDomain(domain); + if (!_changePasswordSettings.CacheEnabled || !_memoryCache.TryGetValue(mappedDomain, out string? changePasswordUri)) + { + var result = await _changePasswordService.GetChangePasswordUri(domain); + if (result == null) + { + _logger.LogWarning("Null result returned for {0}.", domain); + changePasswordUri = null; + } + else + { + changePasswordUri = result; + } + + if (_changePasswordSettings.CacheEnabled) + { + _logger.LogInformation("Cache uri for {0}.", domain); + _memoryCache.Set(mappedDomain, changePasswordUri, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = new TimeSpan(_changePasswordSettings.CacheHours, 0, 0), + Size = changePasswordUri?.Length ?? 0, + Priority = changePasswordUri == null ? CacheItemPriority.High : CacheItemPriority.Normal + }); + } + } + + return Ok(new ChangePasswordUriResponse(changePasswordUri)); + } +} diff --git a/src/Icons/Models/ChangePasswordUriResponse.cs b/src/Icons/Models/ChangePasswordUriResponse.cs new file mode 100644 index 0000000000..def6806bd3 --- /dev/null +++ b/src/Icons/Models/ChangePasswordUriResponse.cs @@ -0,0 +1,11 @@ +namespace Bit.Icons.Models; + +public class ChangePasswordUriResponse +{ + public string? uri { get; set; } + + public ChangePasswordUriResponse(string? uri) + { + this.uri = uri; + } +} diff --git a/src/Icons/Models/ChangePasswordUriSettings.cs b/src/Icons/Models/ChangePasswordUriSettings.cs new file mode 100644 index 0000000000..bcb804f4e0 --- /dev/null +++ b/src/Icons/Models/ChangePasswordUriSettings.cs @@ -0,0 +1,8 @@ +namespace Bit.Icons.Models; + +public class ChangePasswordUriSettings +{ + public virtual bool CacheEnabled { get; set; } + public virtual int CacheHours { get; set; } + public virtual long? CacheSizeLimit { get; set; } +} diff --git a/src/Icons/Services/ChangePasswordUriService.cs b/src/Icons/Services/ChangePasswordUriService.cs new file mode 100644 index 0000000000..6f2b73efff --- /dev/null +++ b/src/Icons/Services/ChangePasswordUriService.cs @@ -0,0 +1,89 @@ +namespace Bit.Icons.Services; + +public class ChangePasswordUriService : IChangePasswordUriService +{ + private readonly HttpClient _httpClient; + + public ChangePasswordUriService(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient("ChangePasswordUri"); + } + + /// + /// Fetches the well-known change password URL for the given domain. + /// + /// + /// + public async Task GetChangePasswordUri(string domain) + { + if (string.IsNullOrWhiteSpace(domain)) + { + return null; + } + + var hasReliableStatusCode = await HasReliableHttpStatusCode(domain); + var wellKnownChangePasswordUrl = await GetWellKnownChangePasswordUrl(domain); + + + if (hasReliableStatusCode && wellKnownChangePasswordUrl != null) + { + return wellKnownChangePasswordUrl; + } + + // Reliable well-known URL criteria not met, return null + return null; + } + + /// + /// Checks if the server returns a non-200 status code for a resource that should not exist. + // See https://w3c.github.io/webappsec-change-password-url/response-code-reliability.html#semantics + /// + /// The domain of the URL to check + /// True when the domain responds with a non-ok response + private async Task HasReliableHttpStatusCode(string urlDomain) + { + try + { + var url = new UriBuilder(urlDomain) + { + Path = "/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200" + }; + + var request = new HttpRequestMessage(HttpMethod.Get, url.ToString()); + + var response = await _httpClient.SendAsync(request); + return !response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + /// + /// Builds a well-known change password URL for the given origin. Attempts to fetch the URL to ensure a valid response + /// is returned. Returns null if the request throws or the response is not 200 OK. + /// See https://w3c.github.io/webappsec-change-password-url/ + /// + /// The domain of the URL to check + /// The well-known change password URL if valid, otherwise null + private async Task GetWellKnownChangePasswordUrl(string urlDomain) + { + try + { + var url = new UriBuilder(urlDomain) + { + Path = "/.well-known/change-password" + }; + + var request = new HttpRequestMessage(HttpMethod.Get, url.ToString()); + + var response = await _httpClient.SendAsync(request); + return response.IsSuccessStatusCode ? url.ToString() : null; + } + catch + { + return null; + } + } +} diff --git a/src/Icons/Services/IChangePasswordUriService.cs b/src/Icons/Services/IChangePasswordUriService.cs new file mode 100644 index 0000000000..f010255db5 --- /dev/null +++ b/src/Icons/Services/IChangePasswordUriService.cs @@ -0,0 +1,6 @@ +namespace Bit.Icons.Services; + +public interface IChangePasswordUriService +{ + Task GetChangePasswordUri(string domain); +} diff --git a/src/Icons/Startup.cs b/src/Icons/Startup.cs index 4695c320e9..16bbdef553 100644 --- a/src/Icons/Startup.cs +++ b/src/Icons/Startup.cs @@ -2,6 +2,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Icons.Extensions; +using Bit.Icons.Models; using Bit.SharedWeb.Utilities; using Microsoft.Net.Http.Headers; @@ -27,8 +28,11 @@ public class Startup // Settings var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment); var iconsSettings = new IconsSettings(); + var changePasswordUriSettings = new ChangePasswordUriSettings(); ConfigurationBinder.Bind(Configuration.GetSection("IconsSettings"), iconsSettings); + ConfigurationBinder.Bind(Configuration.GetSection("ChangePasswordUriSettings"), changePasswordUriSettings); services.AddSingleton(s => iconsSettings); + services.AddSingleton(s => changePasswordUriSettings); // Http client services.ConfigureHttpClients(); @@ -41,6 +45,10 @@ public class Startup { options.SizeLimit = iconsSettings.CacheSizeLimit; }); + services.AddMemoryCache(options => + { + options.SizeLimit = changePasswordUriSettings.CacheSizeLimit; + }); // Services services.AddServices(); diff --git a/src/Icons/Util/ServiceCollectionExtension.cs b/src/Icons/Util/ServiceCollectionExtension.cs index 5492cda0cf..3bd3537198 100644 --- a/src/Icons/Util/ServiceCollectionExtension.cs +++ b/src/Icons/Util/ServiceCollectionExtension.cs @@ -28,6 +28,24 @@ public static class ServiceCollectionExtension AllowAutoRedirect = false, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, }); + + // The CreatePasswordUri handler wants similar headers as Icons to portray coming from a browser but + // needs to follow redirects to get the final URL. + services.AddHttpClient("ChangePasswordUri", client => + { + client.Timeout = TimeSpan.FromSeconds(20); + client.MaxResponseContentBufferSize = 5000000; // 5 MB + // Let's add some headers to look like we're coming from a web browser request. Some websites + // will block our request without these. + client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"); + client.DefaultRequestHeaders.Add("Accept-Language", "en-US,en;q=0.8"); + client.DefaultRequestHeaders.Add("Cache-Control", "no-cache"); + client.DefaultRequestHeaders.Add("Pragma", "no-cache"); + }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }); } public static void AddHtmlParsing(this IServiceCollection services) @@ -40,5 +58,6 @@ public static class ServiceCollectionExtension services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/Icons/appsettings.json b/src/Icons/appsettings.json index 6b4e2992e0..5e1113b150 100644 --- a/src/Icons/appsettings.json +++ b/src/Icons/appsettings.json @@ -6,5 +6,10 @@ "cacheEnabled": true, "cacheHours": 24, "cacheSizeLimit": null + }, + "changePasswordUriSettings": { + "cacheEnabled": true, + "cacheHours": 24, + "cacheSizeLimit": null } } diff --git a/test/Icons.Test/Services/ChangePasswordUriServiceTests.cs b/test/Icons.Test/Services/ChangePasswordUriServiceTests.cs new file mode 100644 index 0000000000..53b883733b --- /dev/null +++ b/test/Icons.Test/Services/ChangePasswordUriServiceTests.cs @@ -0,0 +1,108 @@ +using System.Net; +using Bit.Icons.Services; +using Bit.Test.Common.MockedHttpClient; +using NSubstitute; +using Xunit; + +namespace Bit.Icons.Test.Services; + +public class ChangePasswordUriServiceTests : ServiceTestBase +{ + [Theory] + [InlineData("https://example.com", "https://example.com:443/.well-known/change-password")] + public async Task GetChangePasswordUri_WhenBothChecksPass_ReturnsWellKnownUrl(string domain, string expectedUrl) + { + // Arrange + var mockedHandler = new MockedHttpMessageHandler(); + + var nonExistentUrl = $"{domain}/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200"; + var changePasswordUrl = $"{domain}/.well-known/change-password"; + + // Mock the response for the resource-that-should-not-exist request (returns 404) + mockedHandler + .When(nonExistentUrl) + .RespondWith(HttpStatusCode.NotFound) + .WithContent(new StringContent("Not found")); + + // Mock the response for the change-password request (returns 200) + mockedHandler + .When(changePasswordUrl) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Ok")); + + var mockHttpFactory = Substitute.For(); + mockHttpFactory.CreateClient("ChangePasswordUri").Returns(mockedHandler.ToHttpClient()); + + var service = new ChangePasswordUriService(mockHttpFactory); + + var result = await service.GetChangePasswordUri(domain); + + Assert.Equal(expectedUrl, result); + } + + [Theory] + [InlineData("https://example.com")] + public async Task GetChangePasswordUri_WhenResourceThatShouldNotExistReturns200_ReturnsNull(string domain) + { + var mockHttpFactory = Substitute.For(); + var mockedHandler = new MockedHttpMessageHandler(); + + mockedHandler + .When(HttpMethod.Get, $"{domain}/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Ok")); + + mockedHandler + .When(HttpMethod.Get, $"{domain}/.well-known/change-password") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Ok")); + + var httpClient = mockedHandler.ToHttpClient(); + mockHttpFactory.CreateClient("ChangePasswordUri").Returns(httpClient); + + var service = new ChangePasswordUriService(mockHttpFactory); + + var result = await service.GetChangePasswordUri(domain); + + Assert.Null(result); + } + + [Theory] + [InlineData("https://example.com")] + public async Task GetChangePasswordUri_WhenChangePasswordUrlNotFound_ReturnsNull(string domain) + { + var mockHttpFactory = Substitute.For(); + var mockedHandler = new MockedHttpMessageHandler(); + + mockedHandler + .When(HttpMethod.Get, $"{domain}/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200") + .RespondWith(HttpStatusCode.NotFound) + .WithContent(new StringContent("Not found")); + + mockedHandler + .When(HttpMethod.Get, $"{domain}/.well-known/change-password") + .RespondWith(HttpStatusCode.NotFound) + .WithContent(new StringContent("Not found")); + + var httpClient = mockedHandler.ToHttpClient(); + mockHttpFactory.CreateClient("ChangePasswordUri").Returns(httpClient); + + var service = new ChangePasswordUriService(mockHttpFactory); + + var result = await service.GetChangePasswordUri(domain); + + Assert.Null(result); + } + + [Theory] + [InlineData("")] + public async Task GetChangePasswordUri_WhenDomainIsNullOrEmpty_ReturnsNull(string domain) + { + var mockHttpFactory = Substitute.For(); + var service = new ChangePasswordUriService(mockHttpFactory); + + var result = await service.GetChangePasswordUri(domain); + + Assert.Null(result); + } +}