Skip to content

Commit 98a459d

Browse files
authored
fix(api): stop leaking destination errors, honour the mapped prefix, guard schedule names (#261)
* fix(api): stop leaking destination errors, honour the mapped prefix, guard schedule names Four API-layer defects recorded in docs/STATUS-AND-BACKLOG.md §6, each with a single defensible answer, so none needed a product decision. - `GET /jobs/{id}` and `GET /jobs/{id}/events` returned a destination's own failure text verbatim. `S3Destination` interpolates `s3://{bucket}/{key}` plus the AWS SDK's message and `LocalDestination` an `IOException` carrying the full server path, so both routes handed infrastructure detail to any API caller. The runner now persists and emits only the file name and destination type and keeps the reason in the log, matching what the read-failure path beside it already does. Scrubbing at the runner rather than in each destination also covers third-party `IDestination` implementations, and `UploadResult` itself is untouched (`Abstractions` is frozen). - `Location` was built from a hardcoded `/api`, so under `MapNeoReports("/v2")` the 202's header was a 404 for any client that followed it. A group-level endpoint filter carries the mapped prefix on the request and the three `Created`/`Accepted` sites build from it. - `PUT`/`DELETE /reports/{name}/schedule` reached a name-validating override store unguarded, so a legitimate code-first report named `sales.daily` got an `ArgumentException` 500. Both now answer 409 naming the pattern, like the "this host cannot do that" response already next to them. The read path guarded already, which is what made this an oversight. - `POST`/`PUT /sources` stored the caller's property bag unnormalized, so under `AddInMemorySourceRegistry()` a source created over HTTP failed later with "requires a non-empty 'connectionString' property" — the value was a `JsonElement`. Both handlers now reuse the run endpoint's normalizer, renamed `NormalizeJsonValues` as it serves two request shapes. Seven tests, each verified to fail against the unfixed code. The `Location` test follows the header instead of string-matching it, so a well-formed-but-wrong URL still fails, and the upload assertions cover the events feed as well as the job's error. * test(api): address CodeQL — drop the redundant cast and document TestApp.StartAsync
1 parent ae29442 commit 98a459d

9 files changed

Lines changed: 285 additions & 32 deletions

File tree

docs/STATUS-AND-BACKLOG.md

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -249,15 +249,20 @@ rather than decided:
249249
exceptions.**~~ **FIXED** (routes through `SchemaProblem` like its siblings). Original description: It catches only `ConfigurationException`, so a bad filter value surfaces the raw
250250
`SqlException`/`PostgresException` (host, port, database) as a 500 — its siblings all route through
251251
`SchemaProblem`. Should be a 400 (bad filter) or the scrubbed 502 the others return.
252-
- **API: schedule/preview write paths reach a name-validating store without the guard.** The
253-
**preview** half is **FIXED** (a name no config store can hold is now treated as not-dynamic, so the
254-
endpoint returns its intended 400). `SetScheduleAsync`/`ClearScheduleAsync` still 500 — original
255-
description:
252+
- ~~**API: schedule/preview write paths reach a name-validating store without the guard.**~~
253+
**FIXED** — the preview half first (a name no config store can hold is treated as not-dynamic, so
254+
the endpoint returns its intended 400), and now `SetScheduleAsync`/`ClearScheduleAsync` too: both
255+
answer **409 Conflict** naming the pattern, matching the "this host cannot do that" response
256+
already next to them, instead of an `ArgumentException` 500. Original description:
256257
`SetScheduleAsync`, `ClearScheduleAsync` and `ReportPreviewRunner`'s config-store probe pass the
257258
report name straight through, so a legitimate **code-first** report whose name is outside
258259
`^[a-zA-Z][a-zA-Z0-9_-]{0,99}$` (e.g. `sales.daily`) gets a **500** from `ArgumentException`. The
259260
read paths already guard, which shows it is an oversight.
260-
- **API: `GET /jobs/{id}` returns raw destination-exception text** (server paths, S3 bucket + key, AWS
261+
- ~~**API: `GET /jobs/{id}` returns raw destination-exception text**~~ **FIXED** — the runner now
262+
persists (and emits as the `UploadFailed` event) only the file name and destination type, keeping the
263+
destination's own wording in the log. Scrubbing at the runner rather than in each destination is what
264+
also covers third-party `IDestination` implementations. `GET /jobs/{id}/events` was a second route
265+
out for the same string and is covered too. Original description: (server paths, S3 bucket + key, AWS
261266
error strings). The read- and write-failure paths in the same method scrub; the **upload** path does
262267
not — and the sync endpoint deliberately suppresses the very same string, so one route hides what
263268
the other returns verbatim.
@@ -267,20 +272,24 @@ rather than decided:
267272
`Outputs`, so a report with one plain and one sectioned output passes the guard, the runner writes
268273
two artifacts, and the caller silently receives **one** — which one decided by directory-enumeration
269274
order. The same undercount makes `GET /reports` under-report a sectioned report's formats.
270-
- **API: `Location`/`Content-Location` headers hardcode `/api`**, ignoring `MapNeoReports`'s
271-
configurable prefix — under `MapNeoReports("/v2")` the 202's `Location` is a 404 for any client that
272-
follows it.
275+
- ~~**API: `Location`/`Content-Location` headers hardcode `/api`**~~ **FIXED** — a group-level
276+
endpoint filter puts the mapped prefix on the request, and the three `Created`/`Accepted` sites build
277+
their URL from it. The test follows the returned `Location` under `MapNeoReports("/v2")` rather than
278+
string-matching it, so a well-formed-but-wrong header still fails. Original description: ignoring
279+
`MapNeoReports`'s configurable prefix — under `MapNeoReports("/v2")` the 202's `Location` is a 404
280+
for any client that follows it.
273281
- **Array/object run parameters still diverge by backend.** Complex parameter values are documented
274282
out of scope for v1, but nothing rejects them: sync/in-memory hand the source a `JsonElement` (the
275283
very thing an ADO provider can't bind) while Hangfire hands it the raw JSON text. Either reject them
276284
at the boundary with a 400, or agree one representation — the current silence produces a driver
277285
error at read time.
278-
- **`POST/PUT /sources` property bags are not normalized.** `SourceRequest.Properties` is the same
286+
- ~~**`POST/PUT /sources` property bags are not normalized.**~~ **FIXED** — both handlers now run the
287+
bag through the same normalizer the run endpoint uses (renamed `NormalizeJsonValues`, since it serves
288+
two request shapes). Original description: `SourceRequest.Properties` is the same
279289
caller-supplied `object?` bag as run parameters. `FileSourceRegistryStore` launders it on write and
280290
read, but `InMemorySourceRegistryStore` stores it as-is — so with `AddInMemorySourceRegistry()` a
281291
source created over HTTP fails later with *"requires a non-empty 'connectionString' property"*
282-
because the value is a `JsonElement`, not a `string`. Pre-existing; the same one-line normalization
283-
the run endpoint now does would close it.
292+
because the value is a `JsonElement`, not a `string`.
284293

285294
Verified correct in the same pass (worth not re-auditing): artifact download path handling (no
286295
caller-supplied filename reaches disk; no zip-slip; `0600` temp), job-id handling, SQL-injection

src/Integrations/NeoReports.AspNetCore/NeoReportsEndpointRouteBuilderExtensions.cs

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ public static RouteGroupBuilder MapNeoReports(
7575
configure?.Invoke(options);
7676

7777
var group = endpoints.MapGroup(prefix);
78+
79+
// A handler that returns a Location must point back at the prefix this group was actually
80+
// mapped under; hardcoding "/api" makes the 202's Location a 404 under MapNeoReports("/v2").
81+
// The handlers are static method groups with no closure over `prefix`, so the mapped prefix
82+
// rides along on the request instead. Trailing '/' is trimmed so MapNeoReports("/") does not
83+
// produce a protocol-relative "//jobs/..." URL.
84+
string mappedPrefix = prefix.TrimEnd('/');
85+
group.AddEndpointFilter(async (context, next) =>
86+
{
87+
context.HttpContext.Items[MappedPrefixKey] = mappedPrefix;
88+
return await next(context).ConfigureAwait(false);
89+
});
90+
7891
if (options.RequireAuthorization)
7992
{
8093
if (string.IsNullOrEmpty(options.AuthorizationPolicy))
@@ -172,7 +185,7 @@ private static async Task<IResult> RunReportAsync(
172185
});
173186
}
174187

175-
IReadOnlyDictionary<string, object?>? parameters = NormalizeParameters(body?.Parameters);
188+
IReadOnlyDictionary<string, object?>? parameters = NormalizeJsonValues(body?.Parameters);
176189

177190
if (string.Equals(mode, "sync", StringComparison.OrdinalIgnoreCase))
178191
{
@@ -217,7 +230,7 @@ private static async Task<IResult> RunReportAsync(
217230
var enqueuedId = await scheduler.EnqueueAsync(
218231
new ReportJobRequest(name, parameters), cancellationToken).ConfigureAwait(false);
219232
return Results.Accepted(
220-
$"{http.Request.PathBase}/api/jobs/{enqueuedId}",
233+
ApiUrl(http, $"/jobs/{enqueuedId}"),
221234
new RunAcceptedResponse(enqueuedId, ReportJobStatus.Queued));
222235
}
223236

@@ -457,7 +470,7 @@ private static async Task<IResult> CreateReportAsync(
457470

458471
var columns = compiled.Schema.Columns.Select(c => c.Name).ToArray();
459472
return Results.Created(
460-
$"{http.Request.PathBase}/api/reports/{config.Name}", new ReportCreatedResponse(config.Name, columns));
473+
ApiUrl(http, $"/reports/{config.Name}"), new ReportCreatedResponse(config.Name, columns));
461474
}
462475

463476
private static async Task<IResult> ValidateReportAsync(
@@ -549,6 +562,47 @@ private static IResult GetCapabilities(HttpContext http)
549562
return Results.Ok(new CapabilitiesResponse(sources, formats, destinations, scheduling));
550563
}
551564

565+
/// <summary><see cref="HttpContext.Items"/> key carrying the prefix this group was mapped at.</summary>
566+
private const string MappedPrefixKey = "NeoReports.MappedPrefix";
567+
568+
/// <summary>
569+
/// Builds a URL for another endpoint of this API, honouring both the host's path base and the
570+
/// prefix <see cref="MapNeoReports"/> was called with. Falls back to the documented default only
571+
/// if the filter that stashes the prefix somehow did not run.
572+
/// </summary>
573+
/// <param name="http">The current request.</param>
574+
/// <param name="relativePath">Path below the prefix, starting with <c>/</c>.</param>
575+
private static string ApiUrl(HttpContext http, string relativePath)
576+
{
577+
string prefix = http.Items.TryGetValue(MappedPrefixKey, out object? mapped) && mapped is string s
578+
? s
579+
: "/api";
580+
return $"{http.Request.PathBase}{prefix}{relativePath}";
581+
}
582+
583+
/// <summary>
584+
/// Rejects a schedule write for a report whose name an override store cannot key.
585+
/// <para>
586+
/// A schedule override is stored by report name, and a store persists it as a file name, so it
587+
/// only accepts <see cref="DynamicReportName.Pattern"/>. A <b>code-first</b> report is under no
588+
/// such constraint — <c>sales.daily</c> is perfectly legal — so a legitimately registered report
589+
/// can reach the store with a name it refuses, which surfaced as an <see cref="ArgumentException"/>
590+
/// and a <b>500</b>. The read path (<c>ResolveScheduleAsync</c>) already skips the lookup for such
591+
/// a name, which is what makes the missing guard here an oversight rather than a design.
592+
/// </para>
593+
/// </summary>
594+
/// <param name="name">The report name from the route.</param>
595+
/// <returns><see langword="null"/> when the name is storable; otherwise the response to return.</returns>
596+
private static IResult? ScheduleOverridesUnsupportedFor(string name) =>
597+
DynamicReportName.IsValid(name)
598+
? null
599+
: Results.Conflict(new
600+
{
601+
error = $"The schedule for '{name}' cannot be changed over HTTP: overrides are stored " +
602+
$"by report name, and a name must match {DynamicReportName.Pattern}. Declare " +
603+
"this report's schedule in code, or rename it.",
604+
});
605+
552606
private static async Task<IResult> SetScheduleAsync(
553607
string name, SetScheduleRequest? body, HttpContext http,
554608
[FromServices] IReportRegistry registry, CancellationToken cancellationToken)
@@ -570,6 +624,9 @@ private static async Task<IResult> SetScheduleAsync(
570624
});
571625
}
572626

627+
if (ScheduleOverridesUnsupportedFor(name) is { } unsupported)
628+
return unsupported;
629+
573630
try
574631
{
575632
CronValidation.Validate(body.Cron);
@@ -600,6 +657,9 @@ private static async Task<IResult> ClearScheduleAsync(
600657
if (scheduler is null || overrides is null)
601658
return Results.Conflict(new { error = "No recurring scheduler is registered on this host." });
602659

660+
if (ScheduleOverridesUnsupportedFor(name) is { } unsupported)
661+
return unsupported;
662+
603663
// A declared schedule needs an explicit "unscheduled" tombstone — merely removing any prior
604664
// override would let the declaration re-apply on the next reconciliation. A report with no
605665
// declaration has nothing to tombstone, so the override entry (if any) is just removed —
@@ -955,8 +1015,8 @@ private static async Task<IResult> CreateSourceAsync(HttpContext http, Cancellat
9551015
if (await registry.GetAsync(body!.Name, cancellationToken).ConfigureAwait(false) is not null)
9561016
return Results.Conflict(new { error = $"A source named '{body.Name}' already exists." });
9571017

958-
await registry.SaveAsync(new SourceDefinition(body.Name, body.Type, body.Properties, body.Description), cancellationToken).ConfigureAwait(false);
959-
return Results.Created($"{http.Request.PathBase}/api/sources/{body.Name}", ToSourceView(
1018+
await registry.SaveAsync(new SourceDefinition(body.Name, body.Type, NormalizeJsonValues(body.Properties), body.Description), cancellationToken).ConfigureAwait(false);
1019+
return Results.Created(ApiUrl(http, $"/sources/{body.Name}"), ToSourceView(
9601020
new SourceDefinition(body.Name, body.Type, Description: body.Description), reportRegistry: http.RequestServices.GetRequiredService<IReportRegistry>(), healthCache: null));
9611021
}
9621022

@@ -974,7 +1034,7 @@ private static async Task<IResult> ReplaceSourceAsync(string name, HttpContext h
9741034
if (await registry.GetAsync(name, cancellationToken).ConfigureAwait(false) is null)
9751035
return Results.NotFound(new { error = $"No source named '{name}' is registered." });
9761036

977-
await registry.SaveAsync(new SourceDefinition(name, body!.Type, body.Properties, body.Description), cancellationToken).ConfigureAwait(false);
1037+
await registry.SaveAsync(new SourceDefinition(name, body!.Type, NormalizeJsonValues(body.Properties), body.Description), cancellationToken).ConfigureAwait(false);
9781038
IReportRegistry reportRegistry = http.RequestServices.GetRequiredService<IReportRegistry>();
9791039
ISourceHealthCache? healthCache = http.RequestServices.GetService<ISourceHealthCache>();
9801040
return Results.Ok(ToSourceView(new SourceDefinition(name, body.Type, Description: body.Description), reportRegistry, healthCache));
@@ -1195,26 +1255,31 @@ private static IResult SchemaProblem(HttpContext http, Exception ex, string sour
11951255
statusCode: StatusCodes.Status502BadGateway);
11961256
}
11971257

1198-
// The run request types its parameter values as `object?`, so System.Text.Json materializes each
1199-
// one as a JsonElement. Nothing downstream can use that: an ADO provider rejects it outright
1258+
// Two request types carry a caller-supplied bag of `object?` values — a run's `Parameters` and a
1259+
// source's `Properties` — so System.Text.Json materializes each value as a JsonElement. Nothing
1260+
// downstream can use that: an ADO provider rejects it outright
12001261
// ("No mapping exists from object type System.Text.Json.JsonElement"), so every parameterized
12011262
// report failed on the sync and in-memory-job paths — while the Hangfire path happened to work,
12021263
// because it round-trips parameters through JobParameters, which converts them. Convert here
12031264
// instead, at the one boundary where they enter, so every backend behaves the same. Round-tripping
12041265
// through PrimitiveObjectConverter reuses the repo's single definition of "JSON value → CLR
12051266
// primitive" (string/long/double/bool/ISO-8601 DateTime, nested objects left as JsonElement)
1206-
// rather than restating it; parameter bags are a handful of scalars, so the cost is irrelevant.
1207-
private static readonly JsonSerializerOptions ParameterJson =
1267+
// rather than restating it; these bags are a handful of scalars, so the cost is irrelevant.
1268+
// Source properties had the identical split: FileSourceRegistryStore launders the bag through JSON
1269+
// on write and read, but InMemorySourceRegistryStore keeps it as given — so under
1270+
// AddInMemorySourceRegistry() a source created over HTTP failed later with "requires a non-empty
1271+
// 'connectionString' property", the value being a JsonElement rather than a string.
1272+
private static readonly JsonSerializerOptions ValueBagJson =
12081273
new(JsonSerializerDefaults.Web) { Converters = { new PrimitiveObjectConverter() } };
12091274

1210-
private static IReadOnlyDictionary<string, object?>? NormalizeParameters(
1275+
private static IReadOnlyDictionary<string, object?>? NormalizeJsonValues(
12111276
IReadOnlyDictionary<string, object?>? parameters)
12121277
{
12131278
if (parameters is null || parameters.Count == 0)
12141279
return parameters;
12151280

1216-
string json = JsonSerializer.Serialize(parameters, ParameterJson);
1217-
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json, ParameterJson);
1281+
string json = JsonSerializer.Serialize(parameters, ValueBagJson);
1282+
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json, ValueBagJson);
12181283
}
12191284

12201285
// A failed health check's raw Error is the underlying driver/IO exception message, which can echo

src/NeoReports.Core/Pipeline/ReportRunner.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -468,13 +468,25 @@ await writer.InitializeAsync(
468468
// the run fails and says which destination/file did not land, instead of
469469
// completing green with the file still on the box (delivery-integrity).
470470
uploadFailed = true;
471-
uploadError ??= $"Upload of '{finished.FileName}' to destination " +
472-
$"'{destSpec.Factory.Type}' failed: {uploadResult.ErrorMessage}";
471+
472+
// The destination's own message is NOT persisted or emitted: it is built by
473+
// the destination and routinely embeds infrastructure detail — S3Destination
474+
// interpolates `s3://{bucket}/{key}` plus the AWS SDK's text, LocalDestination
475+
// an IOException carrying the full server path — and both `GET /jobs/{id}` and
476+
// `GET /jobs/{id}/events` return these verbatim to any API caller. The read
477+
// failure path above already reduces a non-NeoReports exception to its type
478+
// name for exactly this reason, and the sync endpoint suppresses this same
479+
// string. Scrubbing here (rather than in each destination) is what also covers
480+
// third-party IDestination implementations, whose messages we do not control.
481+
// The full reason stays in the log below, which is not caller-visible.
482+
string safeUploadError = $"Upload of '{finished.FileName}' to destination " +
483+
$"'{destSpec.Factory.Type}' failed. See the server logs for the reason.";
484+
uploadError ??= safeUploadError;
473485
execution.Logger.LogError(
474486
"Report {Report} (job {JobId}) failed to upload '{FileName}' to destination '{DestinationType}': {Reason}",
475487
report.Name, execution.JobId, finished.FileName, destSpec.Factory.Type, uploadResult.ErrorMessage);
476488

477-
await events.EmitAsync(JobEventTypes.UploadFailed, uploadResult.ErrorMessage, new Dictionary<string, string>
489+
await events.EmitAsync(JobEventTypes.UploadFailed, safeUploadError, new Dictionary<string, string>
478490
{
479491
["destinationType"] = destSpec.Factory.Type,
480492
[FileNameKey] = finished.FileName,

tests/NeoReports.AspNetCore.IntegrationTests/DynamicReportEndpointsTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ private Task<IHost> StartAsync([System.Runtime.CompilerServices.CallerMemberName
5454
services.AddSingleton<IConfigSourceProvider>(new FakeConfigSourceProvider(
5555
new[] { new object?[] { 1L, "Acme" }, new object?[] { 2L, "Globex" } }));
5656
services.AddSingleton<IWriterFactory>(new CsvWriterFactory(new CsvOptions()));
57-
}, testName);
57+
}, testName: testName);
5858

5959
private static async Task<HttpResponseMessage> PostJsonAsync(HttpClient client, string url, string json)
6060
{

0 commit comments

Comments
 (0)