From 2679b6182ecbe820fe900e2f650a2e9bd4277e15 Mon Sep 17 00:00:00 2001 From: eliau2005 Date: Sun, 13 Sep 2026 14:11:04 +0000 Subject: [PATCH] fix: redact bot token from Telegram HTTP errors (#65) net/http reports transport failures as *url.Error containing the full request URL, which embeds the bot token. The agent logs these errors, so any network failure wrote the token to the journal in plain text. Sanitize errors in Client.call: rewrite url.Error.URL in place (keeping the error type, Timeout() and errors.Is/As chains intact) and fall back to a redacting wrapper if the token still appears in the message. --- internal/telegram/telegram.go | 45 ++++++++- internal/telegram/telegram_test.go | 145 +++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 2 deletions(-) diff --git a/internal/telegram/telegram.go b/internal/telegram/telegram.go index e050029..26d41fb 100644 --- a/internal/telegram/telegram.go +++ b/internal/telegram/telegram.go @@ -7,8 +7,11 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" + "net/url" + "regexp" "strings" "time" ) @@ -43,9 +46,47 @@ type Keyboard [][]Button type Client struct { HTTP *http.Client // BaseURL includes the token: https://api.telegram.org/bot + // Errors returned by Client have the token redacted. BaseURL string } +// tokenPattern matches the bot token segment of a Bot API URL. +var tokenPattern = regexp.MustCompile(`bot[0-9]+:[A-Za-z0-9_-]+`) + +// redact replaces any bot token in s with a placeholder. +func redact(s string) string { + return tokenPattern.ReplaceAllString(s, "bot") +} + +// redactedError hides a token that appears in a wrapped error's text while +// keeping the chain intact for errors.Is / errors.As. +type redactedError struct { + msg string + err error +} + +func (e *redactedError) Error() string { return e.msg } +func (e *redactedError) Unwrap() error { return e.err } + +// sanitizeErr strips the bot token from err. net/http reports transport +// failures as *url.Error carrying the full request URL, which would leak the +// token into logs; its URL is rewritten in place so the error type, Timeout() +// and Unwrap() keep working. +func sanitizeErr(err error) error { + if err == nil { + return nil + } + var ue *url.Error + if errors.As(err, &ue) { + ue.URL = redact(ue.URL) + } + msg := err.Error() + if clean := redact(msg); clean != msg { + return &redactedError{msg: clean, err: err} + } + return err +} + // New returns a client for the given bot token. func New(token string) *Client { return &Client{ @@ -69,12 +110,12 @@ func (c *Client) call(ctx context.Context, method string, payload, result any) e } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/"+method, bytes.NewReader(body)) if err != nil { - return err + return fmt.Errorf("telegram: %s: %w", method, sanitizeErr(err)) } req.Header.Set("Content-Type", "application/json") resp, err := c.HTTP.Do(req) if err != nil { - return fmt.Errorf("telegram: %s: %w", method, err) + return fmt.Errorf("telegram: %s: %w", method, sanitizeErr(err)) } defer resp.Body.Close() var env apiResponse diff --git a/internal/telegram/telegram_test.go b/internal/telegram/telegram_test.go index 88b9b76..c9a6995 100644 --- a/internal/telegram/telegram_test.go +++ b/internal/telegram/telegram_test.go @@ -1,16 +1,161 @@ package telegram import ( + "bytes" "context" "encoding/json" + "errors" "fmt" + "io" + "log" "net/http" "net/http/httptest" + "net/url" + "os" "strings" "testing" "time" ) +const fakeToken = "123456789:AAH-fake_TOKEN_value0123456789abcdef" + +// unreachableClient points at a closed local port so every request fails +// at dial time with the token-bearing URL. +func unreachableClient() *Client { + c := New(fakeToken) + c.BaseURL = "http://127.0.0.1:1/bot" + fakeToken + return c +} + +func assertRedacted(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected an error") + } + if strings.Contains(err.Error(), fakeToken) || strings.Contains(fmt.Sprintf("%+v", err), fakeToken) { + t.Errorf("token leaked in error: %v", err) + } + if !strings.Contains(err.Error(), "bot") { + t.Errorf("error lost its redacted URL context: %v", err) + } +} + +func TestTransportErrorRedactsToken(t *testing.T) { + c := unreachableClient() + ctx := context.Background() + calls := []struct { + method string + run func() error + }{ + {"sendMessage", func() error { return c.SendMessage(ctx, 42, "hi") }}, + {"editMessageText", func() error { return c.EditMessageKB(ctx, 42, 1, "hi", nil) }}, + {"answerCallbackQuery", func() error { return c.AnswerCallback(ctx, "cb", "ok") }}, + {"deleteMessages", func() error { return c.DeleteMessages(ctx, 42, []int64{1}) }}, + {"setMyCommands", func() error { return c.SetMyCommands(ctx, []BotCommand{{Command: "status"}}) }}, + {"getUpdates", func() error { _, err := c.GetUpdates(ctx, 0, time.Second); return err }}, + } + for _, tc := range calls { + t.Run(tc.method, func(t *testing.T) { + err := tc.run() + assertRedacted(t, err) + if !strings.Contains(err.Error(), tc.method) { + t.Errorf("error missing method name: %v", err) + } + }) + } +} + +func TestTransportErrorPreservesType(t *testing.T) { + err := unreachableClient().SendMessage(context.Background(), 42, "hi") + assertRedacted(t, err) + var ue *url.Error + if !errors.As(err, &ue) { + t.Fatalf("errors.As(*url.Error) failed for %T: %v", err, err) + } + if strings.Contains(ue.URL, fakeToken) { + t.Errorf("url.Error.URL still has token: %s", ue.URL) + } +} + +func TestCanceledContextRedactsToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"ok":true,"result":[]}`) + })) + defer srv.Close() + c := &Client{HTTP: srv.Client(), BaseURL: srv.URL + "/bot" + fakeToken} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := c.GetUpdates(ctx, 0, time.Second) + assertRedacted(t, err) + if !errors.Is(err, context.Canceled) { + t.Errorf("errors.Is(context.Canceled) = false: %v", err) + } +} + +func TestTimeoutRedactsToken(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-r.Context().Done(): + } + })) + defer srv.Close() + defer close(release) + c := &Client{HTTP: &http.Client{Timeout: 50 * time.Millisecond}, BaseURL: srv.URL + "/bot" + fakeToken} + err := c.SendMessage(context.Background(), 42, "hi") + assertRedacted(t, err) + var ue *url.Error + if !errors.As(err, &ue) || !ue.Timeout() { + t.Errorf("want timeout *url.Error, got %T: %v", err, err) + } +} + +func TestLoggedErrorRedactsToken(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(os.Stderr) }) + + _, err := unreachableClient().GetUpdates(context.Background(), 0, time.Second) + log.Printf("agent: getUpdates: %v", err) + log.Printf("agent: getUpdates: %+v", err) + if strings.Contains(buf.String(), fakeToken) { + t.Errorf("token leaked into log output: %s", buf.String()) + } +} + +func TestRedact(t *testing.T) { + cases := []struct{ in, want string }{ + {`Post "https://api.telegram.org/bot` + fakeToken + `/sendMessage": EOF`, + `Post "https://api.telegram.org/bot/sendMessage": EOF`}, + {"bot" + fakeToken + " and bot" + fakeToken, "bot and bot"}, + {"dial tcp 127.0.0.1:443: connection refused", "dial tcp 127.0.0.1:443: connection refused"}, + {"robot arm", "robot arm"}, + } + for _, tc := range cases { + if got := redact(tc.in); got != tc.want { + t.Errorf("redact(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestSanitizeErrFallback(t *testing.T) { + if sanitizeErr(nil) != nil { + t.Error("sanitizeErr(nil) != nil") + } + plain := errors.New("no secrets here") + if sanitizeErr(plain) != plain { + t.Error("token-free error should be returned unchanged") + } + err := sanitizeErr(fmt.Errorf("x bot%s y: %w", fakeToken, io.EOF)) + if strings.Contains(err.Error(), fakeToken) { + t.Errorf("token leaked: %v", err) + } + if !errors.Is(err, io.EOF) { + t.Errorf("errors.Is(io.EOF) = false: %v", err) + } +} + func TestSendMessage(t *testing.T) { var got []map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {