fix(notifications): retain context around redacted delivery URLs

PR #1959 exposed that the URL-only redactor was called with a complete delivery error, causing safe but unhelpful replacement of the whole diagnostic. Redact embedded webhook URLs separately so credentials stay masked and non-secret failure context remains available; malformed URLs continue to fail closed.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-07 18:07:09 +01:00
parent 3c77ecb338
commit a2dbb27a68
4 changed files with 83 additions and 2 deletions
@@ -700,6 +700,10 @@ actual rate-limit log output. These queue-free tests establish local diagnostic
redaction, not destination receipt, installed recovery or release qualification.
No claim is made that arbitrary custom path/query secrets are recognised.
Delivery-log errors use `RedactWebhookDiagnosticSecrets` so URLs embedded in
otherwise useful error text receive the same masking without discarding the
surrounding status context. Malformed embedded URLs still fail closed.
### Slack webhook diagnostic path confidentiality
The same helper masks paths on the exact `hooks.slack.com` and
+1 -1
View File
@@ -1076,7 +1076,7 @@ func (h *NotificationHandlers) GetDeliveryLog(w http.ResponseWriter, r *http.Req
// can embed credentials.
for i := range entries {
if entries[i].ErrorMessage != "" {
entries[i].ErrorMessage = notifications.RedactWebhookURLSecrets(entries[i].ErrorMessage)
entries[i].ErrorMessage = notifications.RedactWebhookDiagnosticSecrets(entries[i].ErrorMessage)
}
}
@@ -6,6 +6,8 @@ import (
"strings"
)
const invalidWebhookURLDiagnostic = "[invalid webhook URL]"
// RedactWebhookURLSecrets masks credentials commonly embedded in webhook URLs
// while preserving the URL shape needed for operator diagnostics.
func RedactWebhookURLSecrets(urlString string) string {
@@ -14,7 +16,7 @@ func RedactWebhookURLSecrets(urlString string) string {
// URLs rather than returning unparsed credentials to diagnostic callers.
parsed, err := url.Parse(urlString)
if err != nil {
return "[invalid webhook URL]"
return invalidWebhookURLDiagnostic
}
if parsed.User != nil {
parsed.User = url.User("REDACTED")
@@ -91,6 +93,49 @@ func RedactWebhookURLSecrets(urlString string) string {
return urlString
}
// RedactWebhookDiagnosticSecrets masks webhook URLs embedded in diagnostic
// text while retaining the non-secret context around them. A malformed URL
// still fails closed rather than returning potentially sensitive text.
func RedactWebhookDiagnosticSecrets(message string) string {
lowerMessage := strings.ToLower(message)
cursor := 0
foundURL := false
var redacted strings.Builder
for cursor < len(message) {
httpOffset := strings.Index(lowerMessage[cursor:], "http://")
httpsOffset := strings.Index(lowerMessage[cursor:], "https://")
offset := httpOffset
if offset == -1 || (httpsOffset != -1 && httpsOffset < offset) {
offset = httpsOffset
}
if offset == -1 {
break
}
start := cursor + offset
end := len(message)
if whitespace := strings.IndexAny(message[start:], " \t\r\n"); whitespace != -1 {
end = start + whitespace
}
redactedURL := RedactWebhookURLSecrets(message[start:end])
if redactedURL == invalidWebhookURLDiagnostic {
return invalidWebhookURLDiagnostic
}
redacted.WriteString(message[cursor:start])
redacted.WriteString(redactedURL)
cursor = end
foundURL = true
}
if !foundURL {
return RedactWebhookURLSecrets(message)
}
redacted.WriteString(message[cursor:])
return redacted.String()
}
func redactWebhookTransportError(err error) error {
if err == nil {
return nil
@@ -72,6 +72,38 @@ func TestRedactWebhookURLSecrets(t *testing.T) {
}
}
func TestRedactWebhookDiagnosticSecrets(t *testing.T) {
tests := map[string]struct {
input string
want string
}{
"embedded query credential": {
input: "post https://hooks.example.test/notify?token=supersecret returned 401",
want: "post https://hooks.example.test/notify?token=REDACTED returned 401",
},
"embedded userinfo": {
input: "request to https://hook-user:hook-password@example.test/hook failed",
want: "request to https://REDACTED@example.test/hook failed",
},
"plain diagnostic": {
input: "connection refused",
want: "connection refused",
},
"malformed embedded URL": {
input: "post https://hook-user:hook-password@example.test/%zz failed",
want: invalidWebhookURLDiagnostic,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
if got := RedactWebhookDiagnosticSecrets(test.input); got != test.want {
t.Fatalf("RedactWebhookDiagnosticSecrets() = %q, want %q", got, test.want)
}
})
}
}
func TestRedactWebhookTransportErrorPreservesBehaviorWithoutToken(t *testing.T) {
cause := errors.New("connection refused")
original := &url.Error{