Skip to content

Commit c11608d

Browse files
Encrypt webhook URLs with DPAPI via Windows Credential Manager
Moves Teams and Slack webhook URLs from plaintext settings.json/preferences.json to Windows Credential Manager (DPAPI-encrypted), matching the existing pattern used for SMTP passwords and SQL Server credentials. Includes automatic migration: on first settings load, any plaintext URLs are moved to Credential Manager and removed from the JSON file. Closes #848 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f9be94c commit c11608d

4 files changed

Lines changed: 154 additions & 12 deletions

File tree

Dashboard/Services/WebhookAlertService.cs

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ namespace PerformanceMonitorDashboard.Services
2424
public class WebhookAlertService
2525
{
2626
private const string EditionName = "Performance Monitor Dashboard";
27+
private const string TeamsWebhookCredentialKey = "TeamsWebhook";
28+
private const string SlackWebhookCredentialKey = "SlackWebhook";
2729
private static readonly JsonSerializerOptions s_jsonOptions = new() { PropertyNamingPolicy = null };
30+
private static readonly CredentialService s_credentialService = new();
2831

2932
private readonly UserPreferencesService _preferencesService;
3033
private readonly ConcurrentDictionary<string, DateTime> _cooldowns = new();
@@ -42,6 +45,50 @@ public WebhookAlertService(UserPreferencesService preferencesService)
4245
Current = this;
4346
}
4447

