Skip to content

Commit e5f47f5

Browse files
paskalumputun
authored andcommitted
fix(telegram): never expose bot token in avatar URL
tgAPI.Avatar returned a URL with the bot token embedded in its path: https://api.telegram.org/file/bot{TOKEN}/photos/file_X.jpg The token is a bearer credential for the entire bot API. The URL flowed into User.Picture and from there: * Into avatar.Proxy.Put debug logs ("[DEBUG] saved avatar from <url>" and the corresponding load-failure line) regardless of whether avatar saving succeeded. * Into the JWT claims and the user JSON returned to the browser when no AvatarSaver was configured (User.Picture is in the User struct). Either path leaks the bot token to anyone with log access, anyone who can read the JWT (the user themselves on the device, plus anyone intercepting browser/devtools), or any third-party observability stack. Two-part fix in v1 and v2: 1. avatar/avatar.go: redact the URL in Put's two debug log lines via a new redactAvatarURL helper (hostname only). Add Proxy.PutContent so pre-fetched bytes can be saved without the URL-fetch round trip. 2. provider/telegram.go: in processUpdates, never assign the bot URL to User.Picture. Pass it to a new saveTelegramAvatar method that fetches the bytes server-side and stores them via the new content- saver interface (avatar.Proxy implements it). The call returns a clean local proxy URL or "" — whatever lands in Picture is safe to log and to send to the client. A graceful fallback path warns and drops the avatar when the configured AvatarSaver does not implement PutContent (custom external implementations) — never exposes the token to satisfy the avatar feature. Tests in both modules: * TestSaveTelegramAvatar_BotTokenNeverLogged — unit-level table for the helper covering the success, fallback-without-PutContent and empty-URL paths. * TestTelegramProcessUpdates_BotTokenNeverInUserPicture — regression test for the property: drive processUpdates with a mock that returns a URL containing a bot-token marker; assert the marker never lands in user.Picture and never appears in any captured log line. Reverting the saveTelegramAvatar redirection makes this test fail with a clear assertion message.
1 parent dde9063 commit e5f47f5

11 files changed

