diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GenericWebhookTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GenericWebhookTests.cs new file mode 100644 index 00000000..e23b823e --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GenericWebhookTests.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Notifications; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for the generic webhook provider: payload rendering, custom-header parsing/denylist, +/// and the save-time header validation. +/// +public class GenericWebhookTests +{ + // ── GenericJsonRenderer ─────────────────────────────────────────────── + + [Fact] + public void GenericJsonRenderer_RendersStableSchema() + { + var alert = new NotificationAlert + { + EventType = "enrollment_succeeded", + Title = "✅ Enrollment Succeeded", + Summary = "Enrollment Succeeded: DESK-001", + Severity = NotificationSeverity.Success, + ThemeColor = "00B050", + Facts = { new NotificationFact { Name = "Device", Value = "DESK-001" } }, + Actions = { new NotificationAction { Type = "openUrl", Title = "Open session", Url = "https://portal.example.com/sessions/abc" } }, + }; + + var json = new GenericJsonRenderer().RenderToJson(alert); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal("1.0", root.GetProperty("schemaVersion").GetString()); + Assert.Equal("enrollment_succeeded", root.GetProperty("eventType").GetString()); + Assert.Equal("Success", root.GetProperty("severity").GetString()); + Assert.Equal("Enrollment Succeeded: DESK-001", root.GetProperty("summary").GetString()); + Assert.Equal("https://portal.example.com/sessions/abc", root.GetProperty("primaryUrl").GetString()); + + var facts = root.GetProperty("facts"); + Assert.Equal(1, facts.GetArrayLength()); + Assert.Equal("Device", facts[0].GetProperty("name").GetString()); + Assert.Equal("DESK-001", facts[0].GetProperty("value").GetString()); + } + + [Fact] + public void GenericJsonRenderer_OmitsEventTypeAndSessionUrl_WhenAbsent() + { + var alert = new NotificationAlert + { + Title = "Test", + Summary = "Test", + Severity = NotificationSeverity.Info, + }; + + var json = new GenericJsonRenderer().RenderToJson(alert); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.False(root.TryGetProperty("eventType", out _)); + Assert.False(root.TryGetProperty("primaryUrl", out _)); + Assert.Equal(WebhookProviderType.GenericJson, new GenericJsonRenderer().ProviderType); + } + + [Theory] + [InlineData(true, "enrollment_succeeded")] + [InlineData(false, "enrollment_failed")] + public void BuildEnrollmentAlert_SetsEventType(bool success, string expected) + { + var alert = NotificationAlertBuilder.BuildEnrollmentAlert( + "DESK", "SN", "Contoso", "Model", success, failureReason: "x", duration: null); + + Assert.Equal(expected, alert.EventType); + } + + [Fact] + public void BuildHardwareRejectedAlert_SetsEventType() + { + var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert("Contoso", "Model", "SN"); + Assert.Equal("hardware_rejected", alert.EventType); + } + + // ── TenantConfiguration.GetGenericWebhookHeaders ────────────────────── + + [Fact] + public void GetGenericWebhookHeaders_ReturnsEmpty_ForNonGenericProvider() + { + var config = new TenantConfiguration + { + WebhookUrl = "https://hooks.slack.com/x", + WebhookProviderType = (int)WebhookProviderType.Slack, + WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer x\"}", + }; + + Assert.Empty(config.GetGenericWebhookHeaders()); + } + + [Fact] + public void GetGenericWebhookHeaders_ParsesHeaders_ForGenericProvider() + { + var config = new TenantConfiguration + { + WebhookUrl = "https://tickets.example.com/in", + WebhookProviderType = (int)WebhookProviderType.GenericJson, + WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer abc\",\"X-Api-Key\":\"k1\"}", + }; + + var headers = config.GetGenericWebhookHeaders(); + Assert.Equal("Bearer abc", headers["Authorization"]); + Assert.Equal("k1", headers["X-Api-Key"]); + } + + [Fact] + public void GetGenericWebhookHeaders_DropsRestrictedHeaders() + { + var config = new TenantConfiguration + { + WebhookUrl = "https://tickets.example.com/in", + WebhookProviderType = (int)WebhookProviderType.GenericJson, + WebhookCustomHeadersJson = "{\"Host\":\"evil.example.com\",\"Content-Length\":\"0\",\"X-Ok\":\"v\"}", + }; + + var headers = config.GetGenericWebhookHeaders(); + Assert.False(headers.ContainsKey("Host")); + Assert.False(headers.ContainsKey("Content-Length")); + Assert.True(headers.ContainsKey("X-Ok")); + } + + [Fact] + public void GetGenericWebhookHeaders_ReturnsEmpty_ForMalformedJson() + { + var config = new TenantConfiguration + { + WebhookUrl = "https://tickets.example.com/in", + WebhookProviderType = (int)WebhookProviderType.GenericJson, + WebhookCustomHeadersJson = "{not valid", + }; + + Assert.Empty(config.GetGenericWebhookHeaders()); + } + + // ── UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders ──── + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{\"Authorization\":\"Bearer abc\"}")] + public void ValidateWebhookCustomHeaders_Accepts_EmptyOrValid(string? json) + { + Assert.Null(UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json)); + } + + [Theory] + [InlineData("{not json", "not valid JSON.")] + [InlineData("[\"a\",\"b\"]", "must be a JSON object")] + [InlineData("{\"X-Key\":123}", "must have a string value")] + [InlineData("{\"Bad Header\":\"v\"}", "not a valid HTTP header name")] + [InlineData("{\"X-Key\":\"line1\\nline2\"}", "must not contain line breaks")] + public void ValidateWebhookCustomHeaders_Rejects_Invalid(string json, string expectedFragment) + { + var error = UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json); + Assert.NotNull(error); + Assert.Contains(expectedFragment, error); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index f646a9ee..bd0624a5 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -374,6 +374,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "WebhookNotifyOnFailure", config.WebhookNotifyOnFailure }, { "WebhookNotifyOnHardwareRejection", config.WebhookNotifyOnHardwareRejection }, { "WebhookNotifyOnStart", config.WebhookNotifyOnStart }, + { "WebhookCustomHeadersJson", config.WebhookCustomHeadersJson }, { "ShowScriptOutput", config.ShowScriptOutput }, { "ShowEnrollmentSummary", config.ShowEnrollmentSummary }, { "EnrollmentSummaryTimeoutSeconds", config.EnrollmentSummaryTimeoutSeconds }, @@ -464,6 +465,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage WebhookNotifyOnFailure = entity.GetBoolean("WebhookNotifyOnFailure") ?? true, WebhookNotifyOnHardwareRejection = entity.GetBoolean("WebhookNotifyOnHardwareRejection") ?? false, WebhookNotifyOnStart = entity.GetBoolean("WebhookNotifyOnStart") ?? false, + WebhookCustomHeadersJson = entity.GetString("WebhookCustomHeadersJson"), ShowScriptOutput = entity.GetBoolean("ShowScriptOutput") ?? true, ShowEnrollmentSummary = entity.GetBoolean("ShowEnrollmentSummary"), EnrollmentSummaryTimeoutSeconds = entity.GetInt32("EnrollmentSummaryTimeoutSeconds"), diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs index bf755b3b..e521b542 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs @@ -58,7 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var providerType = (WebhookProviderType)providerTypeInt; var testAlert = NotificationAlertBuilder.BuildTestAlert(); - var result = await _webhookNotificationService.SendNotificationWithResultAsync(webhookUrl, providerType, testAlert); + var result = await _webhookNotificationService.SendNotificationWithResultAsync(webhookUrl, providerType, testAlert, tenantConfig.GetGenericWebhookHeaders()); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs index 4d3b2c00..8f03aa78 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs @@ -78,6 +78,13 @@ namespace AutopilotMonitor.Functions.Functions.Config await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid Teams Webhook URL: {teamsUrlError}" }); return badRequest; } + var headersError = ValidateWebhookCustomHeaders(config.WebhookCustomHeadersJson); + if (headersError != null) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid custom headers: {headersError}" }); + return badRequest; + } // Only validate the customer-supplied SAS URL when the tenant has actually // selected the CustomerSas destination. In Hosted mode the field is unused at // runtime (see GetDiagnosticsUploadUrlFunction), so a stale/legacy value left @@ -176,5 +183,76 @@ namespace AutopilotMonitor.Functions.Functions.Config return response; } } + + private const int MaxCustomHeadersJsonLength = 8192; + private const int MaxCustomHeaderCount = 25; + + /// + /// Validates the generic-webhook custom-headers JSON. Returns an error message, or null when + /// valid/empty. Enforces a JSON object of string values, valid HTTP token names, no CR/LF + /// header-injection, and size caps. Restricted (framing/host/content) headers are not rejected + /// here — they are silently ignored at dispatch by TenantConfiguration.GetGenericWebhookHeaders(). + /// + internal static string? ValidateWebhookCustomHeaders(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + if (json.Length > MaxCustomHeadersJsonLength) + return $"too large (max {MaxCustomHeadersJsonLength} characters)."; + + System.Text.Json.JsonDocument doc; + try + { + doc = System.Text.Json.JsonDocument.Parse(json); + } + catch (System.Text.Json.JsonException) + { + return "not valid JSON."; + } + + using (doc) + { + if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) + return "must be a JSON object of header name/value pairs."; + + var count = 0; + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (++count > MaxCustomHeaderCount) + return $"too many headers (max {MaxCustomHeaderCount})."; + + if (prop.Value.ValueKind != System.Text.Json.JsonValueKind.String) + return $"header \"{prop.Name}\" must have a string value."; + + if (!IsValidHeaderName(prop.Name)) + return $"\"{prop.Name}\" is not a valid HTTP header name."; + + var value = prop.Value.GetString(); + if (value != null && (value.IndexOf('\r') >= 0 || value.IndexOf('\n') >= 0)) + return $"value for \"{prop.Name}\" must not contain line breaks."; + } + } + + return null; + } + + /// Validates an HTTP header name as an RFC 7230 token (no whitespace, controls, or separators). + private static bool IsValidHeaderName(string name) + { + if (string.IsNullOrEmpty(name)) + return false; + + foreach (var ch in name) + { + var isTokenChar = + (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || + "!#$%&'*+-.^_`|~".IndexOf(ch) >= 0; + if (!isTokenChar) + return false; + } + + return true; + } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs index 51639865..1bdc4ae1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs @@ -870,6 +870,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest return; var providerType = (WebhookProviderType)providerTypeInt; + var customHeaders = tenantConfig.GetGenericWebhookHeaders(); var sessionUrl = updatedSession != null ? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}" : null; @@ -905,7 +906,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert) + _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); } @@ -928,7 +929,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert) + _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); } @@ -950,7 +951,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert) + _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs index 5c200342..c0a11b99 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs @@ -231,7 +231,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest if (!string.IsNullOrEmpty(webhookUrl) && providerTypeInt != 0) { var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber); - _ = _webhookNotification.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert); + _ = _webhookNotification.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert, config.GetGenericWebhookHeaders()); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index a9ad5fbe..afd6110a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -260,7 +260,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions isResume: isResume, sessionUrl: sessionUrl); - await _webhookNotificationService.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert); + await _webhookNotificationService.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert, tenantConfig.GetGenericWebhookHeaders()); } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs index 5ab4c2b8..643fa034 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs @@ -27,6 +27,7 @@ namespace AutopilotMonitor.Functions.Services return; var providerType = (WebhookProviderType)providerTypeInt; + var customHeaders = tenantConfig.GetGenericWebhookHeaders(); var sessionUrl = updatedSession != null ? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}" : null; @@ -59,7 +60,7 @@ namespace AutopilotMonitor.Functions.Services sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert) + _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); } @@ -81,7 +82,7 @@ namespace AutopilotMonitor.Functions.Services sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert) + _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); } @@ -102,7 +103,7 @@ namespace AutopilotMonitor.Functions.Services sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert) + _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs new file mode 100644 index 00000000..c67751af --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs @@ -0,0 +1,58 @@ +using System.Linq; +using AutopilotMonitor.Shared.Models.Notifications; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Services.Notifications +{ + /// + /// Renders a NotificationAlert as a stable, channel-agnostic JSON payload for generic webhook + /// consumers (ticketing systems, automation, SMTP gateways such as Postal). + /// + /// Payload contract (schemaVersion "1.0"): + /// { + /// "schemaVersion": "1.0", + /// "eventType": "enrollment_succeeded", // omitted when not set + /// "severity": "Success", // Info | Success | Warning | Error + /// "title": "...", "summary": "...", "themeColor": "00B050", + /// "primaryUrl": "https://.../sessions/{id}", // first openUrl action (session/dashboard link), omitted when none + /// "facts": [ { "name": "Device", "value": "..." } ], + /// "sections": [ { "title": "...", "text": "..." } ], + /// "actions": [ { "type": "openUrl", "title": "...", "url": "..." } ] + /// } + /// + public class GenericJsonRenderer : INotificationRenderer + { + /// Bump only on a breaking change to the payload contract above. + private const string SchemaVersion = "1.0"; + + public WebhookProviderType ProviderType => WebhookProviderType.GenericJson; + + public string RenderToJson(NotificationAlert alert) + { + // First openUrl action: a session link for enrollment alerts, a dashboard/portal link for + // SLA/test alerts. Named generically (primaryUrl) since it is not always a session URL; + // consumers can still inspect actions[] (each carries its title) to disambiguate. + var primaryUrl = alert.Actions + .FirstOrDefault(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url))?.Url; + + var payload = new + { + schemaVersion = SchemaVersion, + eventType = string.IsNullOrEmpty(alert.EventType) ? null : alert.EventType, + severity = alert.Severity.ToString(), + title = alert.Title, + summary = alert.Summary, + themeColor = string.IsNullOrEmpty(alert.ThemeColor) ? null : alert.ThemeColor, + primaryUrl, + facts = alert.Facts.Select(f => new { name = f.Name, value = f.Value }).ToArray(), + sections = alert.Sections.Select(s => new { title = s.Title, text = s.Text }).ToArray(), + actions = alert.Actions.Select(a => new { type = a.Type, title = a.Title, url = a.Url }).ToArray(), + }; + + return JsonConvert.SerializeObject(payload, new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + }); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs index ff384ce4..04d071e9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs @@ -47,6 +47,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications var alert = new NotificationAlert { + EventType = success ? "enrollment_succeeded" : "enrollment_failed", Title = title, Summary = summary, Severity = severity, @@ -84,6 +85,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications var alert = new NotificationAlert { + EventType = isResume ? "preprovisioning_resumed" : "enrollment_started", Title = title, Summary = summary, Severity = NotificationSeverity.Info, @@ -127,6 +129,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications var alert = new NotificationAlert { + EventType = success ? "preprovisioning_completed" : "preprovisioning_failed", Title = title, Summary = summary, Severity = severity, @@ -150,6 +153,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications { return new NotificationAlert { + EventType = "test", Title = "\ud83d\udd14 Test Notification", Summary = "This is a test notification from Autopilot Monitor.", Severity = NotificationSeverity.Info, @@ -175,6 +179,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications return new NotificationAlert { + EventType = "hardware_rejected", Title = "\u26a0\ufe0f Hardware Not Whitelisted", Summary = $"Device rejected: {hardwareText} is not in your hardware whitelist", Severity = NotificationSeverity.Warning, @@ -298,6 +303,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications var alert = new NotificationAlert { + EventType = "sla_breach", Title = title, Summary = summary, Severity = NotificationSeverity.Warning, @@ -346,6 +352,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications var alert = new NotificationAlert { + EventType = "sla_resolved", Title = $"✅ SLA Breach Resolved: {label}", Summary = $"SLA breach resolved for tenant {tenantId}: {label} is back within target.", Severity = NotificationSeverity.Success, @@ -375,6 +382,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications { var alert = new NotificationAlert { + EventType = "consecutive_failures", Title = $"\ud83d\udea8 {consecutiveFailures} Consecutive Enrollment Failures", Summary = $"Alert: {consecutiveFailures} enrollments failed in a row for tenant {tenantId}", Severity = NotificationSeverity.Error, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs index 320b1070..a17ab62d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs @@ -28,13 +28,17 @@ namespace AutopilotMonitor.Functions.Services.Notifications [WebhookProviderType.TeamsLegacyConnector] = new LegacyTeamsConnectorRenderer(), [WebhookProviderType.TeamsWorkflowWebhook] = new TeamsWorkflowAdaptiveCardRenderer(), [WebhookProviderType.Slack] = new SlackRenderer(), + [WebhookProviderType.GenericJson] = new GenericJsonRenderer(), }; } /// /// Sends a notification (fire-and-forget, non-fatal). Exceptions are logged as warnings. + /// (generic webhooks only) are attached to the request, + /// e.g. an API-key/Authorization header for a ticketing system or SMTP gateway. /// - public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert) + public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert, + IReadOnlyDictionary? customHeaders = null) { if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None) return; @@ -50,8 +54,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications await SsrfGuard.ValidateDestinationAsync(webhookUrl); var json = renderer.RenderToJson(alert); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await _http.PostAsync(webhookUrl, content); + var response = await PostAsync(webhookUrl, json, customHeaders); if (response.IsSuccessStatusCode) _logger.LogInformation("Webhook notification sent: {Summary} (provider={ProviderType})", alert.Summary, providerType); @@ -69,8 +72,10 @@ namespace AutopilotMonitor.Functions.Services.Notifications /// /// Sends a notification and returns the result (for test endpoint). Not fire-and-forget. + /// (generic webhooks only) are attached to the request. /// - public async Task SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert) + public async Task SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert, + IReadOnlyDictionary? customHeaders = null) { if (string.IsNullOrEmpty(webhookUrl)) return new WebhookTestResult { Success = false, Message = "Webhook URL is not configured." }; @@ -93,8 +98,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications try { var json = renderer.RenderToJson(alert); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await _http.PostAsync(webhookUrl, content); + var response = await PostAsync(webhookUrl, json, customHeaders); var statusCode = (int)response.StatusCode; if (response.IsSuccessStatusCode) @@ -116,6 +120,25 @@ namespace AutopilotMonitor.Functions.Services.Notifications } } + /// + /// POSTs the rendered JSON, attaching any custom headers as request headers. Restricted + /// (framing/host/content) headers are already filtered upstream by GetGenericWebhookHeaders(). + /// + private Task PostAsync(string webhookUrl, string json, IReadOnlyDictionary? customHeaders) + { + var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + if (customHeaders != null) + { + foreach (var header in customHeaders) + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + return _http.SendAsync(request); + } } public class WebhookTestResult diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs index 7229d6fe..9bee75df 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs @@ -551,7 +551,7 @@ namespace AutopilotMonitor.Functions.Services var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig(); if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0) { - await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert); + await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders()); } await _tenantNotificationService.CreateNotificationAsync( @@ -594,7 +594,7 @@ namespace AutopilotMonitor.Functions.Services var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig(); if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0) { - await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert); + await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders()); } await _tenantNotificationService.CreateNotificationAsync( @@ -682,7 +682,7 @@ namespace AutopilotMonitor.Functions.Services var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig(); if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0) { - await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert); + await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders()); } await _tenantNotificationService.CreateNotificationAsync( diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 39d9d712..3bf3b5de 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -548,6 +548,15 @@ namespace AutopilotMonitor.Shared.Models /// public bool WebhookNotifyOnStart { get; set; } = false; + /// + /// Custom HTTP request headers (JSON object: { "Header-Name": "value", ... }) sent with every + /// generic-webhook POST. Used for API-key authentication against ticketing systems / SMTP gateways. + /// Only applied when the effective provider is . + /// Restricted headers (Host, Content-Length, Content-Type, etc.) are ignored — see + /// . + /// + public string WebhookCustomHeadersJson { get; set; } = default!; + // ===== SLA TARGETS ===== /// @@ -640,6 +649,64 @@ namespace AutopilotMonitor.Shared.Models public bool GetEffectiveNotifyOnStart() => !string.IsNullOrEmpty(WebhookUrl) ? WebhookNotifyOnStart : TeamsNotifyOnStart; + /// + /// HTTP headers that must never be set via the custom-header mechanism: framing/host/content + /// headers are owned by the HTTP client and overriding them breaks the request or enables + /// request-smuggling. Compared case-insensitively. + /// + private static readonly HashSet RestrictedWebhookHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "Host", "Content-Length", "Content-Type", "Transfer-Encoding", "Connection", + "Keep-Alive", "Upgrade", "TE", "Trailer", "Expect", "Proxy-Connection", + }; + + /// + /// Parses into header name/value pairs for the generic + /// webhook dispatcher. Returns an empty dictionary unless the effective provider is + /// , the JSON is a parseable object, + /// and after dropping restricted headers and blank names/values. Never throws. + /// + public IReadOnlyDictionary GetGenericWebhookHeaders() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var (_, providerType) = GetEffectiveWebhookConfig(); + if (providerType != (int)Notifications.WebhookProviderType.GenericJson) + return result; + + if (string.IsNullOrWhiteSpace(WebhookCustomHeadersJson)) + return result; + + try + { + using var doc = JsonDocument.Parse(WebhookCustomHeadersJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return result; + + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (prop.Value.ValueKind != JsonValueKind.String) + continue; + + var name = prop.Name.Trim(); + var value = prop.Value.GetString(); + + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrEmpty(value)) + continue; + if (RestrictedWebhookHeaders.Contains(name)) + continue; + + result[name] = value!; + } + } + catch (JsonException) + { + // Malformed JSON → no custom headers (fail-soft; dispatch still proceeds). + } + + return result; + } + /// /// Checks if the tenant is currently disabled /// Takes into account DisabledUntil if set diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs index 9aa16c49..9ae697d0 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs @@ -9,6 +9,13 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// public class NotificationAlert { + /// + /// Machine-readable event type for routing/filtering by generic webhook consumers + /// (e.g. "enrollment_succeeded", "enrollment_failed", "hardware_rejected", "sla_breach"). + /// Only serialized by the generic JSON renderer; ignored by Teams/Slack renderers. + /// + public string? EventType { get; set; } + /// Main title, e.g. "Enrollment Succeeded". public string Title { get; set; } = default!; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs index 0b05a50b..75dd78d8 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs @@ -16,5 +16,12 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// Slack Incoming Webhook (Block Kit format). Slack = 10, + + /// + /// Generic JSON webhook. Posts a stable, channel-agnostic JSON payload (schemaVersion + eventType) + /// to any HTTP endpoint — for ticketing systems, automation, or SMTP gateways (e.g. Postal). + /// Supports per-tenant custom request headers for API-key authentication. + /// + GenericJson = 20, } } diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx index 826646ee..b32999c8 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx @@ -197,6 +197,7 @@ const WEBHOOK_PROVIDERS: Record = { 1: 'Teams (Legacy Connector)', 2: 'Teams (Workflow Webhook)', 10: 'Slack', + 20: 'Generic JSON', }; function formatValue(val: unknown): string { diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index fdf0b6a9..4483347c 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -153,6 +153,8 @@ interface TenantConfigContextValue { setWebhookNotifyOnFailure: (v: boolean) => void; webhookNotifyOnStart: boolean; setWebhookNotifyOnStart: (v: boolean) => void; + webhookCustomHeaders: string; + setWebhookCustomHeaders: (v: string) => void; testingWebhook: boolean; testWebhookResult: { success: boolean; message: string } | null; handleTestWebhook: () => Promise; @@ -334,6 +336,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } const [webhookNotifyOnSuccess, setWebhookNotifyOnSuccess] = useState(true); const [webhookNotifyOnFailure, setWebhookNotifyOnFailure] = useState(true); const [webhookNotifyOnStart, setWebhookNotifyOnStart] = useState(false); + const [webhookCustomHeaders, setWebhookCustomHeaders] = useState(""); const [testingWebhook, setTestingWebhook] = useState(false); const [testWebhookResult, setTestWebhookResult] = useState<{ success: boolean; message: string } | null>(null); @@ -439,18 +442,21 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setWebhookNotifyOnSuccess(data.webhookNotifyOnSuccess ?? true); setWebhookNotifyOnFailure(data.webhookNotifyOnFailure ?? true); setWebhookNotifyOnStart(data.webhookNotifyOnStart ?? false); + setWebhookCustomHeaders(data.webhookCustomHeadersJson ?? ""); } else if (data.teamsWebhookUrl) { setWebhookProviderType(1); // TeamsLegacyConnector setWebhookUrl(data.teamsWebhookUrl); setWebhookNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true); setWebhookNotifyOnFailure(data.teamsNotifyOnFailure ?? true); setWebhookNotifyOnStart(data.teamsNotifyOnStart ?? false); + setWebhookCustomHeaders(""); } else { setWebhookProviderType(0); setWebhookUrl(""); setWebhookNotifyOnSuccess(true); setWebhookNotifyOnFailure(true); setWebhookNotifyOnStart(false); + setWebhookCustomHeaders(""); } // SLA Targets setSlaTargetSuccessRate(data.slaTargetSuccessRate ?? null); @@ -650,6 +656,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } webhookNotifyOnSuccess, webhookNotifyOnFailure, webhookNotifyOnStart, + // Custom headers only apply to the generic provider; clear them otherwise so a + // provider switch does not leave stale auth headers persisted. + webhookCustomHeadersJson: webhookProviderType === 20 ? (webhookCustomHeaders || undefined) : undefined, // Legacy compat: mirror to old fields during transition teamsWebhookUrl: webhookProviderType === 1 ? (webhookUrl || undefined) : undefined, teamsNotifyOnSuccess: webhookNotifyOnSuccess, @@ -721,6 +730,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds, enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds, webhookProviderType, webhookUrl, webhookNotifyOnSuccess, webhookNotifyOnFailure, webhookNotifyOnStart, + webhookCustomHeaders, slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate, slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach, slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold, @@ -1064,18 +1074,21 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setWebhookNotifyOnSuccess(config.webhookNotifyOnSuccess ?? true); setWebhookNotifyOnFailure(config.webhookNotifyOnFailure ?? true); setWebhookNotifyOnStart(config.webhookNotifyOnStart ?? false); + setWebhookCustomHeaders(config.webhookCustomHeadersJson ?? ""); } else if (config.teamsWebhookUrl) { setWebhookProviderType(1); setWebhookUrl(config.teamsWebhookUrl); setWebhookNotifyOnSuccess(config.teamsNotifyOnSuccess ?? true); setWebhookNotifyOnFailure(config.teamsNotifyOnFailure ?? true); setWebhookNotifyOnStart(config.teamsNotifyOnStart ?? false); + setWebhookCustomHeaders(""); } else { setWebhookProviderType(0); setWebhookUrl(""); setWebhookNotifyOnSuccess(true); setWebhookNotifyOnFailure(true); setWebhookNotifyOnStart(false); + setWebhookCustomHeaders(""); } }, [config]); @@ -1456,6 +1469,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } webhookNotifyOnSuccess, setWebhookNotifyOnSuccess, webhookNotifyOnFailure, setWebhookNotifyOnFailure, webhookNotifyOnStart, setWebhookNotifyOnStart, + webhookCustomHeaders, setWebhookCustomHeaders, testingWebhook, testWebhookResult, handleTestWebhook, handleSaveNotifications, handleResetNotifications, diff --git a/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx index b8d9093c..8c08859b 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef, useState } from "react"; import SaveResetBar from "./SaveResetBar"; interface NotificationsSectionProps { @@ -13,6 +14,8 @@ interface NotificationsSectionProps { setWebhookNotifyOnFailure: (v: boolean) => void; webhookNotifyOnStart: boolean; setWebhookNotifyOnStart: (v: boolean) => void; + webhookCustomHeaders: string; + setWebhookCustomHeaders: (v: string) => void; onTestWebhook: () => Promise; testingWebhook: boolean; testWebhookResult: { success: boolean; message: string } | null; @@ -21,13 +24,113 @@ interface NotificationsSectionProps { saving: boolean; } +const GENERIC_PROVIDER = 20; + const PROVIDERS = [ { value: 0, label: "None (disabled)", placeholder: "" }, { value: 1, label: "Microsoft Teams (Legacy Connector)", placeholder: "https://your-org.webhook.office.com/webhookb2/...", badge: "Deprecated", badgeColor: "bg-amber-100 text-amber-800" }, { value: 2, label: "Microsoft Teams (Workflow Webhook)", placeholder: "https://prod-xx.westeurope.logic.azure.com:443/workflows/...", badge: "Recommended", badgeColor: "bg-green-100 text-green-800" }, { value: 10, label: "Slack", placeholder: "https://hooks.slack.com/services/T.../B.../..." }, + { value: GENERIC_PROVIDER, label: "Generic JSON (ticketing / automation)", placeholder: "https://your-system.example.com/api/webhooks/autopilot" }, ]; +type HeaderRow = { key: string; value: string }; + +function parseHeaderRows(json: string): HeaderRow[] { + if (!json || !json.trim()) return []; + try { + const obj = JSON.parse(json); + if (obj && typeof obj === "object" && !Array.isArray(obj)) { + return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) })); + } + } catch { + // Malformed JSON (e.g. legacy/hand-edited value) — start from an empty editor. + } + return []; +} + +function serializeHeaderRows(rows: HeaderRow[]): string { + const obj: Record = {}; + for (const { key, value } of rows) { + const k = key.trim(); + if (k) obj[k] = value; + } + return Object.keys(obj).length > 0 ? JSON.stringify(obj) : ""; +} + +/** + * Key→value editor for generic-webhook custom headers. Owns row state locally and serializes + * to the JSON string the backend persists; re-syncs only on external changes (config load/reset). + */ +function WebhookHeaderEditor({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const [rows, setRows] = useState(() => parseHeaderRows(value)); + const lastSerialized = useRef(value); + + useEffect(() => { + if (value !== lastSerialized.current) { + setRows(parseHeaderRows(value)); + lastSerialized.current = value; + } + }, [value]); + + const commit = (next: HeaderRow[]) => { + setRows(next); + const json = serializeHeaderRows(next); + lastSerialized.current = json; + onChange(json); + }; + + return ( +
+ Custom Headers +

+ Optional HTTP headers sent with every request — e.g. Authorization with an API key + for your ticketing system or SMTP gateway. Framing headers (Host, Content-Type, …) are ignored. +

+
+ {rows.map((row, i) => ( +
+ commit(rows.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)))} + placeholder="Header name" + className="block w-1/3 px-3 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm" + /> + commit(rows.map((r, j) => (j === i ? { ...r, value: e.target.value } : r)))} + placeholder="Value" + className="block flex-1 px-3 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm" + /> + +
+ ))} +
+ +
+ ); +} + export default function NotificationsSection({ webhookProviderType, setWebhookProviderType, @@ -39,6 +142,8 @@ export default function NotificationsSection({ setWebhookNotifyOnFailure, webhookNotifyOnStart, setWebhookNotifyOnStart, + webhookCustomHeaders, + setWebhookCustomHeaders, onTestWebhook, testingWebhook, testWebhookResult, @@ -74,7 +179,13 @@ export default function NotificationsSection({