48+
/// <summary>
49+
/// Gets a webhook URL from Windows Credential Manager.
50+
/// </summary>
51+
public static string GetWebhookUrl(string credentialKey)
52+
{
53+
try
54+
{
55+
var cred = s_credentialService.GetCredential(credentialKey);
56+
return cred?.Password ?? "";
57+
}
58+
catch (Exception ex)
59+
{
60+
Logger.Error($"Failed to retrieve webhook URL for {credentialKey}: {ex.Message}");
61+
return "";
62+
}
63+
}
64+
65+
/// <summary>
66+
/// Saves a webhook URL to Windows Credential Manager.
67+
/// </summary>
68+
public static void SaveWebhookUrl(string credentialKey, string url)
69+
{
70+
try
71+
{
72+
if (string.IsNullOrWhiteSpace(url))
73+
{
74+
s_credentialService.DeleteCredential(credentialKey);
75+
}
76+
else
77+
{
78+
s_credentialService.SaveCredential(credentialKey, "webhook", url);
79+
}
80+
}
81+
catch (Exception ex)
82+
{
83+
Logger.Error($"Failed to save webhook URL for {credentialKey}: {ex.Message}");
84+
}
85+
}
86+
87+
public static string GetTeamsWebhookUrl() => GetWebhookUrl(TeamsWebhookCredentialKey);
88+
public static string GetSlackWebhookUrl() => GetWebhookUrl(SlackWebhookCredentialKey);
89+
public static void SaveTeamsWebhookUrl(string url) => SaveWebhookUrl(TeamsWebhookCredentialKey, url);
90+
public static void SaveSlackWebhookUrl(string url) => SaveWebhookUrl(SlackWebhookCredentialKey, url);
91+
4592
/// <summary>
4693
/// Sends webhook alerts to all configured channels (Teams and/or Slack).
4794
/// Respects the email cooldown setting for throttling. Never throws.
@@ -67,12 +114,14 @@ public async Task<bool> TrySendWebhookAlertsAsync(
67114

68115
bool sent = false;
69116

70-
if (prefs.TeamsWebhookEnabled && !string.IsNullOrWhiteSpace(prefs.TeamsWebhookUrl))
117+
var teamsUrl = GetTeamsWebhookUrl();
118+
if (prefs.TeamsWebhookEnabled && !string.IsNullOrWhiteSpace(teamsUrl))
71119
{
72120
sent |= await TrySendTeamsAlertAsync(prefs, metricName, serverName, currentValue, thresholdValue, context);
73121
}
74122

75-
if (prefs.SlackWebhookEnabled && !string.IsNullOrWhiteSpace(prefs.SlackWebhookUrl))
123+
var slackUrl = GetSlackWebhookUrl();
124+
if (prefs.SlackWebhookEnabled && !string.IsNullOrWhiteSpace(slackUrl))
76125
{
77126
sent |= await TrySendSlackAlertAsync(prefs, metricName, serverName, currentValue, thresholdValue, context);
78127
}
@@ -148,7 +197,7 @@ private async Task<bool> TrySendTeamsAlertAsync(
148197
try
149198
{
150199
var payload = BuildTeamsPayload(metricName, serverName, currentValue, thresholdValue, context: context);
151-
var error = await PostWebhookAsync(prefs.TeamsWebhookUrl, payload, prefs.TeamsProxyAddress);
200+
var error = await PostWebhookAsync(GetTeamsWebhookUrl(), payload, prefs.TeamsProxyAddress);
152201

153202
if (error != null)
154203
{
@@ -268,7 +317,7 @@ private async Task<bool> TrySendSlackAlertAsync(
268317
try
269318
{
270319
var payload = BuildSlackPayload(metricName, serverName, currentValue, thresholdValue, context: context);
271-
var error = await PostWebhookAsync(prefs.SlackWebhookUrl, payload, prefs.SlackProxyAddress);
320+
var error = await PostWebhookAsync(GetSlackWebhookUrl(), payload, prefs.SlackProxyAddress);
272321

273322
if (error != null)
274323
{

Dashboard/SettingsWindow.xaml.cs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,27 @@ private void LoadSettings()
210210

211211
// Webhook settings (Teams / Slack)
212212
TeamsWebhookEnabledCheckBox.IsChecked = prefs.TeamsWebhookEnabled;
213-
TeamsWebhookUrlTextBox.Text = prefs.TeamsWebhookUrl;
214213
TeamsProxyAddressTextBox.Text = prefs.TeamsProxyAddress;
215214
SlackWebhookEnabledCheckBox.IsChecked = prefs.SlackWebhookEnabled;
216-
SlackWebhookUrlTextBox.Text = prefs.SlackWebhookUrl;
217215
SlackProxyAddressTextBox.Text = prefs.SlackProxyAddress;
216+
217+
/* Migrate legacy plaintext webhook URLs to Credential Manager */
218+
if (!string.IsNullOrWhiteSpace(prefs.TeamsWebhookUrl))
219+
{
220+
WebhookAlertService.SaveTeamsWebhookUrl(prefs.TeamsWebhookUrl);
221+
prefs.TeamsWebhookUrl = "";
222+
_preferencesService.SavePreferences(prefs);
223+
}
224+
if (!string.IsNullOrWhiteSpace(prefs.SlackWebhookUrl))
225+
{
226+
WebhookAlertService.SaveSlackWebhookUrl(prefs.SlackWebhookUrl);
227+
prefs.SlackWebhookUrl = "";
228+
_preferencesService.SavePreferences(prefs);
229+
}
230+
231+
/* Load webhook URLs from Credential Manager */
232+
TeamsWebhookUrlTextBox.Text = WebhookAlertService.GetTeamsWebhookUrl();
233+
SlackWebhookUrlTextBox.Text = WebhookAlertService.GetSlackWebhookUrl();
218234
UpdateTeamsControlStates();
219235
UpdateSlackControlStates();
220236

@@ -705,12 +721,16 @@ private async void OkButton_Click(object sender, RoutedEventArgs e)
705721

706722
// Save webhook settings (Teams / Slack)
707723
prefs.TeamsWebhookEnabled = TeamsWebhookEnabledCheckBox.IsChecked == true;
708-
prefs.TeamsWebhookUrl = TeamsWebhookUrlTextBox.Text?.Trim() ?? "";
724+
prefs.TeamsWebhookUrl = ""; /* URLs stored in Credential Manager, not preferences */
709725
prefs.TeamsProxyAddress = TeamsProxyAddressTextBox.Text?.Trim() ?? "";
710726
prefs.SlackWebhookEnabled = SlackWebhookEnabledCheckBox.IsChecked == true;
711-
prefs.SlackWebhookUrl = SlackWebhookUrlTextBox.Text?.Trim() ?? "";
727+
prefs.SlackWebhookUrl = ""; /* URLs stored in Credential Manager, not preferences */
712728
prefs.SlackProxyAddress = SlackProxyAddressTextBox.Text?.Trim() ?? "";
713729

730+
/* Save webhook URLs to Credential Manager */
731+
WebhookAlertService.SaveTeamsWebhookUrl(TeamsWebhookUrlTextBox.Text?.Trim() ?? "");
732+
WebhookAlertService.SaveSlackWebhookUrl(SlackWebhookUrlTextBox.Text?.Trim() ?? "");
733+
714734
// Save MCP server settings
715735
bool mcpWasEnabled = prefs.McpEnabled;
716736
prefs.McpEnabled = McpEnabledCheckBox.IsChecked == true;

Lite/App.xaml.cs

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,50 @@ private static string GetDefaultCsvSeparator()
128128
public static string SlackWebhookUrl { get; set; } = "";
129129
public static string SlackProxyAddress { get; set; } = "";
130130

131+
private const string TeamsWebhookCredentialKey = "TeamsWebhook";
132+
private const string SlackWebhookCredentialKey = "SlackWebhook";
133+
134+
/// <summary>
135+
/// Gets a webhook URL from Windows Credential Manager.
136+
/// </summary>
137+
public static string GetWebhookUrl(string credentialKey)
138+
{
139+
try
140+
{
141+
var credService = new Services.CredentialService();
142+
var cred = credService.GetCredential(credentialKey);
143+
return cred?.Password ?? "";
144+
}
145+
catch (Exception ex)
146+
{
147+
AppLogger.Error("App", $"Failed to retrieve webhook URL for {credentialKey}: {ex.Message}");
148+
return "";
149+
}
150+
}
151+
152+
/// <summary>
153+
/// Saves a webhook URL to Windows Credential Manager.
154+
/// </summary>
155+
public static void SaveWebhookUrl(string credentialKey, string url)
156+
{
157+
try
158+
{
159+
var credService = new Services.CredentialService();
160+
if (string.IsNullOrWhiteSpace(url))
161+
{
162+
credService.DeleteCredential(credentialKey);
163+
}
164+
else
165+
{
166+
credService.SaveCredential(credentialKey, "webhook", url);
167+
}
168+
}
169+
catch (Exception ex)
170+
{
171+
AppLogger.Error("App", $"Failed to save webhook URL for {credentialKey}: {ex.Message}");
172+
}
173+
}
174+
131175
/* SMTP email alert settings */
132176
public static bool SmtpEnabled { get; set; } = false;
133177
public static string SmtpServer { get; set; } = "";
@@ -356,14 +400,34 @@ public static void LoadAlertSettings()
356400

357401
/* Teams webhook settings */
358402
if (root.TryGetProperty("teams_webhook_enabled", out v)) TeamsWebhookEnabled = v.GetBoolean();
359-
if (root.TryGetProperty("teams_webhook_url", out v)) TeamsWebhookUrl = v.GetString() ?? "";
360403
if (root.TryGetProperty("teams_proxy_address", out v)) TeamsProxyAddress = v.GetString() ?? "";
361404

362405
/* Slack webhook settings */
363406
if (root.TryGetProperty("slack_webhook_enabled", out v)) SlackWebhookEnabled = v.GetBoolean();
364-
if (root.TryGetProperty("slack_webhook_url", out v)) SlackWebhookUrl = v.GetString() ?? "";
365407
if (root.TryGetProperty("slack_proxy_address", out v)) SlackProxyAddress = v.GetString() ?? "";
366408

409+
/* Migrate webhook URLs from plaintext settings.json to Credential Manager */
410+
if (root.TryGetProperty("teams_webhook_url", out v))
411+
{
412+
var legacyUrl = v.GetString() ?? "";
413+
if (!string.IsNullOrWhiteSpace(legacyUrl))
414+
{
415+
SaveWebhookUrl(TeamsWebhookCredentialKey, legacyUrl);
416+
}
417+
}
418+
if (root.TryGetProperty("slack_webhook_url", out v))
419+
{
420+
var legacyUrl = v.GetString() ?? "";
421+
if (!string.IsNullOrWhiteSpace(legacyUrl))
422+
{
423+
SaveWebhookUrl(SlackWebhookCredentialKey, legacyUrl);
424+
}
425+
}
426+
427+
/* Load webhook URLs from Credential Manager */
428+
TeamsWebhookUrl = GetWebhookUrl(TeamsWebhookCredentialKey);
429+
SlackWebhookUrl = GetWebhookUrl(SlackWebhookCredentialKey);
430+
367431
/* SMTP settings */
368432
if (root.TryGetProperty("smtp_enabled", out v)) SmtpEnabled = v.GetBoolean();
369433
if (root.TryGetProperty("smtp_server", out v)) SmtpServer = v.GetString() ?? "";

Lite/Windows/SettingsWindow.xaml.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,10 @@ private void SaveWebhookSettings()
983983
App.SlackWebhookUrl = SlackWebhookUrlBox.Text?.Trim() ?? "";
984984
App.SlackProxyAddress = SlackProxyAddressBox.Text?.Trim() ?? "";
985985

986+
/* Save webhook URLs to Credential Manager instead of settings.json */
987+
App.SaveWebhookUrl("TeamsWebhook", App.TeamsWebhookUrl);
988+
App.SaveWebhookUrl("SlackWebhook", App.SlackWebhookUrl);
989+
986990
var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json");
987991
try
988992
{
@@ -998,12 +1002,17 @@ private void SaveWebhookSettings()
9981002
}
9991003

10001004
root["teams_webhook_enabled"] = App.TeamsWebhookEnabled;
1001-
root["teams_webhook_url"] = App.TeamsWebhookUrl;
10021005
root["teams_proxy_address"] = App.TeamsProxyAddress;
10031006
root["slack_webhook_enabled"] = App.SlackWebhookEnabled;
1004-
root["slack_webhook_url"] = App.SlackWebhookUrl;
10051007
root["slack_proxy_address"] = App.SlackProxyAddress;
10061008

1009+
/* Remove legacy plaintext webhook URLs from settings.json */
1010+
if (root is JsonObject obj)
1011+
{
1012+
obj.Remove("teams_webhook_url");
1013+
obj.Remove("slack_webhook_url");
1014+
}
1015+
10071016
var options = new JsonSerializerOptions { WriteIndented = true };
10081017
File.WriteAllText(settingsPath, root.ToJsonString(options));
10091018
}

0 commit comments

Comments
 (0)