Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions internal/telegram/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
Expand Down Expand Up @@ -43,9 +46,47 @@ type Keyboard [][]Button
type Client struct {
HTTP *http.Client
// BaseURL includes the token: https://api.telegram.org/bot<token>
// 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<redacted>")
}

// 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{
Expand All @@ -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
Expand Down
145 changes: 145 additions & 0 deletions internal/telegram/telegram_test.go
Original file line number Diff line number Diff line change
@@ -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<redacted>") {
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<redacted>/sendMessage": EOF`},
{"bot" + fakeToken + " and bot" + fakeToken, "bot<redacted> and bot<redacted>"},
{"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) {
Expand Down