Fix availability summaries and webhook secret handling

This commit is contained in:
rcourtman
2026-07-24 11:56:30 +01:00
parent 3a913752de
commit f744e0700e
10 changed files with 271 additions and 68 deletions
@@ -1137,6 +1137,7 @@ describe('WebhookConfig', () => {
{ service: 'teams', expected: 'Microsoft Teams' }, { service: 'teams', expected: 'Microsoft Teams' },
{ service: 'pagerduty', expected: 'PagerDuty' }, { service: 'pagerduty', expected: 'PagerDuty' },
{ service: 'telegram', expected: 'Telegram' }, { service: 'telegram', expected: 'Telegram' },
{ service: 'gotify', expected: 'Gotify' },
{ service: 'ntfy', expected: 'ntfy' }, { service: 'ntfy', expected: 'ntfy' },
]; ];
@@ -105,6 +105,41 @@ describe('availabilitySettingsModel', () => {
}), }),
]), ]),
).toBe('1 down · 2 enabled'); ).toBe('1 down · 2 enabled');
expect(
getAvailabilityTargetsSummary([
target({
protocol: 'udp',
port: 27015,
status: {
...target(),
targetId: 'steam-server',
protocol: 'udp',
available: false,
outcome: 'indeterminate',
},
}),
]),
).toBe('1 open or filtered · 1 enabled');
expect(
getAvailabilityTargetsSummary([
target({
id: 'closed-port',
status: { ...target(), targetId: 'closed-port', available: false },
}),
target({
id: 'silent-port',
protocol: 'udp',
port: 27015,
status: {
...target(),
targetId: 'silent-port',
protocol: 'udp',
available: false,
outcome: 'indeterminate',
},
}),
]),
).toBe('1 down · 1 open or filtered · 2 enabled');
expect(getAvailabilityTargetStatusClass(target({ enabled: false }))).toBe( expect(getAvailabilityTargetStatusClass(target({ enabled: false }))).toBe(
'bg-surface-alt text-muted', 'bg-surface-alt text-muted',
); );
@@ -125,10 +125,20 @@ export function getAvailabilityTargetStatusClass(target: AvailabilityTarget): st
export function getAvailabilityTargetsSummary(targets: readonly AvailabilityTarget[]): string { export function getAvailabilityTargetsSummary(targets: readonly AvailabilityTarget[]): string {
const enabled = targets.filter((target) => target.enabled).length; const enabled = targets.filter((target) => target.enabled).length;
const indeterminate = targets.filter(
(target) => target.enabled && target.status?.outcome === 'indeterminate',
).length;
const down = targets.filter( const down = targets.filter(
(target) => target.enabled && target.status?.available === false, (target) =>
target.enabled &&
target.status?.available === false &&
target.status.outcome !== 'indeterminate',
).length; ).length;
if (targets.length === 0) return 'No availability checks configured'; if (targets.length === 0) return 'No availability checks configured';
if (down > 0 && indeterminate > 0) {
return `${down} down · ${indeterminate} open or filtered · ${enabled} enabled`;
}
if (down > 0) return `${down} down · ${enabled} enabled`; if (down > 0) return `${down} down · ${enabled} enabled`;
if (indeterminate > 0) return `${indeterminate} open or filtered · ${enabled} enabled`;
return `${enabled} enabled · ${targets.length} total`; return `${enabled} enabled · ${targets.length} total`;
} }
+4 -61
View File
@@ -707,67 +707,10 @@ func (h *NotificationHandlers) GetWebhookHistory(w http.ResponseWriter, r *http.
json.NewEncoder(w).Encode(history) json.NewEncoder(w).Encode(history)
} }
// redactSecretsFromURL masks tokens and credentials in URLs // redactSecretsFromURL is retained as the API package boundary for delivery
// history while the canonical redaction policy lives with webhook execution.
func redactSecretsFromURL(urlStr string) string { func redactSecretsFromURL(urlStr string) string {
// Redact common patterns like: return notifications.RedactWebhookURLSecrets(urlStr)
// - /bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/sendMessage → /botXXX:REDACTED/sendMessage
// - ?token=abc123 → ?token=REDACTED
// - ?apikey=abc123 → ?apikey=REDACTED
// Redact Telegram bot tokens
if idx := strings.Index(urlStr, "/bot"); idx != -1 {
// Search for next "/" after "/bot" (starting at idx+4)
if endIdx := strings.Index(urlStr[idx+4:], "/"); endIdx != -1 {
urlStr = urlStr[:idx+4] + "REDACTED" + urlStr[idx+4+endIdx:]
} else {
// No trailing slash - token extends to end of URL or query string
if qIdx := strings.Index(urlStr[idx+4:], "?"); qIdx != -1 {
urlStr = urlStr[:idx+4] + "REDACTED" + urlStr[idx+4+qIdx:]
} else {
urlStr = urlStr[:idx+4] + "REDACTED"
}
}
}
// Redact query parameters with sensitive names
if qIdx := strings.Index(urlStr, "?"); qIdx != -1 {
sensitiveParams := []string{"token", "apikey", "api_key", "key", "secret", "password"}
for _, param := range sensitiveParams {
pattern := param + "="
// Search for the pattern after the query string starts
searchStart := qIdx
for {
paramIdx := strings.Index(urlStr[searchStart:], pattern)
if paramIdx == -1 {
break
}
paramIdx += searchStart // Convert to absolute index
// Check that we're at a parameter boundary (after ? or &)
if paramIdx > 0 {
prevChar := urlStr[paramIdx-1]
if prevChar != '?' && prevChar != '&' {
// Not at a boundary - this is part of another param name
// Move past this match and continue searching
searchStart = paramIdx + len(pattern)
continue
}
}
// Valid match - redact the value
start := paramIdx + len(pattern)
end := start
for end < len(urlStr) && urlStr[end] != '&' && urlStr[end] != '#' {
end++
}
urlStr = urlStr[:start] + "REDACTED" + urlStr[end:]
// After modification, continue from after the inserted REDACTED
searchStart = start + len("REDACTED")
}
}
}
return urlStr
} }
// GetEmailProviders returns available email providers // GetEmailProviders returns available email providers
@@ -813,7 +756,7 @@ func (h *NotificationHandlers) TestWebhook(w http.ResponseWriter, r *http.Reques
log.Info(). log.Info().
Str("service", webhook.Service). Str("service", webhook.Service).
Str("url", webhook.URL). Str("url", notifications.RedactWebhookURLSecrets(webhook.URL)).
Str("name", webhook.Name). Str("name", webhook.Name).
Msg("Testing webhook") Msg("Testing webhook")
+6 -6
View File
@@ -2745,7 +2745,7 @@ func (n *NotificationManager) executeWebhookRequest(webhook WebhookConfig, paylo
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to send webhook: %w", err) return nil, fmt.Errorf("failed to send webhook: %w", redactWebhookTransportError(err))
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -2790,7 +2790,7 @@ func (n *NotificationManager) sendWebhookRequest(webhook WebhookConfig, jsonData
log.Error(). log.Error().
Err(err). Err(err).
Str("webhook", webhook.Name). Str("webhook", webhook.Name).
Str("url", webhook.URL). Str("url", RedactWebhookURLSecrets(webhook.URL)).
Msg("webhook URL validation failed at send time - possible DNS rebinding") Msg("webhook URL validation failed at send time - possible DNS rebinding")
return fmt.Errorf("webhook URL validation failed: %w", err) return fmt.Errorf("webhook URL validation failed: %w", err)
} }
@@ -2799,7 +2799,7 @@ func (n *NotificationManager) sendWebhookRequest(webhook WebhookConfig, jsonData
if !n.checkWebhookRateLimit(webhook.URL) { if !n.checkWebhookRateLimit(webhook.URL) {
log.Warn(). log.Warn().
Str("webhook", webhook.Name). Str("webhook", webhook.Name).
Str("url", webhook.URL). Str("url", RedactWebhookURLSecrets(webhook.URL)).
Msg("Webhook request dropped due to rate limiting") Msg("Webhook request dropped due to rate limiting")
return fmt.Errorf("rate limit exceeded for webhook %s", webhook.Name) return fmt.Errorf("rate limit exceeded for webhook %s", webhook.Name)
} }
@@ -3160,7 +3160,7 @@ func (n *NotificationManager) ValidateWebhookURL(webhookURL string) error {
} }
log.Debug(). log.Debug().
Str("host", host). Str("host", host).
Str("url", webhookURL). Str("url", RedactWebhookURLSecrets(webhookURL)).
Msg("localhost webhook URL allowed via allowlist") Msg("localhost webhook URL allowed via allowlist")
} }
@@ -3183,7 +3183,7 @@ func (n *NotificationManager) ValidateWebhookURL(webhookURL string) error {
if n.isIPInAllowlist(ip) { if n.isIPInAllowlist(ip) {
log.Debug(). log.Debug().
Str("ip", ip.String()). Str("ip", ip.String()).
Str("url", webhookURL). Str("url", RedactWebhookURLSecrets(webhookURL)).
Msg("webhook URL resolves to private IP in allowlist") Msg("webhook URL resolves to private IP in allowlist")
} else { } else {
return fmt.Errorf("webhook URL resolves to private IP %s - private networks are not allowed for security (configure allowlist in System Settings)", ip.String()) return fmt.Errorf("webhook URL resolves to private IP %s - private networks are not allowed for security (configure allowlist in System Settings)", ip.String())
@@ -3207,7 +3207,7 @@ func (n *NotificationManager) ValidateWebhookURL(webhookURL string) error {
// This helps prevent SSRF attacks using numeric IPs to bypass filters // This helps prevent SSRF attacks using numeric IPs to bypass filters
if u.Scheme == "https" && isNumericIP(host) { if u.Scheme == "https" && isNumericIP(host) {
log.Warn(). log.Warn().
Str("url", webhookURL). Str("url", RedactWebhookURLSecrets(webhookURL)).
Msg("webhook URL uses numeric IP with HTTPS - certificate validation may fail") Msg("webhook URL uses numeric IP with HTTPS - certificate validation may fail")
} }
+1
View File
@@ -239,6 +239,7 @@ func TestGetWebhookTemplates_KnownServices(t *testing.T) {
"slack", "slack",
"teams", "teams",
"pagerduty", "pagerduty",
"gotify",
"generic", "generic",
} }
@@ -1,6 +1,7 @@
package notifications package notifications
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -37,6 +38,45 @@ func TestEnhancedWebhook(t *testing.T) {
assert.Equal(t, "ok", resp) assert.Equal(t, "ok", resp)
} }
func TestGotifyPresetTestDelivery(t *testing.T) {
nm := NewNotificationManager("https://pulse.example")
require.NoError(t, nm.UpdateAllowedPrivateCIDRs("127.0.0.1"))
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "gotify-app-token", r.URL.Query().Get("token"))
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
var payload struct {
Message string `json:"message"`
Title string `json:"title"`
Priority int `json:"priority"`
Extras map[string]interface{} `json:"extras"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
assert.NotEmpty(t, payload.Message)
assert.NotEmpty(t, payload.Title)
assert.Positive(t, payload.Priority)
assert.Contains(t, payload.Extras, "client::display")
assert.Contains(t, payload.Extras, "pulse::alert")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":1}`))
}))
defer server.Close()
basic := WebhookConfig{
Name: "Operations Gotify",
URL: server.URL + "/message?token=gotify-app-token",
Method: http.MethodPost,
Enabled: true,
Service: "gotify",
}
status, response, err := nm.TestEnhancedWebhook(BuildEnhancedWebhookTestConfig(basic, "gotify"))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, status)
assert.JSONEq(t, `{"id":1}`, response)
}
func TestShouldSendWebhook(t *testing.T) { func TestShouldSendWebhook(t *testing.T) {
nm := &NotificationManager{} nm := &NotificationManager{}
@@ -0,0 +1,72 @@
package notifications
import (
"errors"
"net/url"
"strings"
)
// RedactWebhookURLSecrets masks credentials commonly embedded in webhook URLs
// while preserving the URL shape needed for operator diagnostics.
func RedactWebhookURLSecrets(urlString string) string {
// Telegram bot credentials are path components rather than query values.
if idx := strings.Index(urlString, "/bot"); idx != -1 {
if endIdx := strings.Index(urlString[idx+4:], "/"); endIdx != -1 {
urlString = urlString[:idx+4] + "REDACTED" + urlString[idx+4+endIdx:]
} else if queryIdx := strings.Index(urlString[idx+4:], "?"); queryIdx != -1 {
urlString = urlString[:idx+4] + "REDACTED" + urlString[idx+4+queryIdx:]
} else {
urlString = urlString[:idx+4] + "REDACTED"
}
}
queryIndex := strings.Index(urlString, "?")
if queryIndex == -1 {
return urlString
}
for _, parameter := range []string{"token", "apikey", "api_key", "key", "secret", "password"} {
pattern := parameter + "="
searchStart := queryIndex
for {
parameterIndex := strings.Index(urlString[searchStart:], pattern)
if parameterIndex == -1 {
break
}
parameterIndex += searchStart
if parameterIndex > 0 {
previous := urlString[parameterIndex-1]
if previous != '?' && previous != '&' {
searchStart = parameterIndex + len(pattern)
continue
}
}
valueStart := parameterIndex + len(pattern)
valueEnd := valueStart
for valueEnd < len(urlString) && urlString[valueEnd] != '&' && urlString[valueEnd] != '#' {
valueEnd++
}
urlString = urlString[:valueStart] + "REDACTED" + urlString[valueEnd:]
searchStart = valueStart + len("REDACTED")
}
}
return urlString
}
func redactWebhookTransportError(err error) error {
if err == nil {
return nil
}
var urlError *url.Error
if !errors.As(err, &urlError) {
return err
}
redacted := *urlError
redacted.URL = RedactWebhookURLSecrets(urlError.URL)
return &redacted
}
@@ -0,0 +1,56 @@
package notifications
import (
"errors"
"net/url"
"strings"
"testing"
)
func TestRedactWebhookURLSecrets(t *testing.T) {
tests := map[string]struct {
input string
want string
}{
"gotify token": {
input: "https://gotify.example/message?token=gotify-secret",
want: "https://gotify.example/message?token=REDACTED",
},
"telegram path and query": {
input: "https://api.telegram.org/bot123:secret/send?token=query-secret",
want: "https://api.telegram.org/botREDACTED/send?token=REDACTED",
},
"unrelated parameters": {
input: "https://example.com/hook?extra_token=visible&channel=ops",
want: "https://example.com/hook?extra_token=visible&channel=ops",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
if got := RedactWebhookURLSecrets(test.input); got != test.want {
t.Fatalf("RedactWebhookURLSecrets() = %q, want %q", got, test.want)
}
})
}
}
func TestRedactWebhookTransportErrorPreservesBehaviorWithoutToken(t *testing.T) {
cause := errors.New("connection refused")
original := &url.Error{
Op: "Post",
URL: "https://gotify.example/message?token=gotify-secret",
Err: cause,
}
redacted := redactWebhookTransportError(original)
if strings.Contains(redacted.Error(), "gotify-secret") {
t.Fatalf("redacted transport error exposed token: %v", redacted)
}
if !strings.Contains(redacted.Error(), "token=REDACTED") {
t.Fatalf("redacted transport error omitted diagnostic URL shape: %v", redacted)
}
if !errors.Is(redacted, cause) {
t.Fatal("redacted transport error no longer unwraps to its original cause")
}
}
+45
View File
@@ -4,6 +4,7 @@
package migration package migration
import ( import (
"bytes"
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
@@ -12,6 +13,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/crypto" "github.com/rcourtman/pulse-go-rewrite/internal/crypto"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -256,6 +258,49 @@ func TestV5DataDir_EncryptedConfigRoundtrip(t *testing.T) {
assert.Len(t, nodesCfg3.PVEInstances, 4) assert.Len(t, nodesCfg3.PVEInstances, 4)
} }
func TestV5GotifyWebhookSurvivesV6LoadAndRestart(t *testing.T) {
dataDir, _, _, _, _ := buildV5DataDir(t)
const token = "v5-gotify-app-token"
// v5.1.36 stored this exact WebhookConfig JSON shape in webhooks.enc with
// the installation's AES-GCM key. Build that fixture directly so this test
// does not accidentally rely on the current SaveWebhooks implementation.
v5Webhooks := []notifications.WebhookConfig{{
ID: "gotify-v5",
Name: "Operations Gotify",
URL: "https://gotify.example/message?token=" + token,
Method: "POST",
Headers: map[string]string{"Content-Type": "application/json"},
Enabled: true,
Service: "gotify",
}}
plaintext, err := json.Marshal(v5Webhooks)
require.NoError(t, err)
cryptoManager, err := crypto.NewCryptoManagerAt(dataDir)
require.NoError(t, err)
encrypted, err := cryptoManager.Encrypt(plaintext)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "webhooks.enc"), encrypted, 0o600))
v6Persistence := config.NewConfigPersistence(dataDir)
loaded, err := v6Persistence.LoadWebhooks()
require.NoError(t, err)
require.Len(t, loaded, 1)
assert.Equal(t, v5Webhooks[0], loaded[0])
// A fresh persistence instance models the post-upgrade process restart.
restarted := config.NewConfigPersistence(dataDir)
loadedAfterRestart, err := restarted.LoadWebhooks()
require.NoError(t, err)
require.Len(t, loadedAfterRestart, 1)
assert.Equal(t, v5Webhooks[0], loadedAfterRestart[0])
stored, err := os.ReadFile(filepath.Join(dataDir, "webhooks.enc"))
require.NoError(t, err)
assert.False(t, bytes.Contains(stored, []byte(token)), "Gotify token must remain encrypted at rest")
}
// TestV5DataDir_EmptyDataDir verifies that v6 starts cleanly against an // TestV5DataDir_EmptyDataDir verifies that v6 starts cleanly against an
// empty data directory (brand new installation). // empty data directory (brand new installation).
func TestV5DataDir_EmptyDataDir(t *testing.T) { func TestV5DataDir_EmptyDataDir(t *testing.T) {