From e07e6726926bdf179dd4a46170bce7c111766bad Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 8 Aug 2026 09:26:35 -0700 Subject: [PATCH 1/2] RG-T117 Chatbox fixes --- .../ChatbotDepartmentConfigService.cs | 22 +++++++++++++++++++ .../Controllers/v4/CallsController.cs | 10 +++++++++ .../Controllers/ChatbotSettingsController.cs | 8 +++++++ .../Areas/User/Models/ChatbotSettingsModel.cs | 12 +++++++++- .../User/Views/ChatbotSettings/Index.cshtml | 12 ++++++---- 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs b/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs index 69e58a38b..c95734d1d 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs @@ -121,6 +121,8 @@ public async Task SaveConfigAsync(ChatbotDepartmentConf else config.LlmApiKey = _encryptionService.Encrypt(newPlaintextLlmKey); + ValidateColumnLengths(config); + if (existing == null) { config.Id = Guid.NewGuid().ToString("N"); @@ -151,6 +153,26 @@ public async Task InvalidateCacheAsync(int departmentId) } } + /// + /// Guards against SQL truncation (error 8152) by validating string fields against the + /// ChatbotDepartmentConfigs column sizes (M0068/M0070) before hitting the database. + /// LlmApiKey is checked post-encryption since the ciphertext is what gets stored. + /// + private static void ValidateColumnLengths(ChatbotDepartmentConfig config) + { + if (config.AllowedPlatforms != null && config.AllowedPlatforms.Length > 500) + throw new ArgumentException("AllowedPlatforms cannot exceed 500 characters.", nameof(config)); + + if (config.LlmApiEndpoint != null && config.LlmApiEndpoint.Length > 500) + throw new ArgumentException("LlmApiEndpoint cannot exceed 500 characters.", nameof(config)); + + if (config.LlmModelName != null && config.LlmModelName.Length > 200) + throw new ArgumentException("LlmModelName cannot exceed 200 characters.", nameof(config)); + + if (config.LlmApiKey != null && config.LlmApiKey.Length > 1000) + throw new ArgumentException("Encrypted LlmApiKey cannot exceed 1000 characters; supply a shorter API key.", nameof(config)); + } + private static string CacheKey(int departmentId) => $"ChatbotDeptConfig_{departmentId}"; } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index a34035634..5dbcc0e9b 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -1701,9 +1701,19 @@ public async Task> GetCallHistory(int callId) /// Array of CallResult objects for each call in the department within the range [HttpGet("GetCalls")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [Authorize(Policy = ResgridResources.Call_View)] public async Task> GetCalls(DateTime startDate, DateTime endDate) { + // Missing query params bind to DateTime.MinValue (0001-01-01), which is below the SQL + // Server datetime floor (1753-01-01) and throws SqlDateTime overflow at the repository. + var sqlMinDate = new DateTime(1753, 1, 1); + if (startDate < sqlMinDate || endDate < sqlMinDate) + return BadRequest("startDate and endDate are required and must be valid dates (on or after 1753-01-01)."); + + if (endDate < startDate) + return BadRequest("endDate must be on or after startDate."); + var result = new ActiveCallsResult(); var calls = (await _callsService.GetAllCallsByDepartmentDateRangeAsync(DepartmentId, startDate, endDate)).OrderByDescending(x => x.LoggedOn); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChatbotSettingsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChatbotSettingsController.cs index 9651d5dfe..3a0e8d52f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ChatbotSettingsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChatbotSettingsController.cs @@ -55,6 +55,14 @@ public async Task Index(ChatbotSettingsModel model, CancellationT if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) return Unauthorized(); + if (!ModelState.IsValid) + { + var existing = await _chatbotConfigService.GetConfigAsync(DepartmentId); + model.HasLlmApiKey = !string.IsNullOrWhiteSpace(existing?.LlmApiKey); + model.LlmApiKey = null; + return View(model); + } + try { var config = new ChatbotDepartmentConfig diff --git a/Web/Resgrid.Web/Areas/User/Models/ChatbotSettingsModel.cs b/Web/Resgrid.Web/Areas/User/Models/ChatbotSettingsModel.cs index b6ccf9c4a..b200ae37e 100644 --- a/Web/Resgrid.Web/Areas/User/Models/ChatbotSettingsModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/ChatbotSettingsModel.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace Resgrid.Web.Areas.User.Models { public class ChatbotSettingsModel : BaseUserModel @@ -8,6 +10,7 @@ public class ChatbotSettingsModel : BaseUserModel public bool IsEnabled { get; set; } /// Comma-separated platform names allowed for this department, or "*" for all. + [StringLength(500, ErrorMessage = "Allowed platforms cannot exceed 500 characters.")] public string AllowedPlatforms { get; set; } = "*"; public bool AllowDispatchViaChatbot { get; set; } @@ -24,10 +27,17 @@ public class ChatbotSettingsModel : BaseUserModel // Department's own LLM/AI provider (optional). When set, the chatbot keeps this department's // processing with their provider instead of the Resgrid system LLM. + [StringLength(500, ErrorMessage = "API endpoint cannot exceed 500 characters.")] public string LlmApiEndpoint { get; set; } + + [StringLength(200, ErrorMessage = "Model name cannot exceed 200 characters.")] public string LlmModelName { get; set; } - /// Write-only: a new API key to store. Never populated on read (see HasLlmApiKey). + /// + /// Write-only: a new API key to store. Never populated on read (see HasLlmApiKey). + /// Cap is 700 so the AES+base64 ciphertext fits the 1000-char LlmApiKey column. + /// + [StringLength(700, ErrorMessage = "API key cannot exceed 700 characters.")] public string LlmApiKey { get; set; } /// True when an LLM API key is already stored (so the UI can indicate it without exposing it). diff --git a/Web/Resgrid.Web/Areas/User/Views/ChatbotSettings/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/ChatbotSettings/Index.cshtml index d53eee34f..84f14c3dd 100644 --- a/Web/Resgrid.Web/Areas/User/Views/ChatbotSettings/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/ChatbotSettings/Index.cshtml @@ -53,7 +53,8 @@
- + + @localizer["ChatbotAllowedPlatformsHelp"]
@@ -118,21 +119,24 @@
- + +
- + +
- + + @if (Model.HasLlmApiKey) { From 02e4fa73c5f010373cede6686109706bab59469f Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 8 Aug 2026 09:50:23 -0700 Subject: [PATCH 2/2] RG-T117 PR#451 fixes --- .../ChatbotDepartmentConfigService.cs | 10 ++++--- .../Controllers/v4/ChatbotController.cs | 6 ++++ .../Controllers/ChatbotSettingsController.cs | 5 ++++ .../Areas/User/Models/ChatbotSettingsModel.cs | 5 +++- .../Attributes/MaxUtf8BytesAttribute.cs | 30 +++++++++++++++++++ Web/Resgrid.Web/Resgrid.Web.csproj | 1 + 6 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 Web/Resgrid.Web/Attributes/MaxUtf8BytesAttribute.cs diff --git a/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs b/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs index c95734d1d..673e5cae5 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs @@ -157,20 +157,22 @@ public async Task InvalidateCacheAsync(int departmentId) /// Guards against SQL truncation (error 8152) by validating string fields against the /// ChatbotDepartmentConfigs column sizes (M0068/M0070) before hitting the database. /// LlmApiKey is checked post-encryption since the ciphertext is what gets stored. + /// Throws ArgumentException so API callers can map the failure to a 400 response; + /// messages are caller-safe (no parameter-name suffix). /// private static void ValidateColumnLengths(ChatbotDepartmentConfig config) { if (config.AllowedPlatforms != null && config.AllowedPlatforms.Length > 500) - throw new ArgumentException("AllowedPlatforms cannot exceed 500 characters.", nameof(config)); + throw new ArgumentException("AllowedPlatforms cannot exceed 500 characters."); if (config.LlmApiEndpoint != null && config.LlmApiEndpoint.Length > 500) - throw new ArgumentException("LlmApiEndpoint cannot exceed 500 characters.", nameof(config)); + throw new ArgumentException("LlmApiEndpoint cannot exceed 500 characters."); if (config.LlmModelName != null && config.LlmModelName.Length > 200) - throw new ArgumentException("LlmModelName cannot exceed 200 characters.", nameof(config)); + throw new ArgumentException("LlmModelName cannot exceed 200 characters."); if (config.LlmApiKey != null && config.LlmApiKey.Length > 1000) - throw new ArgumentException("Encrypted LlmApiKey cannot exceed 1000 characters; supply a shorter API key.", nameof(config)); + throw new ArgumentException("Encrypted LlmApiKey cannot exceed 1000 characters; supply a shorter API key."); } private static string CacheKey(int departmentId) => $"ChatbotDeptConfig_{departmentId}"; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs index 8e079aaec..45a1454f7 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs @@ -266,6 +266,7 @@ public async Task GetConfig() /// [HttpPut("Config")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task UpdateConfig([FromBody] ChatbotConfigRequest request) { try @@ -301,6 +302,11 @@ public async Task UpdateConfig([FromBody] ChatbotConfigRequest re return Ok(new { success = true }); } + catch (ArgumentException ex) + { + // Field-length (column size) validation from the config service. + return BadRequest(new { error = ex.Message }); + } catch (Exception ex) { Logging.LogException(ex); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChatbotSettingsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChatbotSettingsController.cs index 3a0e8d52f..393958668 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ChatbotSettingsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChatbotSettingsController.cs @@ -55,6 +55,11 @@ public async Task Index(ChatbotSettingsModel model, CancellationT if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) return Unauthorized(); + // Same SSRF guard as the v4 API config writer (ChatbotController.UpdateConfig). + if (!string.IsNullOrWhiteSpace(model.LlmApiEndpoint) && + !Resgrid.Chatbot.NLU.LlmEndpointValidator.IsValid(model.LlmApiEndpoint, out var llmEndpointError)) + ModelState.AddModelError(nameof(model.LlmApiEndpoint), llmEndpointError); + if (!ModelState.IsValid) { var existing = await _chatbotConfigService.GetConfigAsync(DepartmentId); diff --git a/Web/Resgrid.Web/Areas/User/Models/ChatbotSettingsModel.cs b/Web/Resgrid.Web/Areas/User/Models/ChatbotSettingsModel.cs index b200ae37e..2a4df49b1 100644 --- a/Web/Resgrid.Web/Areas/User/Models/ChatbotSettingsModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/ChatbotSettingsModel.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Resgrid.Web.Attributes; namespace Resgrid.Web.Areas.User.Models { @@ -35,9 +36,11 @@ public class ChatbotSettingsModel : BaseUserModel /// /// Write-only: a new API key to store. Never populated on read (see HasLlmApiKey). - /// Cap is 700 so the AES+base64 ciphertext fits the 1000-char LlmApiKey column. + /// Cap is 700 UTF-8 bytes (encryption operates on bytes) so the AES+base64 ciphertext + /// fits the 1000-char LlmApiKey column; StringLength adds the client-side char cap. /// [StringLength(700, ErrorMessage = "API key cannot exceed 700 characters.")] + [MaxUtf8Bytes(700, ErrorMessage = "API key cannot exceed 700 bytes when UTF-8 encoded (non-ASCII characters count as multiple bytes).")] public string LlmApiKey { get; set; } /// True when an LLM API key is already stored (so the UI can indicate it without exposing it). diff --git a/Web/Resgrid.Web/Attributes/MaxUtf8BytesAttribute.cs b/Web/Resgrid.Web/Attributes/MaxUtf8BytesAttribute.cs new file mode 100644 index 000000000..b05811aa9 --- /dev/null +++ b/Web/Resgrid.Web/Attributes/MaxUtf8BytesAttribute.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.Text; + +namespace Resgrid.Web.Attributes +{ + /// + /// Validates that a string's UTF-8 encoded byte count does not exceed a maximum. Use when the + /// stored representation depends on byte length (e.g. values encrypted before persisting), where + /// a character-count check (StringLength) would pass multi-byte Unicode input that overflows a + /// fixed-size column. + /// + public sealed class MaxUtf8BytesAttribute : ValidationAttribute + { + private readonly int _maxBytes; + + public MaxUtf8BytesAttribute(int maxBytes) + : base($"Cannot exceed {maxBytes} bytes when UTF-8 encoded.") + { + _maxBytes = maxBytes; + } + + public override bool IsValid(object value) + { + if (value is null) + return true; + + return value is string s && Encoding.UTF8.GetByteCount(s) <= _maxBytes; + } + } +} diff --git a/Web/Resgrid.Web/Resgrid.Web.csproj b/Web/Resgrid.Web/Resgrid.Web.csproj index 54ff49a91..cdb887299 100644 --- a/Web/Resgrid.Web/Resgrid.Web.csproj +++ b/Web/Resgrid.Web/Resgrid.Web.csproj @@ -156,6 +156,7 @@ +