diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs index af61e4e..69f0255 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs @@ -313,8 +313,23 @@ public sealed partial class HeimdallIdentityMoveClient( } } -public static class HeimdallIdentityMoveClientServices +public static class HeimdallClientServices { + /// + /// Every Heimdall client Bifrost owns. The auth client carries the app secret, the member client only the member's bearer and + /// the mover the app secret and its call deadline; all share the header redaction. + /// + public static void AddHeimdallClients(this IServiceCollection services) + { + services.AddHttpClient((serviceProvider, client) => + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); + services.AddHttpClient((serviceProvider, client) => + HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); + services.AddHeimdallIdentityMoveClient(); + } + /// /// The mover's HttpClient: Heimdall's base address and app secret from HeimdallAuthService.ConfigureClient (the one secret path), the /// call deadline, no redirects (a 3xx must never carry the secret or the proofs to another origin), and the shared header redaction. diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 2985fe2..0a9f48c 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -154,15 +154,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services.AddHttpClient((serviceProvider, client) => - HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) - .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); -// Heimdall's member routes take only the member's bearer: this client carries no app secret. -builder.Services.AddHttpClient((serviceProvider, client) => - HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) - .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); -// The mover route is an app-secret call like HeimdallAuthService's: same ConfigureClient, same header redaction, no redirects. -builder.Services.AddHeimdallIdentityMoveClient(); +// The app-secret and member-bearer clients and the mover: no redirects (see HeimdallClientServices). +builder.Services.AddHeimdallClients(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/tests/Bifrost.Web.Tests/HeimdallRedirectTests.cs b/tests/Bifrost.Web.Tests/HeimdallRedirectTests.cs new file mode 100644 index 0000000..cce754b --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallRedirectTests.cs @@ -0,0 +1,76 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Heimdall; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +/// +/// Every Heimdall client as Program registers it (): a 3xx from Heimdall's origin +/// is the answer, never a second request. The app secret and a member's bearer reach only the origin they were configured for. +/// +public sealed class HeimdallRedirectTests +{ + private static int FreePort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + [Theory] + [InlineData(typeof(HeimdallAuthService), 307)] + [InlineData(typeof(HeimdallAuthService), 302)] + [InlineData(typeof(HeimdallMemberClient), 307)] + [InlineData(typeof(HeimdallMemberClient), 302)] + [InlineData(typeof(HeimdallIdentityMoveClient), 307)] + public async Task A_redirect_from_Heimdall_is_not_followed_and_no_secret_or_bearer_reaches_the_other_host(Type client, int code) + { + int first = FreePort(), second = FreePort(); + using var origin = new HttpListener(); + origin.Prefixes.Add($"http://127.0.0.1:{first}/"); + origin.Start(); + using var other = new HttpListener(); + other.Prefixes.Add($"http://127.0.0.1:{second}/"); + other.Start(); + var redirected = Task.Run(async () => + { + var context = await origin.GetContextAsync(); + context.Response.StatusCode = code; + context.Response.RedirectLocation = $"http://127.0.0.1:{second}/steal"; + context.Response.Close(); + }); + var stolen = new List(); + _ = Task.Run(async () => + { + try + { + var context = await other.GetContextAsync(); + stolen.Add(context.Request.Headers["x-heimdall-app-secret"] + context.Request.Headers["Authorization"]); + context.Response.StatusCode = 200; + context.Response.Close(); + } + catch (Exception error) when (error is HttpListenerException or ObjectDisposedException) + { + } + }); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Options.Create(new HeimdallOptions { BaseUrl = $"http://127.0.0.1:{first}", AppSecret = "APP-SECRET-VALUE" })); + services.AddHeimdallClients(); + using var provider = services.BuildServiceProvider(); + using var http = provider.GetRequiredService().CreateClient(client.Name); + using var request = new HttpRequestMessage(HttpMethod.Get, "/v1/me/identities"); + request.Headers.Authorization = new("Bearer", "MEMBER-BEARER"); + + using var response = await http.SendAsync(request); + await redirected; + await Task.Delay(300); + + Assert.Equal(code, (int)response.StatusCode); + Assert.Empty(stolen); + } +}