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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,29 @@ go func() {
service.AddCustomHandler(&telegram)
```

#### Avatar handling

Telegram's file API requires the bot token in the URL path
(`https://api.telegram.org/file/bot<TOKEN>/...`), so the URL is a bearer
credential and must never reach the JWT, the user JSON, or any debug log.
The provider fetches the avatar bytes server-side and stores them via
`AvatarSaver`, returning a clean local proxy URL.

This requires `AvatarSaver` to support direct content saving.
`avatar.Proxy` (the default returned by `service.AvatarProxy()`) does -- no
extra setup needed. **Custom `AvatarSaver` implementations** that don't
implement `PutContent(userID string, content io.Reader) (string, error)`
will see an empty `User.Picture` for Telegram logins, and the avatar proxy
will fall back to identicon. To preserve avatars with a custom saver,
implement that method alongside `Put`.

> **Behaviour change for custom `AvatarSaver`:** before this change, custom
> savers received the raw Telegram file URL (which included the bot token)
> via `Put`. After this change they no longer do, because that URL is a
> bearer credential. Implementations that relied on receiving and refetching
> that URL must now implement `PutContent` to keep saving Telegram avatars,
> or accept that Telegram users get identicons.

Now all your users have to do is click one of the following links and press **start**
`tg://resolve?domain=<botname>&start=<token>` or `https://t.me/<botname>/?start=<token>`

Expand Down
49 changes: 46 additions & 3 deletions avatar/avatar.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"image/png"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
Expand All @@ -26,6 +27,12 @@ import (
// http.sniffLen is 512 bytes which is how much we need to read to detect content type
const sniffLen = 512

// maxAvatarFetchSize bounds the bytes read from a remote avatar URL. 10 MiB is
// generous for any reasonable avatar (Telegram caps photo at 5 MiB; Gravatar is
// much smaller); the cap protects Proxy.Put against an upstream sending an
// unbounded body that would exhaust process memory inside resize.
const maxAvatarFetchSize = 10 << 20

// Proxy provides http handler for avatars from avatar.Store
// On user login token will call Put and it will retrieve and save picture locally.
type Proxy struct {
Expand Down Expand Up @@ -61,7 +68,7 @@ func (p *Proxy) Put(u token.User, client *http.Client) (avatarURL string, err er

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

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

p.Logf("[DEBUG] saved avatar from %s to %s, user %q", u.Picture, avatarID, u.Name)
p.Logf("[DEBUG] saved avatar from %s to %s, user %q", redactAvatarURL(u.Picture), avatarID, u.Name)
return p.URL + p.RoutePath + "/" + avatarID, nil
}

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

// redactAvatarURL returns the hostname only, dropping scheme, userinfo, path,
// query and fragment. This is enough to keep avatar URLs identifiable in logs
// while ensuring credentials carried in any of those parts (e.g. Telegram bot
// tokens, time-limited signed-URL tokens, basic-auth in userinfo) don't reach
// log destinations. On parse failure a sentinel is returned.
func redactAvatarURL(raw string) string {
if u, err := url.Parse(raw); err == nil && u.Hostname() != "" {
return u.Hostname()
}
return "<unparseable>"
}

// load avatar from remote url and return body. Caller has to close the reader
func (p *Proxy) load(url string, client *http.Client) (rc io.ReadCloser, err error) {
// load avatar from remote location
Expand All @@ -98,7 +131,17 @@ func (p *Proxy) load(url string, client *http.Client) (rc io.ReadCloser, err err
return nil, fmt.Errorf("failed to get avatar from the orig, status %s", resp.Status)
}

return resp.Body, nil
// buffer the body up to the cap to fail fast on oversized inputs.
// Reading +1 byte beyond the cap distinguishes "exactly cap" from "too big".
body, err := io.ReadAll(io.LimitReader(resp.Body, maxAvatarFetchSize+1))
_ = resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("failed to read avatar body: %w", err)
}
if int64(len(body)) > maxAvatarFetchSize {
return nil, fmt.Errorf("avatar body exceeds %d bytes", maxAvatarFetchSize)
}
return io.NopCloser(bytes.NewReader(body)), nil
}

// Handler returns token routes for given provider
Expand Down
53 changes: 53 additions & 0 deletions avatar/avatar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,34 @@ func TestAvatar_Put(t *testing.T) {
assert.Equal(t, int64(21), fi.Size())
}

func TestAvatar_PutContent(t *testing.T) {
defer func() { _ = os.RemoveAll("/tmp/avatars.put-content.test/") }()
p := Proxy{RoutePath: "/avatar", URL: "http://localhost:8080", Store: NewLocalFS("/tmp/avatars.put-content.test"), L: logger.Std}
got, err := p.PutContent("user1", strings.NewReader("png-bytes"))
require.NoError(t, err)
assert.Equal(t, "http://localhost:8080/avatar/b3daa77b4c04a9551b8781d03191fe098f325e67.image", got)
fi, err := os.Stat("/tmp/avatars.put-content.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image")
require.NoError(t, err)
assert.Greater(t, fi.Size(), int64(0))
}

func TestAvatar_RedactAvatarURL(t *testing.T) {
cases := []struct {
in, want string
}{
{in: "https://api.telegram.org/file/botSECRET/photo.jpg", want: "api.telegram.org"},
{in: "https://x:y@example.com/path?q=1", want: "example.com"}, // #nosec G101 -- deliberate test fixture for userinfo redaction
{in: "", want: "<unparseable>"},
{in: "/local/path", want: "<unparseable>"},
{in: "://malformed", want: "<unparseable>"},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
assert.Equal(t, c.want, redactAvatarURL(c.in))
})
}
}

