Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ Seed__AdminPassword=replace-with-a-strong-password
# RateLimiting__Refresh__PermitLimit=60
# RateLimiting__Refresh__WindowSeconds=900

# Browser error reports (#217): the anonymous /api/v1/client-errors endpoint is
# throttled per client IP so it can't be used to flood the log. The default is
# enough for a genuinely crashing screen, tiny for an abuser.
# RateLimiting__ClientErrors__PermitLimit=10
# RateLimiting__ClientErrors__WindowSeconds=300

# OpenTelemetry export (#214 traces, #215 metrics). Both signals go over OTLP
# to the SAME endpoint, ONLY when it is set; leave it unset to keep telemetry
# in-process (no exporter, no overhead). A malformed endpoint or protocol
Expand Down
8 changes: 8 additions & 0 deletions src/Cluckwork.Api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# #217 — sourcemaps are an OPERATOR tool, not a public asset. The build emits
# them "hidden" (no sourceMappingURL comment), but anything left in dist/ ends
# up in wwwroot where a predictable "<bundle>.js.map" URL would serve the full
# original sources to anyone. Park them next to the app instead: resolve a
# reported stack with docker cp <container>:/app/sourcemaps .
RUN mkdir /web/sourcemaps && \
find /web/dist -name '*.map' -exec mv {} /web/sourcemaps/ \;

# --- API build: restore + publish the .NET app ---
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
Expand All @@ -26,4 +33,5 @@ EXPOSE 8080
FROM runtime AS final
COPY --from=build /out .
COPY --from=web-build /web/dist ./wwwroot
COPY --from=web-build /web/sourcemaps ./sourcemaps
ENTRYPOINT ["dotnet", "Cluckwork.Api.dll"]
153 changes: 153 additions & 0 deletions src/Cluckwork.Api/Endpoints/ClientErrors/ClientErrorEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
namespace Cluckwork.Api.Endpoints.ClientErrors;

using System.Text.Json;
using Cluckwork.Api.RateLimiting;
using Microsoft.AspNetCore.Http.Features;

// #217 — the SPA's ErrorBoundary reports render crashes here so the operator
// learns a screen is crashing without a support screenshot. The report is
// WRITTEN TO THE LOG and stored nowhere else: no table, no retention question,
// and the existing log pipeline (console, OTLP when enabled) carries it.
//
// Everything about the endpoint assumes a hostile caller, because it is
// anonymous by design — the login screen can crash too, and a crashing
// authenticated app may no longer hold a usable token. Per-IP rate limit
// (#143 infrastructure), a total byte cap read under our control, and
// per-field truncation bound what one address can push into the log.
public static class ClientErrorEndpoints
{
// Total request-body cap. A real report is a few KB of stack; 16 KB gives
// slack for deep component trees without letting one POST carry a novel.
public const int MaxReportBytes = 16 * 1024;

// Per-field bounds applied AFTER the byte cap: the cap limits the request,
// these keep any single log line readable and bounded even at the cap.
private const int MaxMessageChars = 2000;
private const int MaxStackChars = 8000;
private const int MaxRouteChars = 500;
private const int MaxShortChars = 100;

public static RouteGroupBuilder MapClientErrorEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/", Report)
.AllowAnonymous()
.RequireRateLimiting(RateLimitingOptions.ClientErrorsPolicyName)
.WithName("ReportClientError")
.WithSummary("Accept a browser error report and write it to the server log.");
return group;
}

// Bound manually (HttpRequest, not a [FromBody] DTO): model binding would
// buffer an arbitrarily large body before our code ran; here the read loop
// below is the size guarantee, same pattern as the logo upload (#123).
private static async Task<IResult> Report(
HttpRequest request, ILogger<ClientErrorReport> logger, CancellationToken ct)
{
// A declared oversize is refused without reading a byte. Content-Length
// is only a claim, which is why the read below is capped as well.
if (request.ContentLength > MaxReportBytes)
return TooLarge();

// Best-effort transport cutoff (absent under TestServer, read-only once
// the body is touched); the loop bound is the guarantee, not this.
var sizeLimit = request.HttpContext.Features.Get<IHttpMaxRequestBodySizeFeature>();
if (sizeLimit is { IsReadOnly: false })
sizeLimit.MaxRequestBodySize = MaxReportBytes;

// One byte of headroom: filling past MaxReportBytes proves the body is
// over the cap without reading whatever else the client meant to send.
var buffer = new byte[MaxReportBytes + 1];
var read = 0;
while (read < buffer.Length)
{
var n = await request.Body.ReadAsync(buffer.AsMemory(read, buffer.Length - read), ct);
if (n == 0) break;
read += n;
}
if (read > MaxReportBytes)
return TooLarge();

ClientErrorReport? report;
try
{
report = JsonSerializer.Deserialize<ClientErrorReport>(
buffer.AsSpan(0, read), JsonOptions);
}
catch (JsonException)
{
return Results.Problem(
title: "Malformed report", statusCode: StatusCodes.Status400BadRequest,
detail: "The error report is not valid JSON.");
}

if (report is null || string.IsNullOrWhiteSpace(report.Message))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["message"] = ["A non-empty message is required."]
});

var scope = report.Scope?.ToLowerInvariant();
if (scope is not ("app" or "screen"))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["scope"] = ["Scope must be \"app\" or \"screen\"."]
});

