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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
1 parent
5e34517e73
commit
125a49a1a0
5 files changed
+168
-4
No files matched your search
@@ -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
|
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.
|
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`.
|
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.
|
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,
|
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
|
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
|
||||||
|
|||||||
@@ -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<string> 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<string>();
|
||||||
|
|
||||||
|
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<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
|
||||||
|
foreach (var endpoint in endpoints)
|
||||||
|
{
|
||||||
|
var methods = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.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<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ using Serilog;
|
|||||||
using PrivaPub.Data;
|
using PrivaPub.Data;
|
||||||
using PrivaPub.Extensions;
|
using PrivaPub.Extensions;
|
||||||
using PrivaPub.Api.Mastodon.Auth;
|
using PrivaPub.Api.Mastodon.Auth;
|
||||||
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||||
using PrivaPub.Infrastructure;
|
using PrivaPub.Infrastructure;
|
||||||
using PrivaPub.Infrastructure.Cli;
|
using PrivaPub.Infrastructure.Cli;
|
||||||
using PrivaPub.Infrastructure.Data;
|
using PrivaPub.Infrastructure.Data;
|
||||||
@@ -128,6 +129,7 @@ try
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.UseCors("DefaultCORS");
|
app.UseCors("DefaultCORS");
|
||||||
|
app.UseMastodonErrorBodies();
|
||||||
|
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
var mediaRoot = app.Services.GetRequiredService<PrivaPub.Domain.Media.IMediaService>().Root;
|
var mediaRoot = app.Services.GetRequiredService<PrivaPub.Domain.Media.IMediaService>().Root;
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ namespace PrivaPub.Services
|
|||||||
ILogger<JwtEvents> _logger { get; set; }
|
ILogger<JwtEvents> _logger { get; set; }
|
||||||
const string contentType = "application/json";
|
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
|
try
|
||||||
{
|
{
|
||||||
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
|
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
|
||||||
@@ -26,7 +29,7 @@ namespace PrivaPub.Services
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
||||||
_logger.LogError(ex, "Error at AuthenticationFailed()");
|
_logger.LogError(ex, "Error at Challenge()");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ namespace PrivaPub.Services
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
||||||
_logger.LogError(ex, "Error at AuthenticationFailed()");
|
_logger.LogError(ex, "Error at Forbidden()");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user