func TestAvatar_PutIdenticon(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Print("request: ", r.URL.Path)
Expand Down Expand Up @@ -103,6 +131,31 @@ func TestAvatar_PutFailed(t *testing.T) {
assert.Equal(t, int64(992), fi.Size())
}

func TestAvatar_PutCapsBodySize(t *testing.T) {
// upstream that streams indefinitely past the cap; without
// the maxAvatarFetchSize check resize would consume all bytes.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/*")
w.WriteHeader(http.StatusOK)
buf := make([]byte, 64<<10)
for i := 0; i < (maxAvatarFetchSize/len(buf))+32; i++ {
if _, err := w.Write(buf); err != nil {
return
}
}
}))
defer ts.Close()

dir := t.TempDir()
p := Proxy{RoutePath: "/avatar", URL: "http://localhost:8080", Store: NewLocalFS(dir), L: logger.NoOp}
client := &http.Client{Timeout: 5 * time.Second}

u := token.User{ID: "user1", Name: "huge avatar", Picture: ts.URL + "/pic.png"}
res, err := p.Put(u, client)
require.NoError(t, err, "Put falls back to identicon on capped fetch failure")
assert.Contains(t, res, "/avatar/", "still returns a proxy URL via identicon fallback")
}

func TestAvatar_Routes(t *testing.T) {

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
2 changes: 1 addition & 1 deletion middleware/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ func TestBasicAdminUserDoesNotLogPassword(t *testing.T) {
const secret = "super-secret-attempted-passwd"
var buf strings.Builder
a := makeTestAuth(t)
a.L = logger.Func(func(format string, args ...interface{}) {
a.L = logger.Func(func(format string, args ...any) {
fmt.Fprintf(&buf, format, args...)
})

Expand Down
11 changes: 11 additions & 0 deletions provider/oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,14 @@ func (m *mockAvatarSaver) Put(u token.User, _ *http.Client) (avatarURL string, e
return "http://example.com/fake.png", nil

}

// PutContent satisfies the avatarContentSaver type assertion in telegram.go so
// the test mock behaves like avatar.Proxy and the bot-token-aware path can be
// exercised. The body is drained but the saver returns the same canned URL it
// would for a regular Put, so existing tests asserting on Picture stay valid.
func (m *mockAvatarSaver) PutContent(_ string, content io.Reader) (avatarURL string, err error) {
if _, e := io.Copy(io.Discard, content); e != nil {
return "", e
}
return "http://example.com/ava12345.png", nil
}
2 changes: 1 addition & 1 deletion provider/sender/email_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func TestEmail_New(t *testing.T) {
func TestEmail_SendDoesNotLogBody(t *testing.T) {
const secretBody = "Confirmation token: super-secret-magic-link-XYZ"
var buf strings.Builder
capturing := logger.Func(func(format string, args ...interface{}) {
capturing := logger.Func(func(format string, args ...any) {
fmt.Fprintf(&buf, format, args...)
})
p := EmailParams{Host: "127.0.0.2", Port: 25, From: "from@example.com",
Expand Down
Loading
Loading