Lines changed: 1120 additions & 38 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,29 @@ go func() {
377377
service.AddCustomHandler(&telegram)
378378
```
379379

380+
#### Avatar handling
381+
382+
Telegram's file API requires the bot token in the URL path
383+
(`https://api.telegram.org/file/bot<TOKEN>/...`), so the URL is a bearer
384+
credential and must never reach the JWT, the user JSON, or any debug log.
385+
The provider fetches the avatar bytes server-side and stores them via
386+
`AvatarSaver`, returning a clean local proxy URL.
387+
388+
This requires `AvatarSaver` to support direct content saving.
389+
`avatar.Proxy` (the default returned by `service.AvatarProxy()`) does -- no
390+
extra setup needed. **Custom `AvatarSaver` implementations** that don't
391+
implement `PutContent(userID string, content io.Reader) (string, error)`
392+
will see an empty `User.Picture` for Telegram logins, and the avatar proxy
393+
will fall back to identicon. To preserve avatars with a custom saver,
394+
implement that method alongside `Put`.
395+
396+
> **Behaviour change for custom `AvatarSaver`:** before this change, custom
397+
> savers received the raw Telegram file URL (which included the bot token)
398+
> via `Put`. After this change they no longer do, because that URL is a
399+
> bearer credential. Implementations that relied on receiving and refetching
400+
> that URL must now implement `PutContent` to keep saving Telegram avatars,
401+
> or accept that Telegram users get identicons.
402+
380403
Now all your users have to do is click one of the following links and press **start**
381404
`tg://resolve?domain=<botname>&start=<token>` or `https://t.me/<botname>/?start=<token>`
382405

avatar/avatar.go

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"image/png"
1212
"io"
1313
"net/http"
14+
"net/url"
1415
"strconv"
1516
"strings"
1617
"time"
@@ -26,6 +27,12 @@ import (
2627
// http.sniffLen is 512 bytes which is how much we need to read to detect content type
2728
const sniffLen = 512
2829

30+
// maxAvatarFetchSize bounds the bytes read from a remote avatar URL. 10 MiB is
31+
// generous for any reasonable avatar (Telegram caps photo at 5 MiB; Gravatar is
32+
// much smaller); the cap protects Proxy.Put against an upstream sending an
33+
// unbounded body that would exhaust process memory inside resize.
34+
const maxAvatarFetchSize = 10 << 20
35+
2936
// Proxy provides http handler for avatars from avatar.Store
3037
// On user login token will call Put and it will retrieve and save picture locally.
3138
type Proxy struct {
@@ -61,7 +68,7 @@ func (p *Proxy) Put(u token.User, client *http.Client) (avatarURL string, err er
6168

6269
body, err := p.load(u.Picture, client)
6370
if err != nil {
64-
p.Logf("[DEBUG] failed to fetch avatar from the orig %s, %v", u.Picture, err)
71+
p.Logf("[DEBUG] failed to fetch avatar from the orig %s, %v", redactAvatarURL(u.Picture), err)
6572
return genIdenticon(u.ID)
6673
}
6774

@@ -76,10 +83,36 @@ func (p *Proxy) Put(u token.User, client *http.Client) (avatarURL string, err er
7683
return "", err
7784
}
7885

79-
p.Logf("[DEBUG] saved avatar from %s to %s, user %q", u.Picture, avatarID, u.Name)
86+
p.Logf("[DEBUG] saved avatar from %s to %s, user %q", redactAvatarURL(u.Picture), avatarID, u.Name)
87+
return p.URL + p.RoutePath + "/" + avatarID, nil
88+
}
89+
90+
// PutContent stores already-fetched avatar bytes via the underlying Store and returns
91+
// the proxied URL. It exists so providers that authenticate with credentials embedded
92+
// in the upstream URL (e.g. Telegram bot file API: /file/bot{TOKEN}/...) can fetch the
93+
// content themselves and avoid exposing the credential to Put's URL-fetching path —
94+
// where it would land in u.Picture, debug logs, and the user JSON returned to clients.
95+
func (p *Proxy) PutContent(userID string, content io.Reader) (avatarURL string, err error) {
96+
avatarID, err := p.Store.Put(userID, p.resize(content, p.ResizeLimit))
97+
if err != nil {
98+
return "", err
99+
}
100+
p.Logf("[DEBUG] saved avatar bytes to %s, user %q", avatarID, userID)
80101
return p.URL + p.RoutePath + "/" + avatarID, nil
81102
}
82103

104+
// redactAvatarURL returns the hostname only, dropping scheme, userinfo, path,
105+
// query and fragment. This is enough to keep avatar URLs identifiable in logs
106+
// while ensuring credentials carried in any of those parts (e.g. Telegram bot
107+
// tokens, time-limited signed-URL tokens, basic-auth in userinfo) don't reach
108+
// log destinations. On parse failure a sentinel is returned.
109+
func redactAvatarURL(raw string) string {
110+
if u, err := url.Parse(raw); err == nil && u.Hostname() != "" {
111+
return u.Hostname()
112+
}
113+
return "<unparseable>"
114+
}
115+
83116
// load avatar from remote url and return body. Caller has to close the reader
84117
func (p *Proxy) load(url string, client *http.Client) (rc io.ReadCloser, err error) {
85118
// load avatar from remote location
@@ -98,7 +131,17 @@ func (p *Proxy) load(url string, client *http.Client) (rc io.ReadCloser, err err
98131
return nil, fmt.Errorf("failed to get avatar from the orig, status %s", resp.Status)
99132
}
100133

101-
return resp.Body, nil
134+
// buffer the body up to the cap to fail fast on oversized inputs.
135+
// Reading +1 byte beyond the cap distinguishes "exactly cap" from "too big".
136+
body, err := io.ReadAll(io.LimitReader(resp.Body, maxAvatarFetchSize+1))
137+
_ = resp.Body.Close()
138+
if err != nil {
139+
return nil, fmt.Errorf("failed to read avatar body: %w", err)
140+
}
141+
if int64(len(body)) > maxAvatarFetchSize {
142+
return nil, fmt.Errorf("avatar body exceeds %d bytes", maxAvatarFetchSize)
143+
}
144+
return io.NopCloser(bytes.NewReader(body)), nil
102145
}
103146

104147
// Handler returns token routes for given provider

avatar/avatar_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,34 @@ func TestAvatar_Put(t *testing.T) {
5858
assert.Equal(t, int64(21), fi.Size())
5959
}
6060

61+
func TestAvatar_PutContent(t *testing.T) {
62+
defer func() { _ = os.RemoveAll("/tmp/avatars.put-content.test/") }()
63+
p := Proxy{RoutePath: "/avatar", URL: "http://localhost:8080", Store: NewLocalFS("/tmp/avatars.put-content.test"), L: logger.Std}
64+
got, err := p.PutContent("user1", strings.NewReader("png-bytes"))
65+
require.NoError(t, err)
66+
assert.Equal(t, "http://localhost:8080/avatar/b3daa77b4c04a9551b8781d03191fe098f325e67.image", got)
67+
fi, err := os.Stat("/tmp/avatars.put-content.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image")
68+
require.NoError(t, err)
69+
assert.Greater(t, fi.Size(), int64(0))
70+
}
71+
72+
func TestAvatar_RedactAvatarURL(t *testing.T) {
73+
cases := []struct {
74+
in, want string
75+
}{
76+
{in: "https://api.telegram.org/file/botSECRET/photo.jpg", want: "api.telegram.org"},
77+
{in: "https://x:y@example.com/path?q=1", want: "example.com"}, // #nosec G101 -- deliberate test fixture for userinfo redaction
78+
{in: "", want: "<unparseable>"},
79+
{in: "/local/path", want: "<unparseable>"},
80+
{in: "://malformed", want: "<unparseable>"},
81+
}
82+
for _, c := range cases {
83+
t.Run(c.in, func(t *testing.T) {
84+
assert.Equal(t, c.want, redactAvatarURL(c.in))
85+
})
86+
}
87+
}
88+
6189
func TestAvatar_PutIdenticon(t *testing.T) {
6290
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6391
log.Print("request: ", r.URL.Path)
@@ -103,6 +131,31 @@ func TestAvatar_PutFailed(t *testing.T) {
103131
assert.Equal(t, int64(992), fi.Size())
104132
}
105133

134+
func TestAvatar_PutCapsBodySize(t *testing.T) {
135+
// upstream that streams indefinitely past the cap; without
136+
// the maxAvatarFetchSize check resize would consume all bytes.
137+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
138+
w.Header().Set("Content-Type", "image/*")
139+
w.WriteHeader(http.StatusOK)
140+
buf := make([]byte, 64<<10)
141+
for i := 0; i < (maxAvatarFetchSize/len(buf))+32; i++ {
142+
if _, err := w.Write(buf); err != nil {
143+
return
144+
}
145+
}
146+
}))
147+
defer ts.Close()
148+
149+
dir := t.TempDir()
150+
p := Proxy{RoutePath: "/avatar", URL: "http://localhost:8080", Store: NewLocalFS(dir), L: logger.NoOp}
151+
client := &http.Client{Timeout: 5 * time.Second}
152+
153+
u := token.User{ID: "user1", Name: "huge avatar", Picture: ts.URL + "/pic.png"}
154+
res, err := p.Put(u, client)
155+
require.NoError(t, err, "Put falls back to identicon on capped fetch failure")
156+
assert.Contains(t, res, "/avatar/", "still returns a proxy URL via identicon fallback")
157+
}
158+
106159
func TestAvatar_Routes(t *testing.T) {
107160

108161
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

provider/oauth2_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,14 @@ func (m *mockAvatarSaver) Put(u token.User, _ *http.Client) (avatarURL string, e
450450
return "http://example.com/fake.png", nil
451451

452452
}
453+
454+
// PutContent satisfies the avatarContentSaver type assertion in telegram.go so
455+
// the test mock behaves like avatar.Proxy and the bot-token-aware path can be
456+
// exercised. The body is drained but the saver returns the same canned URL it
457+
// would for a regular Put, so existing tests asserting on Picture stay valid.
458+
func (m *mockAvatarSaver) PutContent(_ string, content io.Reader) (avatarURL string, err error) {
459+
if _, e := io.Copy(io.Discard, content); e != nil {
460+
return "", e
461+
}
462+
return "http://example.com/ava12345.png", nil
463+
}

provider/telegram.go

Lines changed: 125 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ package provider
33
//go:generate moq --out telegram_moq_test.go . TelegramAPI
44

55
import (
6+
"bytes"
67
"context"
78
"crypto/sha1"
89
"encoding/json"
10+
"errors"
911
"fmt"
1012
"io"
1113
"net/http"
1214
neturl "net/url"
15+
"regexp"
1316
"strings"
1417
"sync"
1518
"sync/atomic"
@@ -19,6 +22,7 @@ import (
1922
"github.com/go-pkgz/rest"
2023
"github.com/golang-jwt/jwt"
2124

25+
"github.com/go-pkgz/auth/avatar"
2226
"github.com/go-pkgz/auth/logger"
2327
authtoken "github.com/go-pkgz/auth/token"
2428
)
@@ -186,11 +190,18 @@ func (th *TelegramHandler) processUpdates(ctx context.Context, updates *telegram
186190

187191
id := th.ProviderName + "_" + authtoken.HashID(sha1.New(), fmt.Sprint(update.Message.Chat.ID))
188192

193+
// avatarURL embeds the bot token in its path
194+
// (https://api.telegram.org/file/bot{TOKEN}/...). Never store it in
195+
// User.Picture: it would leak through avatar.Proxy.Put logs and, when
196+
// no avatar saver is configured, into the JWT and on to the client.
197+
// Fetch the bytes here and hand them to the avatar store directly.
198+
picture := th.saveTelegramAvatar(ctx, id, avatarURL)
199+
189200
authRequest.confirmed = true
190201
authRequest.user = &authtoken.User{
191202
ID: id,
192203
Name: update.Message.Chat.Name,
193-
Picture: avatarURL,
204+
Picture: picture,
194205
}
195206

196207
th.requests.Lock()
@@ -294,10 +305,19 @@ func (th *TelegramHandler) LoginHandler(w http.ResponseWriter, r *http.Request)
294305
return
295306
}
296307

297-
u, err := setAvatar(th.AvatarSaver, *authUser, &http.Client{Timeout: 5 * time.Second})
298-
if err != nil {
299-
rest.SendErrorJSON(w, r, th.L, http.StatusInternalServerError, err, "failed to save avatar to proxy")
300-
return
308+
// when saveTelegramAvatar already populated Picture with a local proxy
309+
// URL, skip the URL-fetching avatar pipeline. Letting setAvatar run
310+
// here would have it call Proxy.Put which re-fetches Picture; in
311+
// split-DNS / unreachable-internal-Opts.URL deployments that fetch
312+
// fails and the identicon fallback would silently overwrite the
313+
// stored Telegram bytes with an identicon at the same store path.
314+
u := *authUser
315+
if u.Picture == "" {
316+
u, err = setAvatar(th.AvatarSaver, *authUser, &http.Client{Timeout: 5 * time.Second})
317+
if err != nil {
318+
rest.SendErrorJSON(w, r, th.L, http.StatusInternalServerError, err, "failed to save avatar to proxy")
319+
return
320+
}
301321
}
302322

303323
claims := authtoken.Claims{
@@ -455,12 +475,12 @@ func (tg *tgAPI) request(ctx context.Context, method string, data any) error {
455475

456476
req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody)
457477
if err != nil {
458-
return fmt.Errorf("failed to create request: %w", err)
478+
return fmt.Errorf("failed to create request: %w", redactBotURLInErr(err))
459479
}
460480

461481
resp, err := tg.client.Do(req)
462482
if err != nil {
463-
return fmt.Errorf("failed to send request: %w", err)
483+
return fmt.Errorf("failed to send request: %w", redactBotURLInErr(err))
464484
}
465485
defer resp.Body.Close() //nolint gosec // we don't care about response body
466486

@@ -485,3 +505,101 @@ func (tg *tgAPI) parseError(r io.Reader, statusCode int) error {
485505
}
486506
return fmt.Errorf("unexpected telegram API status code %d, error: %q", statusCode, tgErr.Description)
487507
}
508+
509+
// avatarContentSaver matches the optional method on AvatarSaver implementations
510+
// that can store already-fetched bytes (avatar.Proxy provides one). Used by the
511+
// Telegram provider to avoid passing a bot-token-bearing URL through the
512+
// URL-fetching avatar pipeline.
513+
type avatarContentSaver interface {
514+
PutContent(userID string, content io.Reader) (string, error)
515+
}
516+
517+
// saveTelegramAvatar fetches the avatar bytes from a bot-token-bearing Telegram
518+
// URL and stores them via th.AvatarSaver, returning a clean local proxy URL.
519+
// The bot URL is consumed entirely inside this function so it never reaches
520+
// User.Picture, JWT claims, or any debug log of the user object. Returns ""
521+
// when the avatar cannot be saved (no URL, no compatible saver, or fetch
522+
// failure) — the caller treats that as "no picture" and the avatar pipeline
523+
// falls back to identicon as usual.
524+
func (th *TelegramHandler) saveTelegramAvatar(ctx context.Context, userID, avatarURL string) string {
525+
if avatarURL == "" {
526+
return ""
527+
}
528+
// guard against typed-nil *avatar.Proxy. auth.go skips initializing
529+
// res.avatarProxy when Opts.AvatarStore is unset, so AvatarSaver can be
530+
// a non-nil interface wrapping a nil *avatar.Proxy. The type assertion
531+
// below would still succeed (interface satisfaction is structural), but
532+
// PutContent on a nil receiver panics on the first p.Store deref.
533+
if th.AvatarSaver == nil || th.AvatarSaver == (*avatar.Proxy)(nil) {
534+
th.Logf("[WARN] telegram avatar dropped: AvatarSaver is not configured")
535+
return ""
536+
}
537+
saver, ok := th.AvatarSaver.(avatarContentSaver)
538+
if !ok {
539+
// fallback intentionally drops the picture rather than expose the bot
540+
// token; warn so operators can wire a content-aware saver if they want
541+
// telegram avatars saved
542+
th.Logf("[WARN] telegram avatar dropped: configured AvatarSaver does not support direct content save")
543+
return ""
544+
}
545+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, avatarURL, http.NoBody)
546+
if err != nil {
547+
th.Logf("[WARN] telegram avatar fetch request build failed: %v", redactBotURLInErr(err))
548+
return ""
549+
}
550+
client := &http.Client{Timeout: 5 * time.Second}
551+
resp, err := client.Do(req)
552+
if err != nil {
553+
th.Logf("[WARN] telegram avatar fetch failed: %v", redactBotURLInErr(err))
554+
return ""
555+
}
556+
defer func() { _ = resp.Body.Close() }()
557+
if resp.StatusCode != http.StatusOK {
558+
th.Logf("[WARN] telegram avatar fetch returned status %d", resp.StatusCode)
559+
return ""
560+
}
561+
// cap body size to protect PutContent from an unbounded upstream response.
562+
// Telegram caps photos at 5 MiB; 10 MiB is generous headroom while still
563+
// bounding worst-case memory.
564+
body, err := io.ReadAll(io.LimitReader(resp.Body, maxTelegramAvatarSize+1))
565+
if err != nil {
566+
th.Logf("[WARN] telegram avatar read failed: %v", err)
567+
return ""
568+
}
569+
if int64(len(body)) > maxTelegramAvatarSize {
570+
th.Logf("[WARN] telegram avatar dropped: body exceeds %d bytes", maxTelegramAvatarSize)
571+
return ""
572+
}
573+
picture, err := saver.PutContent(userID, bytes.NewReader(body))
574+
if err != nil {
575+
th.Logf("[WARN] telegram avatar save failed: %v", err)
576+
return ""
577+
}
578+
return picture
579+
}
580+
581+
const maxTelegramAvatarSize = 10 << 20
582+
583+
// botTokenInURLPath matches the bot-token segment of a Telegram URL anchored
584+
// between path slashes ("/botTOKEN/..."). The leading and trailing slashes
585+
// avoid matching unrelated identifiers that happen to start with "bot" (e.g.
586+
// the username "botFather" appearing elsewhere in a log line). Replacement
587+
// preserves the slashes via "/bot<redacted>/" to keep surrounding URL
588+
// structure intact for diagnostics.
589+
var botTokenInURLPath = regexp.MustCompile(`/bot[A-Za-z0-9:_-]+/`)
590+
591+
// redactBotURLInErr returns the error with any embedded Telegram bot-token
592+
// segment in URL paths replaced by "bot<redacted>". net/http's *url.Error
593+
// stringifies as `Op "URL": Err`, so a transport failure on a URL like
594+
// https://api.telegram.org/file/bot<TOKEN>/... otherwise prints the token
595+
// verbatim.
596+
func redactBotURLInErr(err error) error {
597+
if err == nil {
598+
return nil
599+
}
600+
redacted := botTokenInURLPath.ReplaceAllString(err.Error(), "/bot<redacted>/")
601+
if redacted == err.Error() {
602+
return err
603+
}
604+
return errors.New(redacted)
605+
}

0 commit comments

Comments
 (0)