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
}