From 5d31144367a624766dc10ac6e4e602e57204ba23 Mon Sep 17 00:00:00 2001 From: Logan Pratt Date: Sun, 18 Oct 2026 08:52:16 -0400 Subject: [PATCH 1/2] fix(download): only return a message from the requested chat For users and basic groups getMessage calls messages.getMessages, which looks an id up across all of the account's private chats and basic groups: it is not scoped to the peer. `tg download @alice 123` (and `tg reactions`) could therefore act on message 123 of a different chat. Check that the returned message belongs to the requested peer; channel lookups were already scoped, and Saved Messages (self) is left unchecked. Co-Authored-By: Claude Opus 5.5 --- cmd/tg/download.go | 29 ++++++++++++++++++++++++++--- cmd/tg/download_test.go | 22 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/cmd/tg/download.go b/cmd/tg/download.go index 70154d6..bee64a2 100644 --- a/cmd/tg/download.go +++ b/cmd/tg/download.go @@ -49,12 +49,35 @@ func getMessage(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, id if !ok { return nil, errors.Errorf("unexpected messages type %T", res) } + // messages.getMessages looks ids up across all private chats and basic + // groups, so check the message belongs to the requested chat. Channel + // lookups are already scoped; self (-1) is not checked. + want := inputPeerID(peer) for _, m := range msgs.GetMessages() { - if msg, ok := m.(*tg.Message); ok && msg.ID == id { - return msg, nil + msg, ok := m.(*tg.Message) + if !ok || msg.ID != id { + continue } + if _, channel := peer.(*tg.InputPeerChannel); !channel && want > 0 && peerIDOf(msg.PeerID) != want { + break + } + return msg, nil + } + return nil, errors.Errorf("message #%d not found in this chat", id) +} + +// peerIDOf returns the bare id of a peer. +func peerIDOf(p tg.PeerClass) int64 { + switch v := p.(type) { + case *tg.PeerUser: + return v.UserID + case *tg.PeerChat: + return v.ChatID + case *tg.PeerChannel: + return v.ChannelID + default: + return 0 } - return nil, errors.Errorf("message #%d not found", id) } // documentFilename returns the filename attribute of a document, or a fallback. diff --git a/cmd/tg/download_test.go b/cmd/tg/download_test.go index 5fa49ae..56ca0a0 100644 --- a/cmd/tg/download_test.go +++ b/cmd/tg/download_test.go @@ -1,9 +1,11 @@ package main import ( + "context" "path/filepath" "testing" + "github.com/gotd/td/bin" "github.com/gotd/td/tg" ) @@ -82,3 +84,23 @@ func TestResolveOutPath(t *testing.T) { t.Errorf("dir join = %q", got) } } + +func TestGetMessageScopedToChat(t *testing.T) { + // messages.getMessages is not scoped to a chat: the same id may belong to + // another private chat, which must not be returned for this one. + api := newFuncAPI(t, func(bin.Encoder) (bin.Encoder, error) { + return &tg.MessagesMessages{Messages: []tg.MessageClass{ + &tg.Message{ID: 5, PeerID: &tg.PeerUser{UserID: 999}}, + }}, nil + }) + ctx := context.Background() + if _, err := getMessage(ctx, api, &tg.InputPeerUser{UserID: 7, AccessHash: 1}, 5); err == nil { + t.Error("expected not found for a message from another chat") + } + if msg, err := getMessage(ctx, api, &tg.InputPeerUser{UserID: 999, AccessHash: 1}, 5); err != nil || msg.ID != 5 { + t.Errorf("same chat: %v, %v", msg, err) + } + if _, err := getMessage(ctx, api, &tg.InputPeerSelf{}, 5); err != nil { + t.Errorf("self is not checked: %v", err) + } +} From e8475de1e34bccea7066e07aeadc991978412fac Mon Sep 17 00:00:00 2001 From: Logan Pratt Date: Sun, 18 Oct 2026 15:34:59 -0400 Subject: [PATCH 2/2] fix(download): scope Saved Messages lookups to Saved Messages The chat check skipped "me", so `tg download me 123` still returned message 123 of whichever private chat or basic group held that id. A Saved Messages hit is accepted only when the response marks the message's user as the current user, without an extra RPC. Co-Authored-By: Claude Opus 5.5 --- cmd/tg/download.go | 29 +++++++++++++++++++++++++---- cmd/tg/download_test.go | 22 ++++++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/cmd/tg/download.go b/cmd/tg/download.go index bee64a2..9ef09c9 100644 --- a/cmd/tg/download.go +++ b/cmd/tg/download.go @@ -50,15 +50,13 @@ func getMessage(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, id return nil, errors.Errorf("unexpected messages type %T", res) } // messages.getMessages looks ids up across all private chats and basic - // groups, so check the message belongs to the requested chat. Channel - // lookups are already scoped; self (-1) is not checked. - want := inputPeerID(peer) + // groups, so check the message belongs to the requested chat. for _, m := range msgs.GetMessages() { msg, ok := m.(*tg.Message) if !ok || msg.ID != id { continue } - if _, channel := peer.(*tg.InputPeerChannel); !channel && want > 0 && peerIDOf(msg.PeerID) != want { + if !inChat(peer, msg, msgs.GetUsers()) { break } return msg, nil @@ -66,6 +64,29 @@ func getMessage(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, id return nil, errors.Errorf("message #%d not found in this chat", id) } +// inChat reports whether msg belongs to the chat peer addresses. Channel +// lookups are scoped by the server. Saved Messages ("me") is the private chat +// whose user the response marks as the current user. +func inChat(peer tg.InputPeerClass, msg *tg.Message, users []tg.UserClass) bool { + switch peer.(type) { + case *tg.InputPeerChannel: + return true + case *tg.InputPeerSelf: + pu, ok := msg.PeerID.(*tg.PeerUser) + if !ok { + return false + } + for _, u := range users { + if user, ok := u.(*tg.User); ok && user.ID == pu.UserID { + return user.Self + } + } + return false + } + want := inputPeerID(peer) + return want <= 0 || peerIDOf(msg.PeerID) == want +} + // peerIDOf returns the bare id of a peer. func peerIDOf(p tg.PeerClass) int64 { switch v := p.(type) { diff --git a/cmd/tg/download_test.go b/cmd/tg/download_test.go index 56ca0a0..f48cd9a 100644 --- a/cmd/tg/download_test.go +++ b/cmd/tg/download_test.go @@ -100,7 +100,25 @@ func TestGetMessageScopedToChat(t *testing.T) { if msg, err := getMessage(ctx, api, &tg.InputPeerUser{UserID: 999, AccessHash: 1}, 5); err != nil || msg.ID != 5 { t.Errorf("same chat: %v, %v", msg, err) } - if _, err := getMessage(ctx, api, &tg.InputPeerSelf{}, 5); err != nil { - t.Errorf("self is not checked: %v", err) +} + +func TestGetMessageSavedMessagesIsolation(t *testing.T) { + // For "me", a message id from another private chat must not be returned + // as if it were in Saved Messages. + users := []tg.UserClass{&tg.User{ID: 42, Self: true}, &tg.User{ID: 999}} + respond := func(peerID int64) *tg.Client { + return newFuncAPI(t, func(bin.Encoder) (bin.Encoder, error) { + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{&tg.Message{ID: 5, PeerID: &tg.PeerUser{UserID: peerID}}}, + Users: users, + }, nil + }) + } + ctx := context.Background() + if _, err := getMessage(ctx, respond(999), &tg.InputPeerSelf{}, 5); err == nil { + t.Error("a message from another private chat must not be returned for me") + } + if msg, err := getMessage(ctx, respond(42), &tg.InputPeerSelf{}, 5); err != nil || msg.ID != 5 { + t.Errorf("saved message: %v, %v", msg, err) } }