Skip to content

Commit 2fec2d2

Browse files
authored
fix(api): scrub driver errors from the preview endpoint and answer 400, not 500 (#258)
* fix(api): scrub driver errors from the preview endpoint and answer 400, not 500 POST /reports/{name}/preview caught only ConfigurationException. It opens a live connection and runs SQL, so a bad filter value (say Equals "abc" against an integer column) surfaced the raw SqlException/PostgresException/OracleException — which names the host, port and database — as an unhandled 500, and on a host running in Development the developer exception page renders the whole stack trace. Every sibling endpoint that touches a source already routes failures through SchemaProblem (logged server-side, generic to the caller); this one now does too. That alone would have turned a second bug into a misleading 502: a code-first report's name is only checked for non-blank, so "sales.daily" is legal, while a config store validates its argument against the dynamic-name pattern and throws for it. ReportPreviewRunner probed the store before deciding the report was typed, so that ArgumentException escaped as a 500. A name the store cannot hold is definitively not a dynamic report, so it is now recognised as such and the endpoint returns the clear 400 it always intended. The regression test initially passed for the wrong reason - TestApp registers no config store unless AddDynamicReports is called, so the runner short-circuited before reaching the name check. Hardened to register a real file-backed store; it now fails without the guard (502 instead of 400). * test(api): cover the preview endpoint's driver-error scrubbing The Sonar gate flagged new_coverage 0%: the two new lines are the catch and its SchemaProblem return, and nothing exercised them — the existing regression test takes the name-guard path, which returns 400 before any driver call. So the security fix itself was untested. Add a filter translator that fails the way a provider does, with a message naming the connection target, and assert the response is the scrubbed 502 and carries neither the host, the database, nor the driver text. Verified to fail without the catch (the exception escapes with the connection string intact).
1 parent a39a140 commit 2fec2d2

5 files changed

Lines changed: 89 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ The `NeoReports.Abstractions` contract follows SemVer strictly.
5858
the host — D20 — this is a nudge, not a behaviour change).
5959

6060
### Fixed
61+
- **The report-preview endpoint no longer leaks driver exceptions, and answers 400 where it meant to.**
62+
It caught only `ConfigurationException`, so a bad filter value surfaced the raw `SqlException`/
63+
`PostgresException`/`OracleException` — naming the host, port and database — as an unhandled 500
64+
(with a full stack trace on a host running in Development). It now routes those through the same
65+
scrubbed 502 its sibling schema endpoints use. Separately, a **code-first** report whose name a
66+
config store cannot hold (`sales.daily`, say — code-first names are unrestricted) made the preview
67+
runner throw while probing that store, another 500; such a name is now recognised as definitively
68+
not-dynamic, giving the intended 400 explaining that typed reports have no filterable source.
6169
- **Sectioned outputs are counted as outputs.** `CompiledReport.OutputCount` (and `OutputFormats`)
6270
omitted them, so a report with one plain and one sectioned output looked single-output: the API's
6371
sync mode — which supports single-output reports only — accepted it, the runner wrote both files,

docs/STATUS-AND-BACKLOG.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,11 +245,14 @@ rather than decided:
245245
source built with defaults fails its very first request until the author calls `.PageSize(100)`.
246246
Loud, but the default configuration is non-functional. Clamping vs. failing with a clear message is
247247
a product call.
248-
- **API: `POST /reports/{name}/preview` is the one data-plane endpoint that doesn't scrub driver
249-
exceptions.** It catches only `ConfigurationException`, so a bad filter value surfaces the raw
248+
- ~~**API: `POST /reports/{name}/preview` is the one data-plane endpoint that doesn't scrub driver
249+
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.**
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:
253256
`SetScheduleAsync`, `ClearScheduleAsync` and `ReportPreviewRunner`'s config-store probe pass the
254257
report name straight through, so a legitimate **code-first** report whose name is outside
255258
`^[a-zA-Z][a-zA-Z0-9_-]{0,99}$` (e.g. `sales.daily`) gets a **500** from `ArgumentException`. The

src/Integrations/NeoReports.AspNetCore/NeoReportsEndpointRouteBuilderExtensions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,15 @@ private static async Task<IResult> PreviewReportAsync(
262262
{
263263
return Results.BadRequest(new { error = ex.Message });
264264
}
265+
catch (Exception ex) when (ex is not OperationCanceledException)
266+
{
267+
// Preview opens a live connection and runs SQL, so a bad filter value surfaces the raw
268+
// driver exception — which names the host, port and database. Every sibling endpoint that
269+
// touches a source routes failures through SchemaProblem (logged server-side, generic to
270+
// the caller); this one used to let them escape as an unhandled 500, which on a host
271+
// running in Development also renders the full stack trace.
272+
return SchemaProblem(http, ex, name, "Could not run the report preview.");
273+
}
265274
}
266275

