diff --git a/internal/telegram/client.go b/internal/telegram/client.go index 95750db..79c0bef 100644 --- a/internal/telegram/client.go +++ b/internal/telegram/client.go @@ -15,6 +15,7 @@ import ( type Client struct { baseURL string + token string httpClient *http.Client requestTimeout time.Duration } @@ -66,6 +67,7 @@ func IsTerminalSendError(err error) bool { func NewClient(token string, timeout time.Duration) *Client { return &Client{ baseURL: "https://api.telegram.org/bot" + token, + token: token, httpClient: &http.Client{}, requestTimeout: timeout, } @@ -124,6 +126,20 @@ func (c *Client) SendText(ctx context.Context, chatID int64, threadID *int, text return c.postJSON(ctx, "/sendMessage", payload, &result, c.requestTimeout) } +// redactToken returns an error whose message has the bot token replaced with a +// placeholder. It leaves the error untouched when no token is set or the token +// does not appear in the message, so typed errors are preserved where possible. +func (c *Client) redactToken(err error) error { + if err == nil || c.token == "" { + return err + } + msg := err.Error() + if !strings.Contains(msg, c.token) { + return err + } + return errors.New(strings.ReplaceAll(msg, c.token, "")) +} + func (c *Client) longPollRequestTimeout(timeoutSeconds int) time.Duration { telegramWait := time.Duration(timeoutSeconds) * time.Second if telegramWait < 0 { @@ -160,7 +176,10 @@ func (c *Client) postJSON(ctx context.Context, path string, payload any, target } res, err := httpClient.Do(req) if err != nil { - return err + // Transport errors are *url.Error and embed the full request URL, + // which contains the bot token. Strip it before the error propagates + // to logs. + return c.redactToken(err) } defer res.Body.Close() diff --git a/internal/telegram/client_test.go b/internal/telegram/client_test.go index 4ab9280..c6c916a 100644 --- a/internal/telegram/client_test.go +++ b/internal/telegram/client_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -168,6 +169,28 @@ func TestSendTextRejectsNonSuccessHTTPStatusEvenWhenEnvelopeOK(t *testing.T) { } } +func TestTransportErrorRedactsBotToken(t *testing.T) { + const token = "123456:super-secret-token" + // Port 0 is reserved and never listens, forcing a transport-level dial + // error whose *url.Error message embeds the token-bearing URL. + client := &Client{ + baseURL: "http://127.0.0.1:0/bot" + token, + token: token, + httpClient: &http.Client{}, + requestTimeout: time.Second, + } + err := client.SendText(context.Background(), 123, nil, "hello") + if err == nil { + t.Fatal("expected transport error, got nil") + } + if strings.Contains(err.Error(), token) { + t.Fatalf("error leaked bot token: %v", err) + } + if !strings.Contains(err.Error(), "") { + t.Fatalf("error missing redaction placeholder: %v", err) + } +} + func TestNewClientUsesConfiguredTimeoutForOrdinaryRequests(t *testing.T) { client := NewClient("token", 10*time.Second) if client.requestTimeout != 10*time.Second {