From 87a1bb6b3b4f1cd552ffa84f55e21fc42d37ea41 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 14:41:14 +0000 Subject: [PATCH] Start refusal: bound the body read, survive undecodable bodies, pin the code length cap Soul pass on c085cead: a refusal with an unsupported charset escaped as a 500, the body was read without a bound, and the 64-character cap was untested. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Heimdall/HeimdallAuthService.cs | 23 +++++++--- .../HeimdallCompletionTests.cs | 43 +++++++++++++++++++ .../Support/HeimdallTestKit.cs | 10 +++-- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index e3abda2..b34a21d 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -53,18 +53,29 @@ public sealed class HeimdallAuthService( } } + private const int RefusalBodyLimit = 4096; + /// - /// Names the HTTP status and, when the body carries one, Heimdall's error code. The code is shown only - /// if it is a short snake_case token, so nothing else from the body (free text, tokens) reaches a log. + /// Names the HTTP status and, when the first 4 KiB of the body carry one, Heimdall's error code. The code + /// is shown only if it is a short snake_case token, so nothing else from the body (free text, tokens) + /// reaches a log. A body that cannot be read or parsed leaves the status alone: a refusal never becomes a 500. /// private static async Task RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var detail = $"HTTP {(int)response.StatusCode}"; try { - var body = await response.Content.ReadFromJsonAsync(cancellationToken); - if (body.ValueKind == JsonValueKind.Object - && body.TryGetProperty("error", out var error) + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var head = new byte[RefusalBodyLimit]; + var length = 0; + while (length < head.Length && await stream.ReadAsync(head.AsMemory(length), cancellationToken) is > 0 and var read) + { + length += read; + } + + using var body = JsonDocument.Parse(head.AsMemory(0, length)); + if (body.RootElement.ValueKind == JsonValueKind.Object + && body.RootElement.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.String && error.GetString() is { Length: > 0 and <= 64 } code && code.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) @@ -72,7 +83,7 @@ public sealed class HeimdallAuthService( detail += ", " + code; } } - catch (JsonException) + catch (Exception) when (!cancellationToken.IsCancellationRequested) { } diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index f7e40de..da9463a 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -1154,6 +1154,49 @@ public sealed class HeimdallCompletionTests Assert.DoesNotContain("free text", logs); } + [Fact] + public async Task A_code_longer_than_sixty_four_characters_is_not_logged() + { + using var host = new HeimdallHost(); + host.Heimdall.StartStatus = HttpStatusCode.Unauthorized; + host.Heimdall.StartBody = "{\"error\":\"" + new string('a', 65) + "\"}"; + + await host.StartAsync(member: true); + + var logs = string.Join("\n", host.Logs.Lines); + Assert.Contains("Heimdall start refused: Heimdall rejected the auth start (HTTP 401).", logs); + Assert.DoesNotContain(new string('a', 65), logs); + } + + [Theory] + [InlineData("text/html; charset=windows-1252")] + [InlineData("text/html; charset=bogus")] + [InlineData("text/html; charset=utf-7")] + public async Task A_refusal_whose_body_cannot_be_decoded_still_redirects_with_the_status(string contentType) + { + using var host = new HeimdallHost(); + host.Heimdall.StartStatus = HttpStatusCode.BadGateway; + host.Heimdall.StartContentType = contentType; + host.Heimdall.StartBody = "bad gateway"; + + var attempt = await host.StartAsync(); + + Assert.Equal(ErrorRedirect, attempt.Response.Headers.Location!.OriginalString); + Assert.Contains("Heimdall start refused: Heimdall rejected the auth start (HTTP 502).", string.Join("\n", host.Logs.Lines)); + } + + [Fact] + public async Task Only_the_head_of_a_refusal_body_is_read() + { + using var host = new HeimdallHost(); + host.Heimdall.StartStatus = HttpStatusCode.Unauthorized; + host.Heimdall.StartBody = "{\"pad\":\"" + new string('x', 200_000) + "\",\"error\":\"app_auth_required\"}"; + + await host.StartAsync(member: true); + + Assert.Contains("Heimdall start refused: Heimdall rejected the auth start (HTTP 401).", string.Join("\n", host.Logs.Lines)); + } + [Fact] public async Task Refusals_echo_no_input_in_responses_or_logs() { diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index bd5f917..67dd4fe 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -207,6 +207,9 @@ public sealed class FakeHeimdall : HttpMessageHandler public HttpStatusCode StartStatus { get; set; } = HttpStatusCode.OK; + /// A raw Content-Type header for the start answer (for example a charset .NET cannot decode); null means application/json. + public string? StartContentType { get; set; } + public string StartBody { get; set; } = """{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}"""; @@ -263,10 +266,9 @@ public sealed class FakeHeimdall : HttpMessageHandler }; } - return new HttpResponseMessage(StartStatus) - { - Content = new StringContent(StartBody, Encoding.UTF8, "application/json") - }; + var content = new ByteArrayContent(Encoding.UTF8.GetBytes(StartBody)); + content.Headers.TryAddWithoutValidation("Content-Type", StartContentType ?? "application/json"); + return new HttpResponseMessage(StartStatus) { Content = content }; } if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/auth-completions/redeem")