1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

PM-21024 ChangePasswordUri controller + service (#5845)

* add ChangePasswordUri controller and service to Icons

* add individual settings for change password uri

* add logging to change password uri controller

* use custom http client that follows redirects

* add ChangePasswordUriService tests

* remove unneeded null check

* fix copy pasta - changePasswordUriSettings

* add `HelpUsersUpdatePasswords` policy

* Remove policy for change password uri - this was removed from scope

* fix nullable warnings
This commit is contained in:
Nick Krantz
2025-08-26 07:35:23 -05:00
committed by GitHub
parent a4c4d0157b
commit 004e6285a1
9 changed files with 343 additions and 0 deletions

View File

@@ -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<ChangePasswordUriController> _logger;
public ChangePasswordUriController(
IMemoryCache memoryCache,
IDomainMappingService domainMappingService,
IChangePasswordUriService changePasswordService,
ChangePasswordUriSettings changePasswordUriSettings,
ILogger<ChangePasswordUriController> 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<IActionResult> 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));
}
}

View File

@@ -0,0 +1,11 @@
namespace Bit.Icons.Models;
public class ChangePasswordUriResponse
{
public string? uri { get; set; }
public ChangePasswordUriResponse(string? uri)
{
this.uri = uri;
}
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,89 @@
namespace Bit.Icons.Services;
public class ChangePasswordUriService : IChangePasswordUriService
{
private readonly HttpClient _httpClient;
public ChangePasswordUriService(IHttpClientFactory httpClientFactory)
{
_httpClient = httpClientFactory.CreateClient("ChangePasswordUri");
}
/// <summary>
/// Fetches the well-known change password URL for the given domain.
/// </summary>
/// <param name="domain"></param>
/// <returns></returns>
public async Task<string?> 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;
}
/// <summary>
/// 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
/// </summary>
/// <param name="urlDomain">The domain of the URL to check</param>
/// <returns>True when the domain responds with a non-ok response</returns>
private async Task<bool> 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;
}
}
/// <summary>
/// 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/
/// </summary>
/// <param name="urlDomain">The domain of the URL to check</param>
/// <returns>The well-known change password URL if valid, otherwise null</returns>
private async Task<string?> 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;
}
}
}

View File

@@ -0,0 +1,6 @@
namespace Bit.Icons.Services;
public interface IChangePasswordUriService
{
Task<string?> GetChangePasswordUri(string domain);
}

View File

@@ -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();

View File

@@ -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<IUriService, UriService>();
services.AddSingleton<IDomainMappingService, DomainMappingService>();
services.AddSingleton<IIconFetchingService, IconFetchingService>();
services.AddSingleton<IChangePasswordUriService, ChangePasswordUriService>();
}
}

View File

@@ -6,5 +6,10 @@
"cacheEnabled": true,
"cacheHours": 24,
"cacheSizeLimit": null
},
"changePasswordUriSettings": {
"cacheEnabled": true,
"cacheHours": 24,
"cacheSizeLimit": null
}
}

View File

@@ -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<ChangePasswordUriService>
{
[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<IHttpClientFactory>();
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<IHttpClientFactory>();
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<IHttpClientFactory>();
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<IHttpClientFactory>();
var service = new ChangePasswordUriService(mockHttpFactory);
var result = await service.GetChangePasswordUri(domain);
Assert.Null(result);
}
}