diff --git a/docs/qualification/release-v6.4-telegram-redaction/README.md b/docs/qualification/release-v6.4-telegram-redaction/README.md new file mode 100644 index 000000000..d6a9d59bc --- /dev/null +++ b/docs/qualification/release-v6.4-telegram-redaction/README.md @@ -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). diff --git a/docs/qualification/release-v6.4-telegram-redaction/after.log b/docs/qualification/release-v6.4-telegram-redaction/after.log new file mode 100644 index 000000000..fd6ae4f2b --- /dev/null +++ b/docs/qualification/release-v6.4-telegram-redaction/after.log @@ -0,0 +1 @@ +ok github.com/rcourtman/pulse-go-rewrite/internal/notifications 1.142s diff --git a/docs/qualification/release-v6.4-telegram-redaction/before.log b/docs/qualification/release-v6.4-telegram-redaction/before.log new file mode 100644 index 000000000..23283aefc --- /dev/null +++ b/docs/qualification/release-v6.4-telegram-redaction/before.log @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index bb5c4ecef..670f8af00 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -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. diff --git a/internal/notifications/webhook_url_redaction.go b/internal/notifications/webhook_url_redaction.go index 019ec41f5..41e8dc609 100644 --- a/internal/notifications/webhook_url_redaction.go +++ b/internal/notifications/webhook_url_redaction.go @@ -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, "?") diff --git a/internal/notifications/webhook_url_redaction_test.go b/internal/notifications/webhook_url_redaction_test.go index 7f871ac30..b6ddfe018 100644 --- a/internal/notifications/webhook_url_redaction_test.go +++ b/internal/notifications/webhook_url_redaction_test.go @@ -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) + } +}