diff --git a/Core/Resgrid.Services/Reporting/IncidentExport.cs b/Core/Resgrid.Services/Reporting/IncidentExport.cs
index 661b7da36..04c11c647 100644
--- a/Core/Resgrid.Services/Reporting/IncidentExport.cs
+++ b/Core/Resgrid.Services/Reporting/IncidentExport.cs
@@ -127,6 +127,14 @@ private static string Utc(DateTime? value)
private static string Escape(string value)
{
value ??= string.Empty;
+
+ // A leading =, +, -, @, tab or CR makes Excel/Sheets evaluate the cell as a formula on
+ // import (CSV injection). Plain numeric values (e.g. "-122.5") are exempt so coordinates
+ // and phone numbers survive intact.
+ if (value.Length > 0 && (value[0] == '=' || value[0] == '+' || value[0] == '-' || value[0] == '@' || value[0] == '\t' || value[0] == '\r')
+ && !double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
+ value = "'" + value;
+
if (value.IndexOf('"') >= 0 || value.IndexOf(',') >= 0 || value.IndexOf('\n') >= 0 || value.IndexOf('\r') >= 0)
return "\"" + value.Replace("\"", "\"\"") + "\"";
return value;
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs
index 7ffbb1adb..bc48cb305 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs
@@ -1175,7 +1175,7 @@ from shifts sh
SelectAllOpenCallsByDidDateQuery =
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND IsDeleted = false AND State = 0";
SelectAllCallsByDidLoggedOnQuery =
- "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND AND LoggedOn >= %DATE%";
+ "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND LoggedOn >= %DATE%";
// ----- Platform reporting / analytics (set-based; %ALLDEPTS% = 1 means system-wide) -----
SelectReportCallsCountQuery =
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs
index fb6a4d8d9..0b86df880 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs
@@ -1128,7 +1128,7 @@ FROM [dbo].[Shifts] sh
SelectAllOpenCallsByDidDateQuery =
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND [IsDeleted] = 0 AND [State] = 0";
SelectAllCallsByDidLoggedOnQuery =
- "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND AND [LoggedOn] >= %DATE%";
+ "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND [LoggedOn] >= %DATE%";
// ----- Platform reporting / analytics (set-based; %ALLDEPTS% = 1 means system-wide) -----
SelectReportCallsCountQuery =
diff --git a/Tests/Resgrid.Tests/Services/IncidentExportTests.cs b/Tests/Resgrid.Tests/Services/IncidentExportTests.cs
index 60e2d4ce6..738454dbb 100644
--- a/Tests/Resgrid.Tests/Services/IncidentExportTests.cs
+++ b/Tests/Resgrid.Tests/Services/IncidentExportTests.cs
@@ -62,6 +62,25 @@ public void BuildCsv_writes_header_and_escapes_values()
csv.Should().Contain("2026-06-01T12:00:00Z");
}
+ [Test]
+ public void BuildCsv_neutralizes_leading_formula_characters()
+ {
+ var call = SampleCall();
+ call.Name = "=HYPERLINK(\"http://evil.example\",\"click\")";
+ call.NatureOfCall = "@cmd|' /C calc'!A0";
+ call.Address = "-122.5";
+
+ var bytes = IncidentExport.BuildCsv(ExportProfile.Generic, new[] { call });
+ var csv = Encoding.UTF8.GetString(bytes);
+
+ // Formula-leading cells are neutralized with a single-quote prefix.
+ csv.Should().Contain("'=HYPERLINK");
+ csv.Should().Contain("'@cmd");
+ csv.Should().NotContain(",=HYPERLINK");
+ // Plain negative numbers are exempt from neutralization.
+ csv.Should().Contain(",-122.5,");
+ }
+
[Test]
public void BuildCsv_nfirs_emits_full_schema_header_with_empty_gap_cells()
{
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ReportingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ReportingController.cs
index abb2ef383..ee639a4f1 100644
--- a/Web/Resgrid.Web.Services/Controllers/v4/ReportingController.cs
+++ b/Web/Resgrid.Web.Services/Controllers/v4/ReportingController.cs
@@ -6,6 +6,7 @@
using Microsoft.AspNetCore.Mvc;
using Resgrid.Model.Reporting;
using Resgrid.Model.Services;
+using Resgrid.Providers.Claims;
using Resgrid.Web.Services.Helpers;
using Resgrid.Web.Services.Models.v4.Reporting;
@@ -18,6 +19,7 @@ namespace Resgrid.Web.Services.Controllers.v4
///
/// SECURITY: every endpoint is hard-scoped to the authenticated user's claim DepartmentId — there is
/// deliberately no departmentId parameter, so a client can never request another department's data.
+ /// Every endpoint also requires the caller's Reports/View permission (Reports_View policy).
/// System-wide (cross-department) reporting is intentionally NOT exposed over HTTP; it is available
/// only to the in-process BackOffice, which resolves IPlatformReportingService directly and calls it
/// with departmentId = null.
@@ -29,6 +31,7 @@ public class ReportingController : V4AuthenticatedApiControllerbase
{
private const int MaxDayWindow = 366;
private const int MaxMonthWindowDays = 366 * 5;
+ private const int MaxTopN = 50;
private readonly IPlatformReportingService _reportingService;
@@ -44,14 +47,15 @@ public ReportingController(IPlatformReportingService reportingService)
/// Window start (UTC).
/// Window end (UTC).
/// Series bucketing: 0 = day, 1 = month.
- /// Max slices per breakdown before an "Other" bucket.
+ /// Max slices per breakdown before an "Other" bucket (clamped to 1–50).
[HttpGet("GetDashboard")]
[ProducesResponseType(StatusCodes.Status200OK)]
- [Authorize]
+ [Authorize(Policy = ResgridResources.Reports_View)]
public async Task> GetDashboard(DateTime from, DateTime to,
int granularity = 0, int topN = 5, CancellationToken cancellationToken = default)
{
var gran = granularity == 1 ? ReportGranularity.Month : ReportGranularity.Day;
+ topN = Math.Clamp(topN, 1, MaxTopN);
var (startUtc, endUtc) = NormalizeWindow(from, to, gran == ReportGranularity.Month ? MaxMonthWindowDays : MaxDayWindow);
var report = await _reportingService.GetDashboardReportAsync(DepartmentId, startUtc, endUtc, gran, topN, false, cancellationToken);
@@ -65,7 +69,7 @@ public async Task> GetDashboard(DateTime fro
/// Response-time / NFPA analytics (alarm handling, turnout, travel, total response).
[HttpGet("GetResponseTimes")]
[ProducesResponseType(StatusCodes.Status200OK)]
- [Authorize]
+ [Authorize(Policy = ResgridResources.Reports_View)]
public async Task> GetResponseTimes(DateTime from, DateTime to,
CancellationToken cancellationToken = default)
{
@@ -81,7 +85,7 @@ public async Task> GetResponseTimes(DateT
/// Unit Hour Utilization and workload analytics.
[HttpGet("GetUtilization")]
[ProducesResponseType(StatusCodes.Status200OK)]
- [Authorize]
+ [Authorize(Policy = ResgridResources.Reports_View)]
public async Task> GetUtilization(DateTime from, DateTime to,
CancellationToken cancellationToken = default)
{
@@ -97,7 +101,7 @@ public async Task> GetUtilization(DateTime
/// Personnel participation and certification-compliance analytics.
[HttpGet("GetParticipation")]
[ProducesResponseType(StatusCodes.Status200OK)]
- [Authorize]
+ [Authorize(Policy = ResgridResources.Reports_View)]
public async Task> GetParticipation(DateTime from, DateTime to,
CancellationToken cancellationToken = default)
{
@@ -116,7 +120,7 @@ public async Task> GetParticipation(Date
///
[HttpGet("ExportIncidents")]
[ProducesResponseType(StatusCodes.Status200OK)]
- [Authorize]
+ [Authorize(Policy = ResgridResources.Reports_View)]
public async Task ExportIncidents(DateTime from, DateTime to, int profile = 0,
CancellationToken cancellationToken = default)
{
@@ -134,7 +138,7 @@ public async Task ExportIncidents(DateTime from, DateTime to, int
///
[HttpGet("GetExportGaps")]
[ProducesResponseType(StatusCodes.Status200OK)]
- [Authorize]
+ [Authorize(Policy = ResgridResources.Reports_View)]
public ActionResult GetExportGaps(int profile = 0)
{
var exportProfile = Enum.IsDefined(typeof(ExportProfile), profile) ? (ExportProfile)profile : ExportProfile.Generic;
diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
index caca028f0..33f0b86ef 100644
--- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
+++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
@@ -1341,6 +1341,7 @@
SECURITY: every endpoint is hard-scoped to the authenticated user's claim DepartmentId — there is
deliberately no departmentId parameter, so a client can never request another department's data.
+ Every endpoint also requires the caller's Reports/View permission (Reports_View policy).
System-wide (cross-department) reporting is intentionally NOT exposed over HTTP; it is available
only to the in-process BackOffice, which resolves IPlatformReportingService directly and calls it
with departmentId = null.
@@ -1354,7 +1355,7 @@
Window start (UTC).
Window end (UTC).
Series bucketing: 0 = day, 1 = month.
- Max slices per breakdown before an "Other" bucket.
+ Max slices per breakdown before an "Other" bucket (clamped to 1–50).
Response-time / NFPA analytics (alarm handling, turnout, travel, total response).