From 0599aedf0804e6a852f6eaf77719a6e5acd7dae2 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 09:56:19 +0000 Subject: [PATCH] Test: a 307 from Heimdall is not followed by the auth and member clients (red) AddHeimdallClients gathers the three Heimdall client registrations so the test exercises the registrations Program uses. The auth and member clients still follow redirects here; the mover already does not. Co-Authored-By: Claude Sonnet 5.5 --- .../Heimdall/HeimdallIdentityMoveClient.cs | 17 ++++- src/Bifrost.Web/Program.cs | 11 +-- .../HeimdallRedirectTests.cs | 76 +++++++++++++++++++ 3 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/HeimdallRedirectTests.cs 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); + } +}