267276
private static IReadOnlyList<PreviewFilter> ParseFilters(IReadOnlyList<PreviewFilterRequest>? filters)

src/NeoReports.Core/Preview/ReportPreviewRunner.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,13 @@ public static async Task<PreviewResult> PreviewAsync(
5151
return await PreviewUnfilteredAsync(report, pageSize, execution, services, cancellationToken).ConfigureAwait(false);
5252

5353
IReportConfigStore? configStore = services.GetService<IReportConfigStore>();
54+
// Check the name is one a config store could even hold before probing it: the file-backed
55+
// store validates its argument and throws for anything outside the dynamic-name pattern, and
56+
// a code-first report is under no such restriction (a name like "sales.daily" is legal). That
57+
// threw out of the endpoint as an unhandled ArgumentException — a 500 — instead of the clear
58+
// "typed report" message below. A name the store cannot hold is definitively not dynamic.
5459
bool isDynamic = configStore is not null
60+
&& DynamicReportName.IsValid(report.Name)
5561
&& await configStore.ExistsAsync(report.Name, cancellationToken).ConfigureAwait(false);
5662

5763
if (!isDynamic)

tests/NeoReports.AspNetCore.IntegrationTests/PreviewEndpointTests.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,50 @@ public async Task PageSize_is_capped_to_the_request()
7171
body.GetProperty("hasMore").GetBoolean().ShouldBeTrue();
7272
}
7373

74+
[Fact]
75+
public async Task A_driver_failure_during_preview_is_scrubbed_and_not_returned_to_the_caller()
76+
{
77+
// Preview opens a live connection and runs SQL, so a driver exception is reachable from a
78+
// request. Its message routinely echoes the connection target; it must never reach the caller.
79+
using Microsoft.Extensions.Hosting.IHost host =
80+
await StartWithDynamicReportAsync("fake-sql", new ThrowingFilterTranslator("fake-sql"));
81+
var client = host.GetTestClient();
82+
83+
var response = await PostJsonAsync(client, "/api/reports/dyn/preview",
84+
"""{ "filters": [ { "column": "Id", "operator": "Equals", "value": 1 } ] }""");
85+
86+
response.StatusCode.ShouldBe(HttpStatusCode.BadGateway);
87+
var body = await response.Content.ReadAsStringAsync();
88+
body.ShouldNotContain("db.internal");
89+
body.ShouldNotContain("payroll");
90+
body.ShouldNotContain("Login failed");
91+
}
92+
93+
[Fact]
94+
public async Task Filters_on_a_typed_report_whose_name_the_config_store_cannot_hold_return_400()
95+
{
96+
// A code-first report's name is only checked for non-blank, so "sales.daily" is legal — but a
97+
// config store validates its argument against the dynamic-name pattern and throws for it. The
98+
// preview runner probed the store before deciding the report was typed, so that ArgumentException
99+
// escaped as a 500 instead of the clear "typed report" 400 this endpoint means to return.
100+
// A real (file-backed) config store must be registered, otherwise the runner short-circuits on
101+
// `configStore is null` and never reaches the name check this test is about.
102+
using var host = await TestApp.StartAsync(services =>
103+
{
104+
services.AddDynamicReports(o => o.Directory = _configDir);
105+
services.AddReport<Sale>("sales.daily", b => b
106+
.From(new InMemorySource(rows: 3, pageSize: 10))
107+
.Column(v => v.Id, "Id")
108+
.To(NeoReports.Formats.Csv.Format.Csv()));
109+
});
110+
var client = host.GetTestClient();
111+
112+
var response = await PostJsonAsync(client, "/api/reports/sales.daily/preview",
113+
"""{ "filters": [ { "column": "Id", "operator": "Equals", "value": 1 } ] }""");
114+
115+
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
116+
}
117+
74118
[Fact]
75119
public async Task Filters_on_a_typed_report_return_400()
76120
{
@@ -329,3 +373,19 @@ public bool TryTranslate(
329373
return true;
330374
}
331375
}
376+
377+
/// <summary>Translator that fails the way a real provider does — an exception whose message names the
378+
/// connection target — so the endpoint's scrubbing can be asserted.</summary>
379+
public sealed class ThrowingFilterTranslator : IFilterTranslator
380+
{
381+
public ThrowingFilterTranslator(string type) => Type = type;
382+
383+
public string Type { get; }
384+
385+
public bool TryTranslate(
386+
IReadOnlyDictionary<string, object?> properties, IReadOnlyList<PreviewFilter> filters, ReportSchema schema,
387+
out IReadOnlyDictionary<string, object?> propertyOverrides,
388+
out IReadOnlyDictionary<string, object?> parameters) =>
389+
throw new InvalidOperationException(
390+
"Login failed for user 'sa'. Server=db.internal:1433;Database=payroll");
391+
}

0 commit comments

Comments
 (0)