mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user