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).