diff --git a/devdocs/modernization/NET10-PORT.md b/devdocs/modernization/NET10-PORT.md index 7a6c760b..74eefbe8 100644 --- a/devdocs/modernization/NET10-PORT.md +++ b/devdocs/modernization/NET10-PORT.md @@ -183,7 +183,7 @@ John's "next release" cases for AyaNova (exported 2026-10-24). Each is its own s | 4713 | Contact users limited to customer roles | F28 | | 4715 | Download file names checked (one helper) | F21. **Done** (`FileUtil.FullPathInFolderOrNull` in temp/backup exists checks and both log routes; test `DownloadNames`) | | 4716 | Pick-list search text as SQL parameters | F18. **Done** (`@plq`/`@pltag` parameters; test `FetchUserPickListAutoCompleteApostrophe` fails on the old code, passes now; 9/9 pick-list tests) | -| 4717 | KPI interval allowlist | F26 | +| 4717 | KPI interval allowlist | F26. **Done** (`KPIFetcher.DateTruncUnitOrNull` in the 7 KPIs reading `interval`; test `KpiInterval`) | | 4718 | Report and translation import need create rights | F19, F24. **Done** (contact and Service 403, BizAdmin 202; test `ImportRights`) | | 4719 | Report page network restricted; notification reports not run as SuperUser | F20 | | 4720 | Restricted roles filtered in report/export data | F27 | diff --git a/devdocs/modernization/SECURITY-REVIEW.md b/devdocs/modernization/SECURITY-REVIEW.md index 2bb9d12d..084bc486 100644 --- a/devdocs/modernization/SECURITY-REVIEW.md +++ b/devdocs/modernization/SECURITY-REVIEW.md @@ -196,7 +196,7 @@ take the next free number; a fixed or accepted finding keeps its ID and changes | F19 | Critical | **fixed in case 4718** | Any logged-in user, customer contacts included, can import report templates, whose JavaScript runs in the server's browser | | F21 | Critical | **fixed in case 4715** | Download file names from the URL are not checked: `report/download` and `export/download` may serve files outside the temp folder on Windows | | F18 | Critical | **fixed in case 4716** | Pick-list search text is put into SQL without escaping; any logged-in user can reach it | -| F26 | Critical | confirmed (code) | KPI `interval` criterion is put into SQL unchecked; reachable by staff users down to TechRestricted | +| F26 | Critical | **fixed in case 4717** | KPI `interval` criterion is put into SQL unchecked; reachable by staff users down to TechRestricted | | F13 | High | **confirmed** | Customer contacts can open any customer's work order (customer view, customer report, attachments) by id | | F14 | High | **confirmed** | Customer contacts can read, and by code change, delete and create, any customer's service requests | | F15 | High | **confirmed** | Customer contacts can list every customer (and vendors, users, parts...) with any pick-list columns, phones and emails included | diff --git a/server/AyaNova/kpi/KPIFetcher.cs b/server/AyaNova/kpi/KPIFetcher.cs index a61410ba..6dc341d3 100644 --- a/server/AyaNova/kpi/KPIFetcher.cs +++ b/server/AyaNova/kpi/KPIFetcher.cs @@ -12,6 +12,19 @@ namespace AyaNova.KPI internal static class KPIFetcher { + //case 4717: the date_trunc field names PostgreSQL accepts; anything else in a KPI interval is refused + private static readonly System.Collections.Generic.HashSet DateTruncUnits = new System.Collections.Generic.HashSet { + "microseconds", "milliseconds", "second", "minute", "hour", "day", "week", + "month", "quarter", "year", "decade", "century", "millennium" }; + + internal static string DateTruncUnitOrNull(string unit) + { + if (string.IsNullOrWhiteSpace(unit)) + return null; + unit = unit.Trim().ToLowerInvariant(); + return DateTruncUnits.Contains(unit) ? unit : null; + } + #if (AYSHOWKPIQUERYINFO) #if (DEBUG) #warning FYI AYSHOWKPIQUERYINFO is defined diff --git a/server/AyaNova/kpi/WorkOrderByStatusList.cs b/server/AyaNova/kpi/WorkOrderByStatusList.cs index 7618ed73..4337ed4c 100644 --- a/server/AyaNova/kpi/WorkOrderByStatusList.cs +++ b/server/AyaNova/kpi/WorkOrderByStatusList.cs @@ -55,7 +55,13 @@ namespace AyaNova.KPI } var timeSpan = (string)options.Criteria["timeSpan"]; - var interval = (string)options.Criteria["interval"]; + //case 4717: interval goes into DATE_TRUNC in the query, so only PostgreSQL's own unit names are accepted + var interval = KPIFetcher.DateTruncUnitOrNull((string)options.Criteria["interval"]); + if (interval == null) + { + _errorMessage = "Invalid 'interval' criteria"; + return; + } var dateWhere = DataListSqlFilterCriteriaBuilder.DataFilterToColumnCriteria("aworkorder.createddate", UiFieldDataType.DateTime, "no-operator", timeSpan, options.ClientTimeStamp); var wotags = options.Criteria["wotags"].ToObject>(); diff --git a/server/AyaNova/kpi/WorkOrderCompletedOnTimePct.cs b/server/AyaNova/kpi/WorkOrderCompletedOnTimePct.cs index 87276db8..ebb992c3 100644 --- a/server/AyaNova/kpi/WorkOrderCompletedOnTimePct.cs +++ b/server/AyaNova/kpi/WorkOrderCompletedOnTimePct.cs @@ -41,7 +41,13 @@ AuthorizationRoles.Accounting; } var timeSpan = (string)options.Criteria["timeSpan"]; - var interval = (string)options.Criteria["interval"]; + //case 4717: interval goes into DATE_TRUNC in the query, so only PostgreSQL's own unit names are accepted + var interval = KPIFetcher.DateTruncUnitOrNull((string)options.Criteria["interval"]); + if (interval == null) + { + _errorMessage = "Invalid 'interval' criteria"; + return; + } var dateWhere = DataListSqlFilterCriteriaBuilder.DataFilterToColumnCriteria("aworkorder.createddate", UiFieldDataType.DateTime, "no-operator", timeSpan, options.ClientTimeStamp); var wotags = options.Criteria["wotags"].ToObject>(); bool wotagsany = options.Criteria["wotagsany"].ToObject(); diff --git a/server/AyaNova/kpi/WorkOrderCreatedCount.cs b/server/AyaNova/kpi/WorkOrderCreatedCount.cs index a67b81b3..c88248a6 100644 --- a/server/AyaNova/kpi/WorkOrderCreatedCount.cs +++ b/server/AyaNova/kpi/WorkOrderCreatedCount.cs @@ -41,7 +41,13 @@ AuthorizationRoles.Accounting; } var timeSpan = (string)options.Criteria["timeSpan"]; - var interval = (string)options.Criteria["interval"]; + //case 4717: interval goes into DATE_TRUNC in the query, so only PostgreSQL's own unit names are accepted + var interval = KPIFetcher.DateTruncUnitOrNull((string)options.Criteria["interval"]); + if (interval == null) + { + _errorMessage = "Invalid 'interval' criteria"; + return; + } var dateWhere = DataListSqlFilterCriteriaBuilder.DataFilterToColumnCriteria("aworkorder.createddate", UiFieldDataType.DateTime, "no-operator", timeSpan, options.ClientTimeStamp); var wotags = options.Criteria["wotags"].ToObject>(); bool wotagsany = options.Criteria["wotagsany"].ToObject(); diff --git a/server/AyaNova/kpi/WorkOrderItemLaborQuantityEveryoneSummary.cs b/server/AyaNova/kpi/WorkOrderItemLaborQuantityEveryoneSummary.cs index 1912f43c..8a538376 100644 --- a/server/AyaNova/kpi/WorkOrderItemLaborQuantityEveryoneSummary.cs +++ b/server/AyaNova/kpi/WorkOrderItemLaborQuantityEveryoneSummary.cs @@ -42,7 +42,13 @@ AuthorizationRoles.Accounting; userWhere = $"AND aworkorderitemlabor.userid = {userid}"; } var timeSpan = (string)options.Criteria["timeSpan"]; - var interval = (string)options.Criteria["interval"]; + //case 4717: interval goes into DATE_TRUNC in the query, so only PostgreSQL's own unit names are accepted + var interval = KPIFetcher.DateTruncUnitOrNull((string)options.Criteria["interval"]); + if (interval == null) + { + _errorMessage = "Invalid 'interval' criteria"; + return; + } var dateWhere = DataListSqlFilterCriteriaBuilder.DataFilterToColumnCriteria("aworkorderitemlabor.servicestopdate", UiFieldDataType.DateTime, "no-operator", timeSpan, options.ClientTimeStamp); var techtags = options.Criteria["techtags"].ToObject>(); bool techtagsany = options.Criteria["techtagsany"].ToObject(); diff --git a/server/AyaNova/kpi/WorkOrderItemLaborQuantitySummary.cs b/server/AyaNova/kpi/WorkOrderItemLaborQuantitySummary.cs index f54f2d9f..deb194f4 100644 --- a/server/AyaNova/kpi/WorkOrderItemLaborQuantitySummary.cs +++ b/server/AyaNova/kpi/WorkOrderItemLaborQuantitySummary.cs @@ -34,7 +34,13 @@ namespace AyaNova.KPI } var timeSpan = (string)options.Criteria["timeSpan"]; - var interval = (string)options.Criteria["interval"]; + //case 4717: interval goes into DATE_TRUNC in the query, so only PostgreSQL's own unit names are accepted + var interval = KPIFetcher.DateTruncUnitOrNull((string)options.Criteria["interval"]); + if (interval == null) + { + _errorMessage = "Invalid 'interval' criteria"; + return; + } var dateWhere = DataListSqlFilterCriteriaBuilder.DataFilterToColumnCriteria("aworkorderitemlabor.servicestopdate", UiFieldDataType.DateTime, "no-operator", timeSpan, options.ClientTimeStamp); var wotags = options.Criteria["wotags"].ToObject>(); bool wotagsany = options.Criteria["wotagsany"].ToObject(); diff --git a/server/AyaNova/kpi/WorkOrderStatusCount.cs b/server/AyaNova/kpi/WorkOrderStatusCount.cs index dd2dfc81..514c4a96 100644 --- a/server/AyaNova/kpi/WorkOrderStatusCount.cs +++ b/server/AyaNova/kpi/WorkOrderStatusCount.cs @@ -41,7 +41,13 @@ namespace AyaNova.KPI } var timeSpan = (string)options.Criteria["timeSpan"]; - var interval = (string)options.Criteria["interval"]; + //case 4717: interval goes into DATE_TRUNC in the query, so only PostgreSQL's own unit names are accepted + var interval = KPIFetcher.DateTruncUnitOrNull((string)options.Criteria["interval"]); + if (interval == null) + { + _errorMessage = "Invalid 'interval' criteria"; + return; + } var dateWhere = DataListSqlFilterCriteriaBuilder.DataFilterToColumnCriteria("aworkorder.createddate", UiFieldDataType.DateTime, "no-operator", timeSpan, options.ClientTimeStamp); var wotags = options.Criteria["wotags"].ToObject>(); bool wotagsany = options.Criteria["wotagsany"].ToObject(); diff --git a/server/AyaNova/kpi/WorkOrderStatusPct.cs b/server/AyaNova/kpi/WorkOrderStatusPct.cs index ee30ab2c..f29705f0 100644 --- a/server/AyaNova/kpi/WorkOrderStatusPct.cs +++ b/server/AyaNova/kpi/WorkOrderStatusPct.cs @@ -41,7 +41,13 @@ namespace AyaNova.KPI } var timeSpan = (string)options.Criteria["timeSpan"]; - var interval = (string)options.Criteria["interval"]; + //case 4717: interval goes into DATE_TRUNC in the query, so only PostgreSQL's own unit names are accepted + var interval = KPIFetcher.DateTruncUnitOrNull((string)options.Criteria["interval"]); + if (interval == null) + { + _errorMessage = "Invalid 'interval' criteria"; + return; + } var dateWhere = DataListSqlFilterCriteriaBuilder.DataFilterToColumnCriteria("aworkorder.createddate", UiFieldDataType.DateTime, "no-operator", timeSpan, options.ClientTimeStamp); var wotags = options.Criteria["wotags"].ToObject>(); bool wotagsany = options.Criteria["wotagsany"].ToObject();