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 <noreply@anthropic.com>
This commit is contained in:
parent
c085cead9d
commit
87a1bb6b3b
3 changed files with 66 additions and 10 deletions
|
|
@ -53,18 +53,29 @@ public sealed class HeimdallAuthService(
|
|||
}
|
||||
}
|
||||
|
||||
private const int RefusalBodyLimit = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static async Task<string> RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var detail = $"HTTP {(int)response.StatusCode}";
|
||||
try
|
||||
{
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>(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)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "<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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue