From 99f386820d9dbd37ef4f2402dc27443ef749e00a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:47:40 +0100 Subject: [PATCH] fix(tgbot): treat messages sent on behalf of a chat as strangers Telegram fills a message's From with one of three fixed placeholder users whenever it is posted on behalf of a chat, by an anonymous group admin or a channel. The level gate and the invite claim took that placeholder for the account acting, so it got a throttle window of its own, and a claim bound the client to an id every anonymous admin shares. Such a message is rated as a stranger whatever the placeholder id is bound to, and its invite claim is refused before it is counted or bound. --- .../web/service/tgbot/tgbot_invite_test.go | 39 +++++++++++++++++++ internal/web/service/tgbot/tgbot_level.go | 11 +++++- .../web/service/tgbot/tgbot_level_test.go | 18 +++++++++ internal/web/service/tgbot/tgbot_router.go | 8 +++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/internal/web/service/tgbot/tgbot_invite_test.go b/internal/web/service/tgbot/tgbot_invite_test.go index 936e91c65..b69d99b25 100644 --- a/internal/web/service/tgbot/tgbot_invite_test.go +++ b/internal/web/service/tgbot/tgbot_invite_test.go @@ -216,6 +216,45 @@ func TestInviteAttemptLimit(t *testing.T) { } } +// Regression test: anonymous admins, channels and linked-channel posts all arrive +// under three fixed user ids. A claim under one opened a throttle window of its +// own and bound the client to everybody who can post that way. +func TestClaimSentForChatIsRefused(t *testing.T) { + tb, calls := newInviteTgbot(t, "invitee@x") + + origBy := inviteAttemptsBy + inviteAttemptsBy = map[int64]*inviteAttempts{} + t.Cleanup(func() { inviteAttemptsBy = origBy }) + + placeholders := []telego.User{ + {ID: 1087968824, IsBot: true, Username: "GroupAnonymousBot"}, + {ID: 136817688, IsBot: true, Username: "Channel_Bot"}, + {ID: 777000, FirstName: "Telegram"}, + } + for _, from := range placeholders { + message := commandFrom(from.ID, "/start "+mustInvitePayload(t, "sub-invite")) + message.From = &from + message.Chat = telego.Chat{ID: -1001234500001, Type: telego.ChatTypeSupergroup} + message.SenderChat = &message.Chat + + if isAdmin, ok := tb.gateCommand(message); ok { + tb.answerCommand(message, message.Chat.ID, isAdmin) + } + if got := tb.levelOf(from.ID); got != levelStranger { + t.Errorf("placeholder user %d claimed the invite: level %d", from.ID, got) + } + } + if len(inviteAttemptsBy) != 0 { + t.Errorf("throttle windows = %d, want none for placeholder users", len(inviteAttemptsBy)) + } + if n := calls("sendMessage"); n != len(placeholders) { + t.Errorf("sendMessage calls = %d, want %d refusals", n, len(placeholders)) + } + if outcome, _ := tb.resolveInviteToken("sub-invite", 8080); outcome != inviteBindable { + t.Errorf("owner's outcome afterwards = %d, want bindable", outcome) + } +} + func TestTgUserMentionEscapesName(t *testing.T) { got := tgUserMention(&telego.User{ID: 42, FirstName: "Eve", Username: "eve"}) want := `<b>Eve</b> @eve` diff --git a/internal/web/service/tgbot/tgbot_level.go b/internal/web/service/tgbot/tgbot_level.go index 0918594ea..79371a740 100644 --- a/internal/web/service/tgbot/tgbot_level.go +++ b/internal/web/service/tgbot/tgbot_level.go @@ -46,10 +46,19 @@ func commandAllowed(level userLevel, command string) bool { return commandsByLevel[level][command] } +// sentForChat reports that no account stands behind message.From: a post by an +// anonymous admin or a channel carries a placeholder user shared by everybody. +func sentForChat(message *telego.Message) bool { + return message.SenderChat != nil || message.From.IsBot +} + // gateCommand reports whether a command reaches answerCommand, and as whom. A // stranger's refused command gets no reply, so the bot reveals nothing to probe. func (t *Tgbot) gateCommand(message *telego.Message) (isAdmin bool, ok bool) { - level := t.levelOf(message.From.ID) + level := levelStranger + if !sentForChat(message) { + level = t.levelOf(message.From.ID) + } command, _, _ := tu.ParseCommand(message.Text) if commandAllowed(level, command) { return level == levelAdmin, true diff --git a/internal/web/service/tgbot/tgbot_level_test.go b/internal/web/service/tgbot/tgbot_level_test.go index 70c7590c1..47d0ad66c 100644 --- a/internal/web/service/tgbot/tgbot_level_test.go +++ b/internal/web/service/tgbot/tgbot_level_test.go @@ -79,6 +79,24 @@ func TestGateCommand(t *testing.T) { } } +// Regression test: a message sent on behalf of a chat carries a placeholder user +// shared by everybody, so whatever that id is bound to, its sender is a stranger. +func TestGateCommandIgnoresPlaceholderSender(t *testing.T) { + tb, calls := newLevelTgbot(t) + + for _, from := range []int64{ownerTgID, 1} { + message := commandFrom(from, "/usage "+ownerMail) + message.Chat = telego.Chat{ID: -100200, Type: telego.ChatTypeSupergroup} + message.SenderChat = &message.Chat + if isAdmin, ok := tb.gateCommand(message); ok || isAdmin { + t.Errorf("gateCommand(%d for a chat) = (%v, %v), want (false, false)", from, isAdmin, ok) + } + } + if n := calls("sendMessage"); n != 0 { + t.Errorf("sendMessage calls = %d, want 0", n) + } +} + // Regression test: a stranger's forged callback must be answered, so the button // stops spinning, and must never reach answerCallback. func TestGateCallbackStopsStrangers(t *testing.T) { diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go index e883401bb..206ca5c39 100644 --- a/internal/web/service/tgbot/tgbot_router.go +++ b/internal/web/service/tgbot/tgbot_router.go @@ -258,6 +258,12 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo msg += t.I18nBot("tgbot.commands.pleaseChoose") case "start": if len(commandArgs) > 0 { + // A placeholder user can be neither throttled nor bound, so its claim is + // refused before it counts: the link works from the account's own chat. + if sentForChat(message) { + t.sendNotice(chatId, t.I18nBot("tgbot.messages.inviteInvalid")) + return + } if !isAdmin && !t.allowInviteAttempt(message.From) { t.sendNotice(chatId, t.I18nBot("tgbot.messages.inviteRateLimited")) return @@ -265,7 +271,7 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo t.claimInvite(chatId, message.From.ID, commandArgs[0]) } // A stranger learns only its ChatID, which is what an admin needs to bind it. - if !isAdmin && t.levelOf(message.From.ID) == levelStranger { + if !isAdmin && (sentForChat(message) || t.levelOf(message.From.ID) == levelStranger) { t.sendNotice(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(message.From.ID, 10))) return }