// Template carries the operator-facing line; the bulky fields ride as
// structured properties via the provider's scope handling (#216), so
// one query key (Scope/Route/ClientTraceId) finds them without the
// stacks shouting in the console template.
using (logger.BeginScope(new Dictionary<string, object?>
{
["Stack"] = Truncate(report.Stack, MaxStackChars),
["ComponentStack"] = Truncate(report.ComponentStack, MaxStackChars),
["AppVersion"] = Truncate(report.AppVersion, MaxShortChars),
["ClientTraceId"] = Truncate(report.TraceId, MaxShortChars)
}))
{
// Scope is validated to two literals above; Message and Route are
// the only anonymous-caller strings the console template RENDERS,
// so they get control characters stripped — CR/LF (or an ANSI
// escape) would let one report forge extra plain-text log lines.
// The stacks keep theirs: they ride as structured properties the
// console line never shows, and a stack without newlines is
// useless.
logger.LogError("Client error ({Scope}) at {Route}: {Message}",
scope,
Sanitize(Truncate(report.Route, MaxRouteChars)) ?? "(unknown)",
Sanitize(Truncate(report.Message, MaxMessageChars)));
}

return Results.Accepted();
}

private static IResult TooLarge() => Results.Problem(
title: "Report too large", statusCode: StatusCodes.Status413PayloadTooLarge,
detail: $"Error reports are capped at {MaxReportBytes} bytes.");

private static string? Truncate(string? value, int max) =>
value is null || value.Length <= max ? value : value[..max];

private static string? Sanitize(string? value) =>
value is null || !value.Any(char.IsControl)
? value
: string.Create(value.Length, value, static (chars, source) =>
{
for (var i = 0; i < source.Length; i++)
chars[i] = char.IsControl(source[i]) ? ' ' : source[i];
});

private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web);
}

// The report shape the SPA sends. Everything but the message and scope is
// optional: a crash report with holes still beats silence.
public sealed record ClientErrorReport(
string? Message,
string? Stack,
string? ComponentStack,
string? Scope,
string? Route,
string? AppVersion,
string? TraceId);
10 changes: 9 additions & 1 deletion src/Cluckwork.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Cluckwork.Api.Endpoints.Audit;
using Cluckwork.Api.Endpoints.Auth;
using Cluckwork.Api.Endpoints.Catalog;
using Cluckwork.Api.Endpoints.ClientErrors;
using Cluckwork.Api.Endpoints.Customers;
using Cluckwork.Api.Endpoints.DailyEntries;
using Cluckwork.Api.Endpoints.EggGrades;
Expand Down Expand Up @@ -280,12 +281,13 @@
((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
await Results.Problem(
title: "Too many requests",
detail: "Too many authentication attempts from this address. Try again later.",
detail: "Too many requests from this address. Try again later.",
statusCode: StatusCodes.Status429TooManyRequests)
.ExecuteAsync(context.HttpContext);
};
AddFixedWindowByClientIp(limiter, RateLimitingOptions.LoginPolicyName, rateLimiting.Login);
AddFixedWindowByClientIp(limiter, RateLimitingOptions.RefreshPolicyName, rateLimiting.Refresh);
AddFixedWindowByClientIp(limiter, RateLimitingOptions.ClientErrorsPolicyName, rateLimiting.ClientErrors);

static void AddFixedWindowByClientIp(
Microsoft.AspNetCore.RateLimiting.RateLimiterOptions limiter,
Expand Down Expand Up @@ -721,6 +723,12 @@ static void AddFixedWindowByClientIp(
.RequireAuthorization(AuthPolicies.AdminOnly)
.MapExportEndpoints();

// #217 — browser error reports. Anonymous (the login screen can crash too);
// the endpoint carries its own per-IP rate limit and size cap inside.
app.MapGroup("/api/v1/client-errors")
.WithTags("ClientErrors")
.MapClientErrorEndpoints();

// Health: live = the process runs (no checks); ready = dependencies too.
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Expand Down
6 changes: 6 additions & 0 deletions src/Cluckwork.Api/RateLimiting/RateLimitingOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ public sealed class RateLimitingOptions
// behind one NAT IP starve the shared budget with normal refreshes (#143).
public const string LoginPolicyName = "auth-login";
public const string RefreshPolicyName = "auth-refresh";
// #217: the anonymous browser error-report endpoint. The budget guards the
// LOG, not a credential — enough for a genuinely crashing screen to get its
// story out, too little to flood the log from one address.
public const string ClientErrorsPolicyName = "client-errors";

public FixedWindow Login { get; init; } = new() { PermitLimit = 10, WindowSeconds = 900 };
public FixedWindow Refresh { get; init; } = new() { PermitLimit = 60, WindowSeconds = 900 };
public FixedWindow ClientErrors { get; init; } = new() { PermitLimit = 10, WindowSeconds = 300 };

// Reverse-proxy networks whose X-Forwarded-For is honored (CIDR; /32 for a
// single address). Fed to the framework ForwardedHeaders middleware, which
Expand All @@ -37,6 +42,7 @@ public void Validate()
{
ValidateWindow(nameof(Login), Login);
ValidateWindow(nameof(Refresh), Refresh);
ValidateWindow(nameof(ClientErrors), ClientErrors);
ParseTrustedProxies(); // throws FormatException on a bad CIDR
}

Expand Down
Loading