Heimdall start refusal: log the HTTP status and Heimdall's error code #79

Merged
heimdall merged 2 commits from heimdall/start-refusal-status into main 2026-11-12 14:48:31 +00:00
3 changed files with 110 additions and 6 deletions

View file

@ -53,6 +53,43 @@ public sealed class HeimdallAuthService(
}
}
private const int RefusalBodyLimit = 4096;
/// <summary>
/// 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.
/// </summary>
private static async Task<string> RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var detail = $"HTTP {(int)response.StatusCode}";
try
{
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 '_'))
{
detail += ", " + code;
}
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
}
return $"Heimdall rejected the auth start ({detail}).";
}
public async Task<HeimdallAuthStartResult> StartAsync(
string provider,
string callbackBaseUrl,
@ -91,7 +128,7 @@ public sealed class HeimdallAuthService(
if (!response.IsSuccessStatusCode)
{
throw new HeimdallAuthException("Heimdall rejected the auth start.");
throw new HeimdallAuthException(await RefusedStartMessageAsync(response, cancellationToken));
}
var payload = await response.Content.ReadFromJsonAsync<HeimdallStartResponse>(cancellationToken);

View file

@ -1115,7 +1115,7 @@ public sealed class HeimdallCompletionTests
}
[Theory]
[InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start.")]
[InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start (HTTP 500).")]
[InlineData(HttpStatusCode.OK, "{}", "Heimdall auth start returned no authorization URL.")]
[InlineData(HttpStatusCode.OK, "{\"authorizationUrl\":\" \"}", "Heimdall auth start returned no authorization URL.")]
public async Task A_start_Heimdall_cannot_serve_issues_no_cookie_and_a_fixed_refusal(HttpStatusCode status, string body, string reason)
@ -1132,6 +1132,71 @@ public sealed class HeimdallCompletionTests
Assert.Contains("Heimdall start refused: " + reason, string.Join("\n", host.Logs.Lines));
}
[Theory]
[InlineData(HttpStatusCode.Unauthorized, """{"error":"app_auth_required"}""", "Heimdall rejected the auth start (HTTP 401, app_auth_required).")]
[InlineData(HttpStatusCode.BadRequest, """{"error":"provider_not_supported_for_app","detail":"free text"}""", "Heimdall rejected the auth start (HTTP 400, provider_not_supported_for_app).")]
[InlineData(HttpStatusCode.Unauthorized, """{"error":"<b>Not a code</b> CANARY secret=abc"}""", "Heimdall rejected the auth start (HTTP 401).")]
[InlineData(HttpStatusCode.Unauthorized, """{"error":{"nested":"app_auth_required"}}""", "Heimdall rejected the auth start (HTTP 401).")]
[InlineData(HttpStatusCode.Unauthorized, "not json at all", "Heimdall rejected the auth start (HTTP 401).")]
public async Task A_refused_start_logs_the_status_and_a_well_formed_error_code_and_nothing_else_from_the_body(
HttpStatusCode status, string body, string reason)
{
using var host = new HeimdallHost();
host.Heimdall.StartStatus = status;
host.Heimdall.StartBody = body;
var attempt = await host.StartAsync(member: true);
Assert.Equal(ErrorRedirect, attempt.Response.Headers.Location!.OriginalString);
var logs = string.Join("\n", host.Logs.Lines);
Assert.Contains("Heimdall start refused: " + reason, logs);
Assert.DoesNotContain("CANARY", logs);
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 = "<html>bad gateway</html>";
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()
{

View file

@ -207,6 +207,9 @@ public sealed class FakeHeimdall : HttpMessageHandler
public HttpStatusCode StartStatus { get; set; } = HttpStatusCode.OK;
/// <summary>A raw Content-Type header for the start answer (for example a charset .NET cannot decode); null means application/json.</summary>
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")