From baa70bcef08ddaf659cde3c10f85229e85e294d4 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 03:13:14 +0000 Subject: [PATCH] Move client: no redirects, one deadline over body reads, 64 KiB receipt cap, 409 move_id_conflict is Unknown A 3xx is Unknown and never carries the secret or proofs off origin (registration owns the handler); the HttpClient timeout now bounds the body read and any IOException or stall is Unknown; receipts over 64 KiB are Unknown; the request's ToString hides its proofs; move_id_conflict is Unknown because a committed move answers it when its proofs no longer verify, which keeps the sealed proofs resumable; identity ids need not be GUIDs, proofs are bounded to 8192 and identityIds unique. Co-Authored-By: Claude Sonnet 5.5 --- .../Heimdall/HeimdallIdentityMoveClient.cs | 89 +++++-- src/Bifrost.Web/Program.cs | 9 +- .../HeimdallIdentityMoveClientTests.cs | 239 +++++++++++++++++- 3 files changed, 303 insertions(+), 34 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs index b670aee..94b7853 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs @@ -13,7 +13,15 @@ namespace Bifrost.Web.Features.Heimdall; /// never names an account: the proofs, live Heimdall access tokens, say which accounts are meant. /// public sealed record HeimdallMoveRequest( - string MoveId, IReadOnlyList FromProofs, string? ToProof = null, IReadOnlyList? IdentityIds = null, string? ReversesMoveId = null); + string MoveId, IReadOnlyList FromProofs, string? ToProof = null, IReadOnlyList? IdentityIds = null, string? ReversesMoveId = null) +{ + // The proofs are live bearers: the generated ToString would print them into any log or exception that holds the request. + private bool PrintMembers(StringBuilder builder) + { + builder.Append($"MoveId = {MoveId}, FromProofs = {FromProofs?.Count}, ToProof = {(ToProof is null ? "none" : "present")}, ReversesMoveId = {ReversesMoveId}"); + return true; + } +} public enum HeimdallProofTier { @@ -37,7 +45,6 @@ public enum HeimdallMoveRefusal IdentityNotProved, AlreadyMoved, ProviderConflict, - MoveIdConflict, IdentityNotFound, RequestInvalid, Unrecognized @@ -51,7 +58,8 @@ public enum HeimdallProofSide /// /// The closed answer to a move. Moved: the receipt. Refused: Heimdall answered a 4xx before applying anything, so nothing changed. -/// Unknown: anything else (a 5xx may follow a committed move, a timeout may hide one): the caller replays the same request. +/// Unknown: anything else (a 5xx may follow a committed move, a timeout may hide one, and so may a 409 move_id_conflict: Heimdall +/// answers it to a replay of a committed move whose proofs no longer verify): the caller keeps its proofs and replays the same request. /// public abstract record HeimdallMoveOutcome { @@ -74,9 +82,13 @@ public abstract record HeimdallMoveOutcome public sealed partial class HeimdallIdentityMoveClient( HttpClient httpClient, IOptions heimdallOptions, ILogger logger) { + /// The deadline of one call, headers and body together, set on the HttpClient by . public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15); private const int MaxMovedIdentities = 256; + private const int MaxIdentityIdLength = 128; + private const int MaxReceiptBytes = 64 * 1024; + private const int MaxProofLength = 8192; [GeneratedRegex("^[A-Za-z0-9_-]{16,64}$")] private static partial Regex MoveIdShape(); @@ -91,6 +103,10 @@ public sealed partial class HeimdallIdentityMoveClient( private async Task SendAsync(HeimdallMoveRequest request, string body, CancellationToken cancellationToken) { + // One deadline over the whole call: HttpClient.Timeout ends at the headers, and a stalled body must not outlive it. + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(httpClient.Timeout); + var token = deadline.Token; try { using var message = new HttpRequestMessage( @@ -98,11 +114,11 @@ public sealed partial class HeimdallIdentityMoveClient( { Content = new StringContent(body, Encoding.UTF8, "application/json") }; - using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, token); var status = (int)response.StatusCode; if (response.StatusCode == HttpStatusCode.OK) { - return await ReadReceiptAsync(response, request, cancellationToken) is { } receipt + return await ReadReceiptAsync(response, request, token) is { } receipt ? new HeimdallMoveOutcome.Moved(receipt) : new HeimdallMoveOutcome.Unknown("malformed or mismatched receipt"); } @@ -112,18 +128,24 @@ public sealed partial class HeimdallIdentityMoveClient( return new HeimdallMoveOutcome.Unknown($"HTTP {status}"); } - var refusal = await HeimdallRefusal.ReadAsync(response, cancellationToken); + var refusal = await HeimdallRefusal.ReadAsync(response, token); return status == 400 ? new HeimdallMoveOutcome.Refused(HeimdallMoveRefusal.RequestInvalid) : Refused(refusal); } - catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException + catch (Exception error) when (error is HttpRequestException or IOException or JsonException or NotSupportedException or OperationCanceledException && !cancellationToken.IsCancellationRequested) { return new HeimdallMoveOutcome.Unknown("transport"); } } - private static HeimdallMoveOutcome.Refused Refused(HeimdallRefusalBody? refusal) + private static HeimdallMoveOutcome Refused(HeimdallRefusalBody? refusal) { + // Not a refusal: a committed move answers 409 move_id_conflict when its proofs no longer verify, so it proves nothing about the move. + if (refusal?.Code == "move_id_conflict") + { + return new HeimdallMoveOutcome.Unknown("move_id_conflict"); + } + var code = refusal?.Code switch { "app_auth_required" => HeimdallMoveRefusal.AppAuthRequired, @@ -135,20 +157,19 @@ public sealed partial class HeimdallIdentityMoveClient( "identity_not_proved" => HeimdallMoveRefusal.IdentityNotProved, "already_moved" => HeimdallMoveRefusal.AlreadyMoved, "provider_conflict" => HeimdallMoveRefusal.ProviderConflict, - "move_id_conflict" => HeimdallMoveRefusal.MoveIdConflict, "identity_not_found" => HeimdallMoveRefusal.IdentityNotFound, _ => HeimdallMoveRefusal.Unrecognized }; return code switch { - HeimdallMoveRefusal.ProofInvalid or HeimdallMoveRefusal.ProofStale => new(code, Side: refusal?.Proof switch + HeimdallMoveRefusal.ProofInvalid or HeimdallMoveRefusal.ProofStale => new HeimdallMoveOutcome.Refused(code, Side: refusal?.Proof switch { "from" => HeimdallProofSide.From, "to" => HeimdallProofSide.To, _ => null }), - HeimdallMoveRefusal.ProviderConflict => new(code, Provider: refusal?.Provider), - _ => new(code) + HeimdallMoveRefusal.ProviderConflict => new HeimdallMoveOutcome.Refused(code, Provider: refusal?.Provider), + _ => new HeimdallMoveOutcome.Refused(code) }; } @@ -158,7 +179,20 @@ public sealed partial class HeimdallIdentityMoveClient( /// private static async Task ReadReceiptAsync(HttpResponseMessage response, HeimdallMoveRequest request, CancellationToken cancellationToken) { - using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var buffer = new MemoryStream(); + var chunk = new byte[4096]; + while (await stream.ReadAsync(chunk, cancellationToken) is > 0 and var read) + { + if (buffer.Length + read > MaxReceiptBytes) + { + return null; + } + + buffer.Write(chunk, 0, read); + } + + using var document = JsonDocument.Parse(buffer.ToArray()); var root = document.RootElement; if (root.ValueKind != JsonValueKind.Object || Text(root, "moveId") != request.MoveId @@ -177,7 +211,7 @@ public sealed partial class HeimdallIdentityMoveClient( var ids = new List(); foreach (var item in moved.EnumerateArray()) { - if (item.ValueKind != JsonValueKind.String || !Guid.TryParse(item.GetString(), out _)) + if (item.ValueKind != JsonValueKind.String || item.GetString() is not { Length: > 0 and <= MaxIdentityIdLength }) { return null; } @@ -212,6 +246,8 @@ public sealed partial class HeimdallIdentityMoveClient( private static string? Text(JsonElement item, string name) => item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + private static bool IsBadProof(string? proof) => string.IsNullOrEmpty(proof) || proof.Length > MaxProofLength; + /// The contract's closed body, or ArgumentException before any I/O when the request breaks it. private static string Serialize(HeimdallMoveRequest request) { @@ -220,7 +256,7 @@ public sealed partial class HeimdallIdentityMoveClient( throw new ArgumentException("moveId must match ^[A-Za-z0-9_-]{16,64}$", nameof(request)); } - if (request.FromProofs is not { Count: >= 1 and <= 8 } || request.FromProofs.Any(string.IsNullOrWhiteSpace)) + if (request.FromProofs is not { Count: >= 1 and <= 8 } || request.FromProofs.Any(IsBadProof)) { throw new ArgumentException("fromProofs must hold 1 to 8 proofs", nameof(request)); } @@ -230,7 +266,7 @@ public sealed partial class HeimdallIdentityMoveClient( throw new ArgumentException("exactly one of toProof and reversesMoveId is required", nameof(request)); } - if (request.ToProof is not null && string.IsNullOrWhiteSpace(request.ToProof)) + if (request.ToProof is not null && IsBadProof(request.ToProof)) { throw new ArgumentException("toProof is empty", nameof(request)); } @@ -241,9 +277,10 @@ public sealed partial class HeimdallIdentityMoveClient( } if (request.IdentityIds is not null - && (request.ToProof is null || request.IdentityIds.Count is < 1 or > 32 || request.IdentityIds.Any(string.IsNullOrWhiteSpace))) + && (request.ToProof is null || request.IdentityIds.Count is < 1 or > 32 || request.IdentityIds.Any(string.IsNullOrEmpty) + || request.IdentityIds.Distinct().Count() != request.IdentityIds.Count)) { - throw new ArgumentException("identityIds are 1 to 32 and only with toProof", nameof(request)); + throw new ArgumentException("identityIds are 1 to 32 unique ids and only with toProof", nameof(request)); } var body = new Dictionary { ["moveId"] = request.MoveId, ["fromProofs"] = request.FromProofs }; @@ -265,3 +302,19 @@ public sealed partial class HeimdallIdentityMoveClient( return JsonSerializer.Serialize(body); } } + +public static class HeimdallIdentityMoveClientServices +{ + /// + /// 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. + /// + public static IHttpClientBuilder AddHeimdallIdentityMoveClient(this IServiceCollection services) => + services.AddHttpClient((serviceProvider, client) => + { + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value); + client.Timeout = HeimdallIdentityMoveClient.Timeout; + }) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false, PooledConnectionLifetime = TimeSpan.FromMinutes(2) }) + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 2590f77..38f73cc 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -158,13 +158,8 @@ builder.Services.AddHttpClient((serviceProvider, client) => 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. -builder.Services.AddHttpClient((serviceProvider, client) => - { - HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value); - client.Timeout = HeimdallIdentityMoveClient.Timeout; - }) - .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); +// The mover route is an app-secret call like HeimdallAuthService's: same ConfigureClient, same header redaction, no redirects. +builder.Services.AddHeimdallIdentityMoveClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs index 8b312ba..8157cd1 100644 --- a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs @@ -259,14 +259,16 @@ public sealed class HeimdallIdentityMoveClientTests var (d1, _) = AccountWith(moves, "discord"); Assert.Equal(new Refused(HeimdallMoveRefusal.ProviderConflict, Provider: "discord"), await Move(host, Forward(moves, s1, d1))); - // move_id_conflict: a used id under other accounts + // move_id_conflict is not a refusal: a committed move answers it when its proofs no longer verify, so the caller keeps its proofs. var (s2, _) = AccountWith(moves, "patreon"); var (d2, _) = AccountWith(moves, "twitch"); var used = Forward(moves, s2, d2); Receipt(await Move(host, used)); var (s3, _) = AccountWith(moves, "patreon"); - Assert.Equal(new Refused(HeimdallMoveRefusal.MoveIdConflict), + Assert.Equal(new Unknown("move_id_conflict"), await Move(host, new(used.MoveId, [moves.Issue(s3, stepUpMethod: Passkey)], moves.Issue(d2)))); + Assert.Equal(new Unknown("move_id_conflict"), + await Move(host, new(used.MoveId, ["forged-bearer"], used.ToProof))); // already_moved: s2 now holds nothing and an unreversed move left it Assert.Equal(new Refused(HeimdallMoveRefusal.AlreadyMoved), await Move(host, Forward(moves, s2, d0))); @@ -348,16 +350,82 @@ public sealed class HeimdallIdentityMoveClientTests await Assert.ThrowsAnyAsync(() => ClientOf(host).MoveAsync(request, cancelled.Token)); } - private sealed class Stub(string json) : HttpMessageHandler + private sealed class Stub(Func content, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler { + public Stub(string json, HttpStatusCode status = HttpStatusCode.OK) + : this(() => new StringContent(json, Encoding.UTF8, "application/json"), status) + { + } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => - Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") }); + Task.FromResult(new HttpResponseMessage(status) { Content = content() }); } - private static Task Answered(string json, HeimdallMoveRequest request) => - new HeimdallIdentityMoveClient( - new HttpClient(new Stub(json)) { BaseAddress = new Uri("https://heimdall.test") }, - Options.Create(new HeimdallOptions()), NullLogger.Instance).MoveAsync(request, CancellationToken.None); + /// A body that serves a prefix and then stalls, resets, or streams endless filler. + private sealed class Body(string prefix, bool reset = false, long endless = 0) : Stream + { + private readonly byte[] _prefix = Encoding.UTF8.GetBytes(prefix); + + public long Served { get; private set; } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position { get => Served; set => throw new NotSupportedException(); } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (Served < _prefix.Length) + { + var n = (int)Math.Min(buffer.Length, _prefix.Length - Served); + _prefix.AsMemory((int)Served, n).CopyTo(buffer); + Served += n; + return n; + } + + if (endless > 0 && Served < _prefix.Length + endless) + { + var n = (int)Math.Min(buffer.Length, _prefix.Length + endless - Served); + buffer.Span[..n].Fill((byte)'a'); + Served += n; + return n; + } + + if (reset) + { + throw new IOException("connection reset mid-body"); + } + + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + } + + private static HeimdallIdentityMoveClient Client(HttpMessageHandler handler, TimeSpan? timeout = null) => + new(new HttpClient(handler) { BaseAddress = new Uri("https://heimdall.test"), Timeout = timeout ?? TimeSpan.FromSeconds(30) }, + Options.Create(new HeimdallOptions()), NullLogger.Instance); + + private static Task Answered(string json, HeimdallMoveRequest request, HttpStatusCode status = HttpStatusCode.OK) => + Client(new Stub(json, status)).MoveAsync(request, CancellationToken.None); + + private static readonly HeimdallMoveRequest Plain = new("move-0123456789abcdef", ["p"], "q"); [Fact] public async Task A_200_that_is_not_a_whole_receipt_for_this_request_is_Unknown() @@ -370,6 +438,7 @@ public sealed class HeimdallIdentityMoveClientTests // The well formed shapes are accepted, so each refusal below is about its one flaw. Assert.IsType(await Answered(Body(), forward)); + Assert.IsType(await Answered(Body(ids: "\"not-a-guid-but-an-id\""), forward)); Assert.IsType(await Answered(Body("move-0123456789abcdef", "move-fedcba9876543210", """{"from":"strong","to":null}"""), reversal)); Assert.IsType(await Answered(Body("move-someoneelse12345"), forward)); @@ -380,7 +449,7 @@ public sealed class HeimdallIdentityMoveClientTests Assert.IsType(await Answered(Body(proof: """{"from":"strong","to":null}"""), forward)); Assert.IsType(await Answered(Body(proof: """{"from":"maybe","to":"weak"}"""), forward)); Assert.IsType(await Answered(Body(ids: ""), forward)); - Assert.IsType(await Answered(Body(ids: "\"not-a-guid\""), forward)); + Assert.IsType(await Answered(Body(ids: "123"), forward)); Assert.IsType(await Answered("""{"moveId":"move-0123456789abcdef"}""", forward)); Assert.IsType(await Answered("[]", forward)); } @@ -417,6 +486,156 @@ public sealed class HeimdallIdentityMoveClientTests Assert.Empty(moves.Requests); } + // ---- the wire: bounds, deadline, redirects, secrets ---- + + [Fact] + public async Task A_body_that_stalls_after_the_headers_is_Unknown_within_the_clients_deadline_for_a_receipt_and_a_refusal() + { + foreach (var (status, prefix) in new[] { (HttpStatusCode.OK, """{"moveId":"move-0123456789abcdef","""), (HttpStatusCode.Conflict, """{"error":"pro""") }) + { + var client = Client(new Stub(() => new StreamContent(new Body(prefix)), status), TimeSpan.FromMilliseconds(500)); + var task = client.MoveAsync(Plain, CancellationToken.None); + Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(10)))); + Assert.IsType(await task); + } + } + + [Fact] + public async Task A_connection_reset_while_reading_the_body_is_Unknown_not_an_exception() + { + var client = Client(new Stub(() => new StreamContent(new Body("""{"moveId":"move-0123456789abcdef",""", reset: true)))); + + Assert.IsType(await client.MoveAsync(Plain, CancellationToken.None)); + } + + [Fact] + public async Task A_receipt_body_past_64_KiB_is_refused_unread_as_Unknown() + { + var body = new Body("""{"moveId":"move-0123456789abcdef","accountId":" """, endless: 64L * 1024 * 1024); + var client = Client(new Stub(() => new StreamContent(body))); + + Assert.IsType(await client.MoveAsync(Plain, CancellationToken.None)); + Assert.True(body.Served < 1024 * 1024, $"read {body.Served} bytes"); + } + + [Fact] + public async Task Refusal_qualifiers_are_kept_only_for_the_codes_that_carry_them() + { + Assert.Equal(new Refused(HeimdallMoveRefusal.SameAccount), + await Answered("""{"error":"same_account","proof":"from","provider":"discord"}""", Plain, HttpStatusCode.Conflict)); + Assert.Equal(new Refused(HeimdallMoveRefusal.AlreadyMoved), + await Answered("""{"error":"already_moved","proof":"to"}""", Plain, HttpStatusCode.Conflict)); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProviderConflict, Provider: "discord"), + await Answered("""{"error":"provider_conflict","proof":"to","provider":"discord"}""", Plain, HttpStatusCode.Conflict)); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofStale, HeimdallProofSide.To), + await Answered("""{"error":"proof_stale","proof":"to","provider":"discord"}""", Plain, HttpStatusCode.Forbidden)); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofInvalid), + await Answered("""{"error":"proof_invalid","proof":"sideways"}""", Plain, HttpStatusCode.Forbidden)); + } + + [Fact] + public async Task The_largest_legal_request_is_sent_and_moves_what_it_names() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var source = NewAccount(); + var ids = Enumerable.Range(0, 32).Select(_ => moves.AddLogin(source, "discord")).ToArray(); + var (destination, _) = AccountWith(moves, "twitch"); + var proofs = Enumerable.Range(0, 8).Select(_ => moves.Issue(source, stepUpMethod: Passkey)).ToArray(); + + var receipt = Receipt(await Move(host, new(NewMoveId(), proofs, moves.Issue(destination), ids))); + + Assert.Equal(ids, receipt.MovedIdentityIds); + Assert.Equal(8, JsonDocument.Parse(moves.Requests[0].Body).RootElement.GetProperty("fromProofs").GetArrayLength()); + } + + [Fact] + public void A_request_does_not_print_its_proofs() + { + var request = new HeimdallMoveRequest("move-0123456789abcdef", ["FROM-BEARER-SECRET"], "TO-BEARER-SECRET"); + + Assert.DoesNotContain("BEARER-SECRET", request.ToString()); + Assert.Contains("move-0123456789abcdef", request.ToString()); + } + + private static ServiceProvider RealRegistration(string baseUrl) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Options.Create(new HeimdallOptions { BaseUrl = baseUrl, AppSecret = "APP-SECRET-VALUE" })); + services.AddHeimdallIdentityMoveClient(); + return services.BuildServiceProvider(); + } + + [Fact] + public void The_registered_client_carries_the_app_secret_and_the_15_second_deadline() + { + using var provider = RealRegistration("https://heimdall.test"); + var http = provider.GetRequiredService().CreateClient(nameof(HeimdallIdentityMoveClient)); + + Assert.Equal(TimeSpan.FromSeconds(15), http.Timeout); + Assert.Equal(new Uri("https://heimdall.test"), http.BaseAddress); + Assert.Equal("APP-SECRET-VALUE", Assert.Single(http.DefaultRequestHeaders.GetValues(HeimdallAuthService.AppSecretHeader))); + } + + [Theory] + [InlineData(307)] + [InlineData(302)] + public async Task A_redirect_is_Unknown_and_nothing_reaches_the_other_origin(int code) + { + 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; + } + + 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"] + new StreamReader(context.Request.InputStream).ReadToEnd()); + var forged = Encoding.UTF8.GetBytes($$$"""{"moveId":"move-0123456789abcdef","accountId":"attacker","movedIdentityIds":["{{{Guid.NewGuid()}}}"],"proof":{"from":"weak","to":"weak"}}"""); + context.Response.StatusCode = 200; + await context.Response.OutputStream.WriteAsync(forged); + context.Response.Close(); + } + catch (HttpListenerException) + { + } + catch (ObjectDisposedException) + { + } + }); + using var provider = RealRegistration($"http://127.0.0.1:{first}"); + + var outcome = await provider.GetRequiredService() + .MoveAsync(new("move-0123456789abcdef", ["FROM-BEARER"], "TO-BEARER"), CancellationToken.None); + await redirected; + await Task.Delay(300); + + Assert.IsType(outcome); + Assert.Empty(stolen); + } + // ---- what the logs hold ---- [Fact] @@ -434,6 +653,8 @@ public sealed class HeimdallIdentityMoveClientTests Assert.Contains("Heimdall identity move: Moved", logs); Assert.Contains("Heimdall identity move: Refused", logs); Assert.Contains("identity-moves", logs); + // Only the secret and bearer headers are redacted: an ordinary header's value is logged, so the shared redaction is what is in force. + Assert.Contains("application/json", logs); foreach (var secret in new[] { HeimdallHost.AppSecret, source, destination, request.FromProofs[0], request.ToProof!, request.MoveId }.Concat(ids)) { Assert.DoesNotContain(secret, logs);