fix(notifications): backport Telegram decoded-path credential masking

Backport reviewed Core 192a72e05c with candidate-specific before/after evidence. Change-source: pulse-maintainer

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-07 18:32:34 +01:00
parent dcf7e49961
commit 71c721ccee
6 changed files with 103 additions and 8 deletions
@@ -0,0 +1,25 @@
# Telegram diagnostic credential backport — 7 September 2026
Release Train rules 2/4 permit this named security repair on base
`dcf7e499613679c941abe49800f1294b5714fde1`. Backports only code, tests and
notification contract from reviewed Core `192a72e05c06b2a2ff3a04bb2ef53ded78950e47`.
Existing Discord masking and diagnostic context handling are preserved.
Fresh independent primary evidence: https://core.telegram.org/bots/api#making-requests
identifies bot authentication tokens in request paths and supports local API
servers. This is diagnostic containment, not a new product surface.
Tests imported alone failed (before.log, exit 1):
`go test ./internal/notifications -run '^Test(RedactWebhookURLSecrets|TelegramWebhookDiagnosticsRedactPath)$' -count=1`.
Synthetic escaped path credentials survived helper and transport diagnostics;
non-credential bot hostnames/query URLs also lost diagnostic context.
Repair validation (after.log):
`go test -race ./internal/notifications -run '^Test(RedactWebhook|WebhookRateLimitLogsRedactURLSecrets|SlackWebhookDiagnosticsRedactPath|DiscordWebhookDiagnosticsRedactPath|TelegramWebhookDiagnosticsRedactPath)' -count=20`.
No full qualification, installed change, customer exposure assertion, historical
stored-data cleanup or recipient receipt proof. Protected integration and all
eight enforced checks remain required. Existing adverse latency and excluded
crash evidence are not cleared. Delivery owns release maturity and publication.
Result: exit 0, all selected tests passed with race detection (20 repeats).
@@ -0,0 +1 @@
ok github.com/rcourtman/pulse-go-rewrite/internal/notifications 1.142s
@@ -0,0 +1,18 @@
--- FAIL: TestRedactWebhookURLSecrets (0.00s)
--- FAIL: TestRedactWebhookURLSecrets/telegram_escaped_prefix (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "https://api.telegram.org/%62ot123:telegram-secret/sendMessage", want "https://api.telegram.org/botREDACTED/sendMessage"
--- FAIL: TestRedactWebhookURLSecrets/telegram_local_escaped_prefix (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "http://localhost:8081/%62ot123:telegram-secret/sendMessage", want "http://localhost:8081/botREDACTED/sendMessage"
--- FAIL: TestRedactWebhookURLSecrets/telegram_no_method_with_URL_query (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "https://api.telegram.org/botREDACTED//example.org/status", want "https://api.telegram.org/botREDACTED?next=https://example.org/status"
--- FAIL: TestRedactWebhookURLSecrets/bot_query_is_not_a_path (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "https://example.org/hook?next=https://botREDACTED/status", want "https://example.org/hook?next=https://bot.example.org/status"
--- FAIL: TestRedactWebhookURLSecrets/telegram_fragment (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "https://api.telegram.org/botREDACTED", want "https://api.telegram.org/botREDACTED#diagnostic"
--- FAIL: TestRedactWebhookURLSecrets/bot_hostname_is_not_a_path (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "https://botREDACTED/hook?channel=ops", want "https://bot.example.org/hook?channel=ops"
--- FAIL: TestTelegramWebhookDiagnosticsRedactPath (0.00s)
webhook_url_redaction_test.go:228: unsafe transport diagnostic: Post "https://api.telegram.org/%62ot123:telegram-secret/sendMessage": connection refused
FAIL
FAIL github.com/rcourtman/pulse-go-rewrite/internal/notifications 0.005s
FAIL
@@ -621,3 +621,19 @@ identifies the secure webhook token and token-authorised operations. Focused
synthetic regressions cover helper output, transport diagnostics and actual
rate-limit logs. This is not evidence of customer exposure, recipient receipt,
release qualification, or protection of arbitrary custom-host credentials.
### Telegram diagnostic path parsing
Telegram bot-path masking operates on the parsed, decoded URL path and clears
RawPath after replacement. This covers percent-encoded bot prefixes without
mistaking a hostname or a URL inside a query for a bot path. Method suffixes,
query diagnostics and fragments remain intact; configured destinations and
transport-error causes are unchanged. Host-independent masking is retained for
local API servers, which are supported by the
[Telegram API documentation](https://core.telegram.org/bots/api#making-requests).
Focused regression tests cover escaped prefixes/tokens, local servers, missing
method suffixes, query URLs, fragments, transport errors and rate-limit logs.
This is diagnostic containment, not evidence of customer exposure or recipient
delivery. Arbitrary path secrets and unrecognised query credentials remain
outside this bounded change.
@@ -46,15 +46,18 @@ 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"
// Telegram also supports local API servers, so retain host-independent
// masking, but inspect only the decoded path. Searching the whole URL
// misses escaped prefixes and can mistake hostnames or query URLs for
// bot credentials. Clear RawPath to prevent escaped secrets resurfacing.
if idx := strings.Index(parsed.Path, "/bot"); idx != -1 {
end := len(parsed.Path)
if suffix := strings.Index(parsed.Path[idx+4:], "/"); suffix != -1 {
end = idx + 4 + suffix
}
parsed.Path = parsed.Path[:idx+4] + "REDACTED" + parsed.Path[end:]
parsed.RawPath = ""
urlString = parsed.String()
}
queryIndex := strings.Index(urlString, "?")
@@ -53,6 +53,13 @@ func TestRedactWebhookURLSecrets(t *testing.T) {
input: "https://gotify.example/message?token=gotify-secret",
want: "https://gotify.example/message?token=REDACTED",
},
"telegram escaped prefix": {input: "https://api.telegram.org/%62ot123:telegram-secret/sendMessage", want: "https://api.telegram.org/botREDACTED/sendMessage"},
"telegram escaped token": {input: "https://api.telegram.org/bot123:telegram%2Dsecret/sendMessage", want: "https://api.telegram.org/botREDACTED/sendMessage"},
"telegram local escaped prefix": {input: "http://localhost:8081/%62ot123:telegram-secret/sendMessage", want: "http://localhost:8081/botREDACTED/sendMessage"},
"telegram no method with URL query": {input: "https://api.telegram.org/bot123:telegram-secret?next=https://example.org/status", want: "https://api.telegram.org/botREDACTED?next=https://example.org/status"},
"telegram fragment": {input: "https://api.telegram.org/bot123:telegram-secret#diagnostic", want: "https://api.telegram.org/botREDACTED#diagnostic"},
"bot hostname is not a path": {input: "https://bot.example.org/hook?channel=ops", want: "https://bot.example.org/hook?channel=ops"},
"bot query is not a path": {input: "https://example.org/hook?next=https://bot.example.org/status", want: "https://example.org/hook?next=https://bot.example.org/status"},
"telegram path and query": {
input: "https://api.telegram.org/bot123:secret/send?token=query-secret",
want: "https://api.telegram.org/botREDACTED/send?token=REDACTED",
@@ -211,3 +218,28 @@ func TestDiscordWebhookDiagnosticsRedactPath(t *testing.T) {
t.Fatalf("unsafe rate-limit diagnostic: %s", out)
}
}
func TestTelegramWebhookDiagnosticsRedactPath(t *testing.T) {
const webhookURL = "https://api.telegram.org/%62ot123:telegram-secret/sendMessage"
cause := errors.New("connection refused")
original := &url.Error{Op: "Post", URL: webhookURL, Err: cause}
redacted := redactWebhookTransportError(original)
if strings.Contains(redacted.Error(), "telegram-secret") || !strings.Contains(redacted.Error(), "/botREDACTED/sendMessage") {
t.Fatalf("unsafe transport diagnostic: %v", redacted)
}
if original.URL != webhookURL || !errors.Is(redacted, cause) {
t.Fatal("transport error identity or cause changed")
}
var captured bytes.Buffer
logger := log.Logger
log.Logger = zerolog.New(&captured)
t.Cleanup(func() { log.Logger = logger })
nm := &NotificationManager{webhookRateLimits: make(map[string]*webhookRateLimit)}
for range WebhookRateLimitMax + 2 {
nm.checkWebhookRateLimit(webhookURL)
}
out := captured.String()
if !strings.Contains(out, "rate limit exceeded") || !strings.Contains(out, "/botREDACTED/sendMessage") || strings.Contains(out, "telegram-secret") {
t.Fatalf("unsafe rate-limit diagnostic: %s", out)
}
}