Skip to content

Commit f5691cc

Browse files
committed
security: validate Telegram fallback URLs to prevent token leak
- Add validateFallbackURL helper in internal/telegram/network.go. - Allow only HTTPS telegram.org hosts or loopback addresses. - Change NewFallbackTransport and Bot.SetFallbackURLs to return errors. - Update cmd/odek/telegram.go caller to fail closed on invalid fallbacks. - Update network/bot tests and add validation coverage. - Document allowed fallback URL schemes in docs/TELEGRAM.md. - Remove resolved sec_findings.md entry. Full suite: go test ./... -count=1 passes.
1 parent 8719bcd commit f5691cc

6 files changed

Lines changed: 220 additions & 48 deletions

File tree

cmd/odek/telegram.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ func telegramCmd(args []string) error {
187187

188188
// 4c. Configure fallback Telegram API endpoints if provided.
189189
if len(cfg.FallbackURLs) > 0 {
190-
bot.SetFallbackURLs(cfg.FallbackURLs)
190+
if err := bot.SetFallbackURLs(cfg.FallbackURLs); err != nil {
191+
return fmt.Errorf("telegram: invalid fallback URL: %w", err)
192+
}
191193
}
192194

193195
// 4d. Configure daily token budget (0 = unlimited, the default).

docs/TELEGRAM.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,14 @@ See [Error Handling & Retry](#error-handling--retry) above for retry strategy, `
8383

8484
### Fallback URLs
8585

86-
`SetFallbackURLs` configures alternate Telegram API endpoints. If the primary endpoint is unreachable, the bot falls through to the next URL in the list. This is useful for regions where `api.telegram.org` may be blocked.
86+
`SetFallbackURLs` configures alternate Telegram API endpoints. If the primary endpoint is unreachable, the bot falls through to the next URL in the list. This is useful for regions where `api.telegram.org` may be blocked or for pointing at a local [Telegram Bot API server](https://core.telegram.org/bots/api#using-a-local-bot-api-server).
87+
88+
Fallback URLs are validated on startup. Only the following are accepted:
89+
90+
- HTTPS hosts under `telegram.org` (e.g. `https://api.telegram.org`, `https://fallback.api.telegram.org`).
91+
- Loopback addresses for local Bot API servers (e.g. `http://127.0.0.1:8081`, `http://localhost:8081`, `http://[::1]:8081`).
92+
93+
Non-HTTPS, non-loopback, or non-Telegram URLs are rejected to prevent the bot token from leaking to third parties, because the fallback transport rewrites the request host while preserving the original path (`/bot<token>/<method>`).
8794

8895
### Daily Token Budget
8996

internal/telegram/bot.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -651,12 +651,20 @@ func (b *Bot) SetMyCommands(commands []BotCommand) error {
651651
// "https://api.telegram.org" (without the /bot<token> suffix). The fallback
652652
// transport rewrites the host on each request, keeping the original path
653653
// (which includes the token).
654-
func (b *Bot) SetFallbackURLs(urls []string) {
654+
//
655+
// Fallback URLs are validated: they must be HTTPS telegram.org hosts or
656+
// loopback addresses. This prevents the bot token from leaking to arbitrary
657+
// third-party endpoints.
658+
func (b *Bot) SetFallbackURLs(urls []string) error {
655659
if len(urls) == 0 {
656-
return
660+
return nil
661+
}
662+
ft, err := NewFallbackTransport(urls)
663+
if err != nil {
664+
return err
657665
}
658-
ft := NewFallbackTransport(urls)
659666
ft.WrapBot(b)
667+
return nil
660668
}
661669

662670
// SetDailyTokenBudget sets the daily token usage budget for the bot.

internal/telegram/bot_test.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,8 +1149,10 @@ func TestBot_SetFallbackURLs(t *testing.T) {
11491149
bot := NewBot("testtoken")
11501150
originalClient := bot.Client
11511151

1152-
fallbacks := []string{"https://api.telegram2.org", "https://api.telegram3.org"}
1153-
bot.SetFallbackURLs(fallbacks)
1152+
fallbacks := []string{"https://fallback1.api.telegram.org", "https://fallback2.api.telegram.org"}
1153+
if err := bot.SetFallbackURLs(fallbacks); err != nil {
1154+
t.Fatalf("SetFallbackURLs: %v", err)
1155+
}
11541156

11551157
// The bot's client should have been replaced.
11561158
if bot.Client == originalClient {
@@ -1166,11 +1168,11 @@ func TestBot_SetFallbackURLs(t *testing.T) {
11661168
if len(ft.FallbackURLs) != 2 {
11671169
t.Errorf("FallbackURLs length = %d, want 2", len(ft.FallbackURLs))
11681170
}
1169-
if ft.FallbackURLs[0] != "https://api.telegram2.org" {
1170-
t.Errorf("FallbackURLs[0] = %q, want %q", ft.FallbackURLs[0], "https://api.telegram2.org")
1171+
if ft.FallbackURLs[0] != "https://fallback1.api.telegram.org" {
1172+
t.Errorf("FallbackURLs[0] = %q, want %q", ft.FallbackURLs[0], "https://fallback1.api.telegram.org")
11711173
}
1172-
if ft.FallbackURLs[1] != "https://api.telegram3.org" {
1173-
t.Errorf("FallbackURLs[1] = %q, want %q", ft.FallbackURLs[1], "https://api.telegram3.org")
1174+
if ft.FallbackURLs[1] != "https://fallback2.api.telegram.org" {
1175+
t.Errorf("FallbackURLs[1] = %q, want %q", ft.FallbackURLs[1], "https://fallback2.api.telegram.org")
11741176
}
11751177
}
11761178

@@ -1179,13 +1181,29 @@ func TestBot_SetFallbackURLs_Empty(t *testing.T) {
11791181
originalClient := bot.Client
11801182

11811183
// Empty slice should be a no-op.
1182-
bot.SetFallbackURLs([]string{})
1184+
if err := bot.SetFallbackURLs([]string{}); err != nil {
1185+
t.Fatalf("SetFallbackURLs(empty): %v", err)
1186+
}
11831187

11841188
if bot.Client != originalClient {
11851189
t.Error("bot.Client was replaced despite empty fallback list")
11861190
}
11871191
}
11881192

1193+
func TestBot_SetFallbackURLs_InvalidRejected(t *testing.T) {
1194+
bot := NewBot("testtoken")
1195+
originalClient := bot.Client
1196+
1197+
if err := bot.SetFallbackURLs([]string{"https://attacker.example.com"}); err == nil {
1198+
t.Fatal("expected error for untrusted fallback URL, got nil")
1199+
}
1200+
1201+
// Client must not be replaced when validation fails.
1202+
if bot.Client != originalClient {
1203+
t.Error("bot.Client was replaced despite invalid fallback URL")
1204+
}
1205+
}
1206+
11891207
// ---------------------------------------------------------------------------
11901208
// SetDailyTokenBudget / CheckDailyBudget
11911209
// ---------------------------------------------------------------------------

internal/telegram/network.go

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package telegram
22

33
import (
44
"fmt"
5+
"net"
56
"net/http"
67
"net/url"
8+
"strings"
79
"time"
810
)
911

@@ -16,10 +18,59 @@ type FallbackTransport struct {
1618
Client *http.Client
1719
}
1820

21+
// validateFallbackURL checks that a fallback URL is a trusted Telegram API
22+
// endpoint. The bot token is embedded in the request path, so untrusted
23+
// fallbacks would leak the secret to third parties.
24+
//
25+
// Allowed:
26+
// - https:// hosts under telegram.org (e.g. api.telegram.org)
27+
// - http or https on loopback addresses (localhost, 127.0.0.1, ::1)
28+
func validateFallbackURL(raw string) error {
29+
u, err := url.Parse(raw)
30+
if err != nil {
31+
return fmt.Errorf("invalid URL: %w", err)
32+
}
33+
if u.Scheme == "" || u.Host == "" {
34+
return fmt.Errorf("URL must have a scheme and host")
35+
}
36+
37+
host := u.Hostname()
38+
if host == "" {
39+
return fmt.Errorf("URL must have a host")
40+
}
41+
42+
// Loopback is trusted for local Bot API servers.
43+
ip := net.ParseIP(host)
44+
if ip != nil && ip.IsLoopback() {
45+
return nil
46+
}
47+
if strings.EqualFold(host, "localhost") {
48+
return nil
49+
}
50+
51+
// Everything else must be HTTPS and Telegram-controlled.
52+
if !strings.EqualFold(u.Scheme, "https") {
53+
return fmt.Errorf("non-loopback fallback URL must use HTTPS")
54+
}
55+
if !strings.EqualFold(host, "api.telegram.org") && !strings.HasSuffix(strings.ToLower(host), ".telegram.org") {
56+
return fmt.Errorf("fallback URL must be a telegram.org host or loopback")
57+
}
58+
return nil
59+
}
60+
1961
// NewFallbackTransport creates a FallbackTransport with the given fallback
2062
// URLs. The primary URL defaults to https://api.telegram.org and the timeout
2163
// defaults to 30 seconds.
22-
func NewFallbackTransport(fallbackURLs []string) *FallbackTransport {
64+
//
65+
// It returns an error if any fallback URL is untrusted, because the bot token
66+
// is sent in the request path and untrusted endpoints would receive it.
67+
func NewFallbackTransport(fallbackURLs []string) (*FallbackTransport, error) {
68+
for _, raw := range fallbackURLs {
69+
if err := validateFallbackURL(raw); err != nil {
70+
return nil, fmt.Errorf("invalid fallback URL %q: %w", raw, err)
71+
}
72+
}
73+
2374
ft := &FallbackTransport{
2475
PrimaryURL: "https://api.telegram.org",
2576
FallbackURLs: fallbackURLs,
@@ -29,7 +80,7 @@ func NewFallbackTransport(fallbackURLs []string) *FallbackTransport {
2980
Timeout: ft.Timeout,
3081
Transport: ft,
3182
}
32-
return ft
83+
return ft, nil
3384
}
3485

3586
// allURLs returns the primary URL followed by all fallback URLs in a single

0 commit comments

Comments
 (0)