feat(notifications): Discord webhook provider + HMAC signing for generic webhooks

Discord (provider 30) renders alerts as a single embed with Discord's
field/length caps; actions become markdown links since webhooks cannot
post buttons. Generic JSON channels gain an optional per-channel signing
secret: requests carry X-AutopilotMonitor-Timestamp and
X-AutopilotMonitor-Signature (sha256 hex HMAC over "{timestamp}.{body}"),
computed over the exact posted JSON with same-named custom headers
stripped first. Secret is redacted for readers and restored by channel
id on save; validation enforces 16-128 printable ASCII characters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-22 21:09:12 +02:00
parent 445ce4e75b
commit 6435d5f290
16 changed files with 519 additions and 16 deletions
@@ -0,0 +1,124 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.Models.Notifications;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Discord embed rendering: payload shape, color mapping, and the Discord embed limits
/// (25 fields, 256/1024 name/value, 4096 description, 6000 total).
/// </summary>
public class DiscordRendererTests
{
private static JsonElement RenderEmbed(NotificationAlert alert)
{
var json = new DiscordRenderer().RenderToJson(alert);
var doc = JsonDocument.Parse(json);
var embeds = doc.RootElement.GetProperty("embeds");
Assert.Equal(1, embeds.GetArrayLength());
return embeds[0];
}
[Fact]
public void RendersSingleEmbed_WithTitleColorFieldsAndLinks()
{
var alert = new NotificationAlert
{
Title = "Enrollment Succeeded",
Summary = "Enrollment Succeeded: DESK-001",
Severity = NotificationSeverity.Success,
ThemeColor = "00B050",
Facts = { new NotificationFact { Name = "Device", Value = "DESK-001" } },
Sections = { new NotificationSection { Title = "Details", Text = "All apps installed." } },
Actions = { new NotificationAction { Type = "openUrl", Title = "Open session", Url = "https://portal.example.com/sessions/abc" } },
};
var embed = RenderEmbed(alert);
Assert.Equal("✅ Enrollment Succeeded", embed.GetProperty("title").GetString());
Assert.Equal(0x00B050, embed.GetProperty("color").GetInt32());
var description = embed.GetProperty("description").GetString()!;
Assert.StartsWith("Enrollment Succeeded: DESK-001", description);
Assert.Contains("**Details**\nAll apps installed.", description);
// Webhooks cannot post buttons — actions become markdown links.
Assert.Contains("[Open session](https://portal.example.com/sessions/abc)", description);
var fields = embed.GetProperty("fields");
Assert.Equal(1, fields.GetArrayLength());
Assert.Equal("Device", fields[0].GetProperty("name").GetString());
Assert.Equal("DESK-001", fields[0].GetProperty("value").GetString());
Assert.True(fields[0].GetProperty("inline").GetBoolean());
}
[Theory]
[InlineData(NotificationSeverity.Success, 0x2ECC71)]
[InlineData(NotificationSeverity.Error, 0xE74C3C)]
[InlineData(NotificationSeverity.Warning, 0xF1C40F)]
[InlineData(NotificationSeverity.Info, 0x3498DB)]
public void Color_FallsBackToSeverity_WhenThemeColorMissingOrInvalid(NotificationSeverity severity, int expected)
{
foreach (var themeColor in new[] { "", "not-hex" })
{
var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = severity, ThemeColor = themeColor });
Assert.Equal(expected, embed.GetProperty("color").GetInt32());
}
}
[Fact]
public void Fields_CappedAt25_AndEmptyFactsSkipped()
{
var alert = new NotificationAlert { Title = "T", Summary = "S" };
alert.Facts.Add(new NotificationFact { Name = "", Value = "dropped" });
alert.Facts.Add(new NotificationFact { Name = "dropped", Value = " " });
for (var i = 0; i < 30; i++)
alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = "v" });
var fields = RenderEmbed(alert).GetProperty("fields");
Assert.Equal(25, fields.GetArrayLength());
Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // empty facts skipped, not counted
}
[Fact]
public void FieldValue_TruncatedTo1024_WithEllipsis()
{
var alert = new NotificationAlert { Title = "T" };
alert.Facts.Add(new NotificationFact { Name = "long", Value = new string('x', 2000) });
var value = RenderEmbed(alert).GetProperty("fields")[0].GetProperty("value").GetString()!;
Assert.Equal(1024, value.Length);
Assert.EndsWith("…", value);
}
[Fact]
public void TotalEmbedLength_CappedAt6000_ByDroppingTrailingFields()
{
var alert = new NotificationAlert { Title = "T", Summary = "S" };
for (var i = 0; i < 25; i++)
alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = new string('v', 1000) });
var embed = RenderEmbed(alert);
var fields = embed.GetProperty("fields");
var total = embed.GetProperty("title").GetString()!.Length
+ embed.GetProperty("description").GetString()!.Length;
for (var i = 0; i < fields.GetArrayLength(); i++)
total += fields[i].GetProperty("name").GetString()!.Length + fields[i].GetProperty("value").GetString()!.Length;
Assert.True(fields.GetArrayLength() < 25);
Assert.True(total <= 6000);
Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // leading fields kept
}
[Fact]
public void EmptyDescription_Omitted_AndProviderTypeIsDiscord()
{
var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = NotificationSeverity.Info });
Assert.False(embed.TryGetProperty("description", out _));
Assert.False(embed.TryGetProperty("fields", out _));
Assert.Equal(WebhookProviderType.Discord, new DiscordRenderer().ProviderType);
}
}
@@ -1,4 +1,5 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
@@ -64,6 +65,17 @@ public class NotificationChannelsTests
Assert.Empty(slack.GetCustomHeaders());
}
[Fact]
public void GetSigningSecret_OnlyForGenericJson()
{
var secret = "0123456789abcdef";
Assert.Equal(secret, new NotificationChannel { Id = "g", ProviderType = 20, SigningSecret = secret }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "s", ProviderType = 10, SigningSecret = secret }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "d", ProviderType = 30, SigningSecret = secret }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "w", ProviderType = 20, SigningSecret = " " }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "n", ProviderType = 20 }.GetSigningSecret());
}
// ── Legacy synthesis (GetNotificationChannels) ────────────────────────
[Fact]
@@ -141,7 +153,7 @@ public class NotificationChannelsTests
var cfg = new TenantConfiguration
{
NotificationChannelsJson = "[" +
ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\"") + "," +
ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\",\"signingSecret\":\"0123456789abcdef\"") + "," +
ChannelJson("ch-2", providerType: 10, url: "https://slack.example/h") + "]",
};
@@ -151,8 +163,10 @@ public class NotificationChannelsTests
Assert.Equal("ch-1", redacted[0].Id); // structure visible
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].Url); // secrets masked
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].CustomHeadersJson);
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].SigningSecret);
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[1].Url);
Assert.Null(redacted[1].CustomHeadersJson); // unset stays unset
Assert.Null(redacted[1].SigningSecret);
}
[Fact]
@@ -169,7 +183,7 @@ public class NotificationChannelsTests
{
NotificationChannelsJson = "[" +
ChannelJson("ch-1", url: "https://real.example/hook",
extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\"") + "]",
extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\",\"signingSecret\":\"real-secret-0123\"") + "]",
};
// Round-trip the redacted view back onto a save (defense-in-depth path).
@@ -179,6 +193,7 @@ public class NotificationChannelsTests
var ch = Assert.Single(NotificationChannel.ParseList(incoming.NotificationChannelsJson));
Assert.Equal("https://real.example/hook", ch.Url);
Assert.Equal("{\"X-Api-Key\":\"real\"}", ch.CustomHeadersJson);
Assert.Equal("real-secret-0123", ch.SigningSecret);
}
[Fact]
@@ -244,4 +259,40 @@ public class NotificationChannelsTests
Assert.NotNull(error);
Assert.Contains("headers", error);
}
// ── Signing-secret validation ─────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("0123456789abcdef")] // 16 chars (minimum)
public void ValidateWebhookSigningSecret_AcceptsAbsentOrValid(string? secret)
{
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
// 128 chars (maximum) and a typical generated 64-char hex secret
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 128)));
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('f', 64)));
}
[Theory]
[InlineData("short-15-chars.")] // below minimum
[InlineData("has a space in the middle!")] // whitespace
[InlineData("line\nbreak-0123456789")] // control char
[InlineData("umlauts-äöü-0123456789")] // non-ASCII
[InlineData("***REDACTED***")] // unrestored sentinel (too short anyway)
public void ValidateWebhookSigningSecret_RejectsInvalid(string secret)
{
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 129)));
}
[Fact]
public void ValidateNotificationChannels_RejectsBadPerChannelSigningSecret()
{
var json = "[" + ChannelJson("ch-1", extra: ",\"signingSecret\":\"too-short\"") + "]";
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json);
Assert.NotNull(error);
Assert.Contains("signing secret", error);
}
}
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.Services.Notifications;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// HMAC request-signature scheme for generic JSON webhooks: known-vector stability (the
/// scheme is a published contract receivers implement against), determinism, and
/// secret/message sensitivity.
/// </summary>
public class WebhookSignatureTests
{
[Fact]
public void ComputeSignature_MatchesKnownVector()
{
// Independently computed: HMACSHA256("whsec_test_0123456789abcdef", "1700000000.{"a":1}")
var signature = WebhookSignatureCalculator.ComputeSignature(
"whsec_test_0123456789abcdef", "1700000000", "{\"a\":1}");
Assert.Equal("sha256=07728d7e7b1736bac3de36a46c9bc0b660d59aa4a6f251544124fede1dc42a6c", signature);
}
[Fact]
public void ComputeSignature_IsDeterministic_AndSensitiveToEveryInput()
{
var baseline = WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}");
Assert.Equal(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}"));
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789X", "1700000000", "{}"));
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000001", "{}"));
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{ }"));
}
[Fact]
public void HeaderNames_AreTheDocumentedContract()
{
Assert.Equal("X-AutopilotMonitor-Timestamp", WebhookSignatureCalculator.TimestampHeader);
Assert.Equal("X-AutopilotMonitor-Signature", WebhookSignatureCalculator.SignatureHeader);
}
}
@@ -82,7 +82,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var testAlert = NotificationAlertBuilder.BuildTestAlert();
var result = await _webhookNotificationService.SendNotificationWithResultAsync(
channel.Url, (WebhookProviderType)channel.ProviderType, testAlert, channel.GetCustomHeaders());
channel.Url, (WebhookProviderType)channel.ProviderType, testAlert, channel.GetCustomHeaders(), channel.GetSigningSecret());
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
@@ -182,6 +182,35 @@ namespace AutopilotMonitor.Functions.Helpers
var headerError = ValidateWebhookCustomHeaders(channel.CustomHeadersJson);
if (headerError != null)
return $"channel \"{label}\" headers: {headerError}";
var secretError = ValidateWebhookSigningSecret(channel.SigningSecret);
if (secretError != null)
return $"channel \"{label}\" signing secret: {secretError}";
}
return null;
}
/// <summary>
/// Validates a generic-webhook HMAC signing secret. Returns an error message, or null when
/// valid/absent. A short key is brute-forceable as an HMAC key; the printable-ASCII rule
/// keeps the secret copy-paste safe and (as a side effect) rejects a stray unrestored
/// redaction sentinel, which is shorter than the minimum. No provider gate here — a secret
/// on a non-generic channel is stored but inert at dispatch (GetSigningSecret), matching
/// how CustomHeadersJson behaves.
/// </summary>
internal static string? ValidateWebhookSigningSecret(string? secret)
{
if (string.IsNullOrWhiteSpace(secret))
return null;
if (secret.Length < 16 || secret.Length > 128)
return "must be 16-128 characters.";
foreach (var ch in secret)
{
if (ch < 0x21 || ch > 0x7E)
return "must be printable ASCII without spaces or line breaks.";
}
return null;
@@ -0,0 +1,140 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using AutopilotMonitor.Shared.Models.Notifications;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services.Notifications
{
/// <summary>
/// Renders a NotificationAlert as a Discord webhook payload (single embed).
/// Discord webhooks cannot post buttons, so openUrl actions are appended to the
/// embed description as markdown links.
/// </summary>
public class DiscordRenderer : INotificationRenderer
{
// Discord embed limits: https://discord.com/developers/docs/resources/message#embed-object-embed-limits
private const int MaxTitleLength = 256;
private const int MaxDescriptionLength = 4096;
private const int MaxFields = 25;
private const int MaxFieldNameLength = 256;
private const int MaxFieldValueLength = 1024;
private const int MaxEmbedTotalLength = 6000;
public WebhookProviderType ProviderType => WebhookProviderType.Discord;
public string RenderToJson(NotificationAlert alert)
{
var title = Truncate($"{GetSeverityEmoji(alert.Severity)} {alert.Title}", MaxTitleLength);
var description = Truncate(BuildDescription(alert), MaxDescriptionLength);
// Facts as inline fields. Discord rejects fields with an empty name or value.
var facts = alert.Facts
.Where(f => !string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value))
.Take(MaxFields)
.Select(f => (Name: Truncate(f.Name, MaxFieldNameLength), Value: Truncate(f.Value, MaxFieldValueLength)))
.ToList();
// Enforce the 6000-char total cap across title + description + fields:
// drop trailing fields first, then shrink the description to the remaining budget.
var fixedLength = title.Length + description.Length;
while (facts.Count > 0 && fixedLength + facts.Sum(f => f.Name.Length + f.Value.Length) > MaxEmbedTotalLength)
facts.RemoveAt(facts.Count - 1);
if (fixedLength > MaxEmbedTotalLength)
description = Truncate(description, MaxEmbedTotalLength - title.Length);
var fields = facts
.Select(f => new { name = f.Name, value = f.Value, inline = true })
.ToArray();
var embed = new
{
title,
description = description.Length > 0 ? description : null,
color = GetColor(alert),
fields = fields.Length > 0 ? fields : null
};
var payload = new { embeds = new[] { embed } };
return JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver
{
NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy()
}
});
}
private static string BuildDescription(NotificationAlert alert)
{
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(alert.Summary))
sb.Append(alert.Summary);
foreach (var section in alert.Sections)
{
var text = "";
if (!string.IsNullOrEmpty(section.Title))
text += $"**{section.Title}**\n";
if (!string.IsNullOrEmpty(section.Text))
text += section.Text;
if (text.Length > 0)
{
if (sb.Length > 0)
sb.Append("\n\n");
sb.Append(text);
}
}
foreach (var action in alert.Actions.Where(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url)))
{
if (sb.Length > 0)
sb.Append("\n\n");
sb.Append($"[{action.Title}]({action.Url})");
}
return sb.ToString();
}
private static int GetColor(NotificationAlert alert)
{
if (!string.IsNullOrEmpty(alert.ThemeColor)
&& int.TryParse(alert.ThemeColor.TrimStart('#'), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var color))
return color;
return alert.Severity switch
{
NotificationSeverity.Success => 0x2ECC71,
NotificationSeverity.Error => 0xE74C3C,
NotificationSeverity.Warning => 0xF1C40F,
_ => 0x3498DB
};
}
private static string GetSeverityEmoji(NotificationSeverity severity)
{
return severity switch
{
NotificationSeverity.Success => "\u2705",
NotificationSeverity.Error => "\u274c",
NotificationSeverity.Warning => "\u26a0\ufe0f",
NotificationSeverity.Info => "\u2139\ufe0f",
_ => "\u2139\ufe0f"
};
}
private static string Truncate(string value, int maxLength)
{
if (maxLength <= 0)
return "";
if (value.Length <= maxLength)
return value;
return value.Substring(0, maxLength - 1) + "\u2026";
}
}
}
@@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
[WebhookProviderType.TeamsWorkflowWebhook] = new TeamsWorkflowAdaptiveCardRenderer(),
[WebhookProviderType.Slack] = new SlackRenderer(),
[WebhookProviderType.GenericJson] = new GenericJsonRenderer(),
[WebhookProviderType.Discord] = new DiscordRenderer(),
};
}
@@ -36,9 +37,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications
/// Sends a notification (fire-and-forget, non-fatal). Exceptions are logged as warnings.
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request,
/// e.g. an API-key/Authorization header for a ticketing system or SMTP gateway.
/// <paramref name="signingSecret"/> (generic webhooks only) adds HMAC signature headers
/// (see <see cref="WebhookSignatureCalculator"/>).
/// </summary>
public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
IReadOnlyDictionary<string, string>? customHeaders = null)
IReadOnlyDictionary<string, string>? customHeaders = null, string? signingSecret = null)
{
if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None)
return;
@@ -54,7 +57,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
await SsrfGuard.ValidateDestinationAsync(webhookUrl);
var json = renderer.RenderToJson(alert);
var response = await PostAsync(webhookUrl, json, customHeaders);
var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret);
if (response.IsSuccessStatusCode)
_logger.LogInformation("Webhook notification sent: {Summary} (provider={ProviderType})", alert.Summary, providerType);
@@ -88,16 +91,18 @@ namespace AutopilotMonitor.Functions.Services.Notifications
channel.Url,
(WebhookProviderType)channel.ProviderType,
alert,
channel.GetCustomHeaders());
channel.GetCustomHeaders(),
channel.GetSigningSecret());
}
}
/// <summary>
/// Sends a notification and returns the result (for test endpoint). Not fire-and-forget.
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request.
/// <paramref name="customHeaders"/> and <paramref name="signingSecret"/> (generic
/// webhooks only) are applied exactly like in <see cref="SendNotificationAsync"/>.
/// </summary>
public async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
IReadOnlyDictionary<string, string>? customHeaders = null)
IReadOnlyDictionary<string, string>? customHeaders = null, string? signingSecret = null)
{
if (string.IsNullOrEmpty(webhookUrl))
return new WebhookTestResult { Success = false, Message = "Webhook URL is not configured." };
@@ -120,7 +125,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
try
{
var json = renderer.RenderToJson(alert);
var response = await PostAsync(webhookUrl, json, customHeaders);
var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
@@ -145,8 +150,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications
/// <summary>
/// POSTs the rendered JSON, attaching any custom headers as request headers. Restricted
/// (framing/host/content) headers are already filtered upstream by GetGenericWebhookHeaders().
/// When a signing secret is present, HMAC signature headers are computed over the exact
/// JSON string being posted — after custom headers, so they can never be spoofed by one.
/// </summary>
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? customHeaders)
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? customHeaders,
string? signingSecret = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl)
{
@@ -159,6 +167,16 @@ namespace AutopilotMonitor.Functions.Services.Notifications
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (!string.IsNullOrEmpty(signingSecret))
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture);
request.Headers.Remove(WebhookSignatureCalculator.TimestampHeader);
request.Headers.Remove(WebhookSignatureCalculator.SignatureHeader);
request.Headers.TryAddWithoutValidation(WebhookSignatureCalculator.TimestampHeader, timestamp);
request.Headers.TryAddWithoutValidation(WebhookSignatureCalculator.SignatureHeader,
WebhookSignatureCalculator.ComputeSignature(signingSecret, timestamp, json));
}
return _http.SendAsync(request);
}
}
@@ -0,0 +1,31 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace AutopilotMonitor.Functions.Services.Notifications
{
/// <summary>
/// Computes the HMAC request signature for generic JSON webhooks (Stripe-style scheme).
/// The receiver recomputes HMACSHA256(secret, "{timestamp}.{rawBody}") and compares it
/// (constant-time) against the signature header; the timestamp binds replay protection.
/// </summary>
public static class WebhookSignatureCalculator
{
/// <summary>Header carrying the unix-seconds timestamp the signature was computed with.</summary>
public const string TimestampHeader = "X-AutopilotMonitor-Timestamp";
/// <summary>Header carrying the signature: "sha256=&lt;lowercase hex HMAC&gt;".</summary>
public const string SignatureHeader = "X-AutopilotMonitor-Signature";
/// <summary>
/// Computes "sha256=&lt;hex&gt;" over "{timestamp}.{body}" with the given secret.
/// Secret and message are UTF-8; hex is lowercase.
/// </summary>
public static string ComputeSignature(string secret, string timestamp, string body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}"));
return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
}
}
}
@@ -820,6 +820,7 @@ namespace AutopilotMonitor.Shared.Models
{
channel.Url = RedactNullable(channel.Url);
channel.CustomHeadersJson = RedactNullable(channel.CustomHeadersJson);
channel.SigningSecret = RedactNullable(channel.SigningSecret);
}
return Notifications.NotificationChannel.SerializeList(channels);
@@ -878,6 +879,7 @@ namespace AutopilotMonitor.Shared.Models
continue;
if (channel.Url == Constants.RedactedSecretPlaceholder) channel.Url = match.Url;
if (channel.CustomHeadersJson == Constants.RedactedSecretPlaceholder) channel.CustomHeadersJson = match.CustomHeadersJson;
if (channel.SigningSecret == Constants.RedactedSecretPlaceholder) channel.SigningSecret = match.SigningSecret;
}
NotificationChannelsJson = Notifications.NotificationChannel.SerializeList(incoming);
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// <summary>
/// Provider type (int form of <see cref="WebhookProviderType"/>):
/// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson.
/// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson, 30=Discord.
/// </summary>
public int ProviderType { get; set; }
@@ -56,6 +56,12 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// </summary>
public string? CustomHeadersJson { get; set; }
/// <summary>
/// HMAC key for signing outgoing requests (X-AutopilotMonitor-Signature), applied
/// only when <see cref="ProviderType"/> is GenericJson. Secret — redacted for readers.
/// </summary>
public string? SigningSecret { get; set; }
/// <summary>Master switch: a disabled channel never receives anything.</summary>
public bool Enabled { get; set; } = true;
@@ -93,6 +99,18 @@ namespace AutopilotMonitor.Shared.Models.Notifications
return WebhookHeaderParser.Parse(CustomHeadersJson);
}
/// <summary>
/// Returns the HMAC signing secret for the generic-webhook dispatcher, or null when
/// the channel is not GenericJson or no secret is configured.
/// </summary>
public string? GetSigningSecret()
{
if (ProviderType != (int)WebhookProviderType.GenericJson)
return null;
return string.IsNullOrWhiteSpace(SigningSecret) ? null : SigningSecret;
}
/// <summary>
/// Deserializes a channel list from its JSON storage form. Fail-soft: null/blank/malformed
/// input yields an empty list (dispatch then simply has no channels to send to). Entries
@@ -23,5 +23,11 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// Supports per-tenant custom request headers for API-key authentication.
/// </summary>
GenericJson = 20,
/// <summary>
/// Discord channel webhook (embed format). Webhooks cannot post buttons, so
/// openUrl actions are rendered as markdown links inside the embed description.
/// </summary>
Discord = 30,
}
}
@@ -211,6 +211,7 @@ const WEBHOOK_PROVIDERS: Record<number, string> = {
2: 'Teams (Workflow Webhook)',
10: 'Slack',
20: 'Generic JSON',
30: 'Discord',
};
function formatValue(val: unknown): string {
@@ -25,6 +25,7 @@ const PROVIDERS = [
{ 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" },
{ value: 30, label: "Discord", placeholder: "https://discord.com/api/webhooks/..." },
];
const EVENT_TOGGLES: { key: keyof NotificationChannel; label: string; hint: string }[] = [
@@ -190,12 +191,14 @@ function ChannelEditor({
value={channel.providerType}
onChange={(e) => {
const next = Number(e.target.value);
// Custom headers are generic-only; clear them when leaving the generic provider so
// a later switch back can't revive stale auth headers and persist them to a new endpoint.
// Custom headers and the signing secret are generic-only; clear them when leaving the
// generic provider so a later switch back can't revive stale secrets and persist them
// to a new endpoint.
onChange({
...channel,
providerType: next,
customHeadersJson: next === GENERIC_PROVIDER ? channel.customHeadersJson : undefined,
signingSecret: next === GENERIC_PROVIDER ? channel.signingSecret : undefined,
});
}}
className="block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors"
@@ -232,6 +235,8 @@ function ChannelEditor({
? "Create an Incoming Webhook in Slack (Apps → Incoming Webhooks → Add to channel) and paste the URL here."
: channel.providerType === GENERIC_PROVIDER
? "Any HTTPS endpoint that accepts a JSON POST. Receives a stable payload (schemaVersion + eventType, e.g. \"enrollment_succeeded\") for ticketing, automation, or an SMTP gateway like Postal."
: channel.providerType === 30
? "Create a webhook in your Discord channel (Channel settings → Integrations → Webhooks) and paste the URL here."
: "Create an Incoming Webhook in your Teams channel (Channel → Connectors → Incoming Webhook) and paste the URL here."}
</p>
<div className="flex items-center gap-2">
@@ -259,6 +264,41 @@ function ChannelEditor({
/>
)}
{/* Signing secret (generic provider only) */}
{channel.providerType === GENERIC_PROVIDER && (
<div>
<label className="block">
<span className="text-gray-700 font-medium text-sm">Signing Secret (optional)</span>
<p className="text-xs text-gray-500 mb-1">
When set, every request carries <code className="font-mono">X-AutopilotMonitor-Signature</code> (HMAC-SHA256)
so your endpoint can verify it came from Autopilot Monitor — see the docs for the verification scheme.
</p>
<div className="flex items-center gap-2">
<input
type="password"
value={channel.signingSecret ?? ""}
onChange={(e) => onChange({ ...channel, signingSecret: e.target.value || undefined })}
placeholder="16-128 characters"
autoComplete="off"
className="mt-1 block w-full px-4 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"
/>
<button
type="button"
onClick={() => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const secret = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
onChange({ ...channel, signingSecret: secret });
}}
className="mt-1 flex-shrink-0 px-3 py-2 text-sm font-medium text-sky-600 hover:text-sky-700 border border-sky-200 hover:border-sky-300 rounded-lg transition-colors"
>
Generate
</button>
</div>
</label>
</div>
)}
{/* Event subscriptions */}
<div>
<span className="text-gray-700 font-medium text-sm">Send to this channel</span>
@@ -9,11 +9,13 @@ export interface NotificationChannel {
/** Stable id (UUID, generated on creation). Rules reference channels by this id. */
id: string;
name: string;
/** 1=Teams Legacy, 2=Teams Workflow, 10=Slack, 20=Generic JSON */
/** 1=Teams Legacy, 2=Teams Workflow, 10=Slack, 20=Generic JSON, 30=Discord */
providerType: number;
url?: string;
/** JSON object string { "Header-Name": "value" }; generic provider only. */
customHeadersJson?: string;
/** HMAC key for X-AutopilotMonitor-Signature request signing; generic provider only. */
signingSecret?: string;
enabled: boolean;
notifyOnStart?: boolean;
notifyOnSuccess?: boolean;
@@ -419,7 +419,8 @@ export const SHARED_MANIFEST = {
"TeamsLegacyConnector": 1,
"TeamsWorkflowWebhook": 2,
"Slack": 10,
"GenericJson": 20
"GenericJson": 20,
"Discord": 30
},
"annotationLanes": [
"operator",
@@ -415,7 +415,8 @@
"TeamsLegacyConnector": 1,
"TeamsWorkflowWebhook": 2,
"Slack": 10,
"GenericJson": 20
"GenericJson": 20,
"Discord": 30
},
"annotationLanes": [
"operator",