From 125a49a1a0665ad3371125334738a71a60c26681 Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 3 Oct 2026 10:46:20 +0200 Subject: [PATCH] T4: nothing answers 500 NeverFiveHundredTests walks every endpoint the server maps and fills each route parameter with junk ('x', zeroed and random ObjectIds, a dot-dot segment, 5000 characters). It calls each one anonymously, as a persona, and with a junk token, and asserts that nothing answers 5xx or throws, and that every /api error carries a Mastodon error body. It found two bugs: - A junk 'Authorization: Bearer' on any non-/api route, anonymous ones included (/build.json, /peasants/*, inboxes), broke the response. JwtEvents.AuthenticationFailed wrote a body during authentication and the endpoint then wrote its own. The 401 body now comes from the Challenge event, which runs only when an endpoint needs a user. - /api answered 401, 404 and 429 with no body. UseMastodonErrorBodies gives any /api error that leaves without a body Mastodon's {"error": ...}. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2 --- CLAUDE.md | 5 +- PrivaPub.Tests/Http/NeverFiveHundredTests.cs | 127 ++++++++++++++++++ .../Mastodon/Infrastructure/MastodonErrors.cs | 29 ++++ PrivaPub/Program.cs | 2 + PrivaPub/Services/JwtEvents.cs | 9 +- 5 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 PrivaPub.Tests/Http/NeverFiveHundredTests.cs create mode 100644 PrivaPub/Api/Mastodon/Infrastructure/MastodonErrors.cs diff --git a/CLAUDE.md b/CLAUDE.md index 4c050b9..af03a68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,7 +199,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ 3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long. 4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`. -5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. +5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. Any `/api` + error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from + `UseMastodonErrorBodies`. `NeverFiveHundredTests` walks every route with junk ids, anonymously, as a persona and + with a junk token. 6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist. 7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt, cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes diff --git a/PrivaPub.Tests/Http/NeverFiveHundredTests.cs b/PrivaPub.Tests/Http/NeverFiveHundredTests.cs new file mode 100644 index 0000000..b8a3f63 --- /dev/null +++ b/PrivaPub.Tests/Http/NeverFiveHundredTests.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; + +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Http +{ + [Trait("Category", "Integration")] + public sealed class NeverFiveHundredTests : IAsyncLifetime + { + static readonly string[] Junk = { "x", "000000000000000000000000", "%2e%2e", new string('a', 5000), "5f0c1a2b3c4d5e6f70819203" }; + + PrivaPubHost _host; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + public static TheoryData Credentials() => new() { "anonymous", "persona", "junk" }; + + [Theory] + [MemberData(nameof(Credentials))] + public async Task No_route_answers_500_to_junk(string credentials) + { + var token = TestContext.Current.CancellationToken; + var bearer = credentials switch + { + "persona" => await _host.MastodonToken(await _host.Persona(await _host.SignUp(), "junk")), + "junk" => "not-a-token", + _ => default + }; + var jwt = credentials == "persona" ? (await _host.SignUp()).Jwt : bearer; + var failures = new List(); + + foreach (var (method, template) in Routes()) + foreach (var value in Junk) + { + var path = Fill(template, value); + using var request = new HttpRequestMessage(new HttpMethod(method), path); + request.Headers.Add(PrivaPubHost.ClientHeader, $"10.9.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}"); + var auth = path.StartsWith("/api/", StringComparison.Ordinal) || path.StartsWith("/oauth/", StringComparison.Ordinal) ? bearer : jwt; + if (auth != default) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", auth); + if (method is "POST" or "PUT" or "PATCH") + request.Content = new StringContent("{}", Encoding.UTF8, "application/json"); + using var client = _host.Client(); + HttpResponseMessage response; + try + { + response = await client.SendAsync(request, token); + } + catch (Exception ex) + { + failures.Add($"{method} {Short(path)} threw {ex.GetType().Name}: {ex.Message}"); + continue; + } + using (response) + { + if ((int)response.StatusCode >= 500) + failures.Add($"{method} {Short(path)} answered {(int)response.StatusCode}"); + else if (path.StartsWith("/api/", StringComparison.Ordinal) && (int)response.StatusCode >= 400 && !await IsMastodonError(response)) + failures.Add($"{method} {Short(path)} answered {(int)response.StatusCode} without a Mastodon error body"); + } + } + + Assert.True(failures.Count == 0, $"{failures.Count} problems as {credentials}:\n" + string.Join("\n", failures.Distinct().Take(80))); + } + + IEnumerable<(string Method, string Template)> Routes() + { + var endpoints = _host.Services.GetRequiredService().Endpoints.OfType(); + foreach (var endpoint in endpoints) + { + var methods = endpoint.Metadata.GetMetadata()?.HttpMethods; + var template = "/" + endpoint.RoutePattern.RawText?.TrimStart('/'); + foreach (var method in methods is { Count: > 0 } ? methods : new[] { "GET", "POST" }) + yield return (method, template); + } + } + + static string Fill(string template, string value) + { + var pattern = RoutePatternFactory.Parse(template); + var builder = new StringBuilder(); + foreach (var segment in pattern.PathSegments) + { + builder.Append('/'); + foreach (var part in segment.Parts) + builder.Append(part switch + { + RoutePatternLiteralPart literal => literal.Content, + RoutePatternParameterPart => value, + RoutePatternSeparatorPart separator => separator.Content, + _ => string.Empty + }); + } + return builder.Length == 0 ? "/" : builder.ToString(); + } + + static async Task IsMastodonError(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); + try + { + return JsonNode.Parse(body) is JsonObject error && error["error"] is JsonValue; + } + catch (System.Text.Json.JsonException) + { + return false; + } + } + + static string Short(string path) => path.Length > 120 ? path[..120] + "…" : path; + } +} diff --git a/PrivaPub/Api/Mastodon/Infrastructure/MastodonErrors.cs b/PrivaPub/Api/Mastodon/Infrastructure/MastodonErrors.cs new file mode 100644 index 0000000..63b003c --- /dev/null +++ b/PrivaPub/Api/Mastodon/Infrastructure/MastodonErrors.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace PrivaPub.Api.Mastodon.Infrastructure +{ + public static class MastodonErrors + { + public static IApplicationBuilder UseMastodonErrorBodies(this IApplicationBuilder app) => + app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), api => api.UseStatusCodePages(async pages => + { + var response = pages.HttpContext.Response; + response.ContentType = "application/json; charset=utf-8"; + await response.WriteAsync(JsonSerializer.Serialize(new { error = Message(response.StatusCode) }, MastodonJson.Options)); + })); + + public static string Message(int status) => status switch + { + StatusCodes.Status401Unauthorized => "The access token is invalid", + StatusCodes.Status403Forbidden => "This action is outside the authorized scopes", + StatusCodes.Status404NotFound => "Record not found", + StatusCodes.Status405MethodNotAllowed => "Method not allowed", + StatusCodes.Status413PayloadTooLarge => "Payload too large", + StatusCodes.Status415UnsupportedMediaType => "Unsupported media type", + StatusCodes.Status422UnprocessableEntity => "Validation failed", + StatusCodes.Status429TooManyRequests => "Too many requests", + >= 500 => "Something went wrong.", + _ => "Bad request" + }; + } +} diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index e825882..f6405b5 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -12,6 +12,7 @@ using Serilog; using PrivaPub.Data; using PrivaPub.Extensions; using PrivaPub.Api.Mastodon.Auth; +using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Infrastructure; using PrivaPub.Infrastructure.Cli; using PrivaPub.Infrastructure.Data; @@ -128,6 +129,7 @@ try app.UseHttpsRedirection(); app.UseCors("DefaultCORS"); + app.UseMastodonErrorBodies(); app.UseStaticFiles(); var mediaRoot = app.Services.GetRequiredService().Root; diff --git a/PrivaPub/Services/JwtEvents.cs b/PrivaPub/Services/JwtEvents.cs index 67d40ec..fa79f80 100644 --- a/PrivaPub/Services/JwtEvents.cs +++ b/PrivaPub/Services/JwtEvents.cs @@ -14,8 +14,11 @@ namespace PrivaPub.Services ILogger _logger { get; set; } const string contentType = "application/json"; - public override async Task AuthenticationFailed(AuthenticationFailedContext context) + public override async Task Challenge(JwtBearerChallengeContext context) { + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers.WWWAuthenticate = context.AuthenticateFailure == default ? "Bearer" : "Bearer error=\"invalid_token\""; try { var localizer = context.HttpContext.RequestServices.GetRequiredService>(); @@ -26,7 +29,7 @@ namespace PrivaPub.Services catch (Exception ex) { _logger = context.HttpContext.RequestServices.GetRequiredService>(); - _logger.LogError(ex, "Error at AuthenticationFailed()"); + _logger.LogError(ex, "Error at Challenge()"); } } @@ -42,7 +45,7 @@ namespace PrivaPub.Services catch (Exception ex) { _logger = context.HttpContext.RequestServices.GetRequiredService>(); - _logger.LogError(ex, "Error at AuthenticationFailed()"); + _logger.LogError(ex, "Error at Forbidden()"); } } }