Skip to content

Commit 9dee30b

Browse files
trakhimenokclaude
andcommitted
fix(botsfw)!: scope bot settings lookup by platform
GetBotContext accepted a platformID parameter and never read it. It resolved purely by bot ID or code, so the platform a webhook arrived on had no bearing on which bot's settings came back. With Telegram as the only framework-side platform this was invisible. bots-fw-whatsapp now exists, which is exactly what makes it reachable: a WhatsApp webhook could resolve a Telegram bot's settings - including its Token and WebhookSecretToken - whenever the two shared an ID or code. Note bots-fw-telegram's handler passes PlatformID and then verifies its secret against whatever settings came back. Two bugs, one root cause: bot codes were treated as globally unique when they are only unique per platform. 1. GetBotContext is now platform-scoped, via BotSettingsBy.findByPlatform. A bot on another platform returns ErrUnknownBot rather than being silently substituted. A blank platformID is rejected. 2. NewBotSettingsBy panicked on duplicate codes regardless of platform, so the same product could not be registered on two platforms - "debtus" on Telegram and "debtus" on WhatsApp are different bots with different tokens. It now panics only on a duplicate WITHIN a platform. This is what made a second platform registrable at all. Added BotSettingsBy.ByPlatformAndCode / ByPlatformAndID. ByCode and ByID are kept populated (first-wins) for compatibility but are now deprecated: they are ambiguous across platforms by construction. The legacy fallback in findByPlatform still checks Platform, so it cannot reintroduce the leak it exists to be compatible with. The test suite had ENCODED the bug: three separate fixtures hand-built a BotSettings with no Platform and passed only because platformID was ignored. All three are fixed rather than worked around. NewBotSettings has always panicked on an empty platform, so those fixtures were malformed - they bypassed the constructor. BREAKING: - A BotSettings with an empty Platform now panics in NewBotSettingsBy. Previously accepted, but such a bot can never be resolved by the platform-scoped GetBotContext, so it was silently unreachable. Failing at registration beats failing at 3am. - GetBotContext returns ErrUnknownBot for a bot that exists on a different platform. That is the fix, not a regression. Verified: bots-fw-telegram builds and its tests pass against this branch (temporary local replace, reverted; that repo is untouched). Full suite green, gofmt + vet + golangci-lint clean. Background: spec/research/bots-fw-platform-neutrality-gap-analysis.md "Seam bug" in bots-go-framework/backstage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 21cf952 commit 9dee30b

4 files changed

Lines changed: 222 additions & 25 deletions

File tree

botsfw/bot_context.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,26 @@ func NewBotContextProvider(botHost BotHost, appContext AppContext, botSettingPro
4444

4545
var ErrUnknownBot = errors.New("unknown bot")
4646

47+
// GetBotContext returns the BotContext for a bot on a specific platform.
48+
//
49+
// The lookup is scoped to platformID. This matters: before it was, this method
50+
// accepted platformID and ignored it, resolving purely by bot ID or code. With a
51+
// single platform that was invisible. With two, a webhook for platform A could
52+
// resolve platform B's settings — including its Token and WebhookSecretToken —
53+
// whenever the two shared an ID or code.
54+
//
55+
// Returns ErrUnknownBot when no bot with that ID or code exists ON THAT PLATFORM,
56+
// even if one exists elsewhere. That is deliberate: a caller asking for a WhatsApp
57+
// bot must never be handed a Telegram one.
4758
func (v botContextProvider) GetBotContext(ctx context.Context, platformID botsfwconst.Platform, botID string) (botContext *BotContext, err error) {
59+
if platformID == "" {
60+
return nil, errors.New("required parameter platformID is empty")
61+
}
4862
botSettingsBy := v.botSettingProvider(ctx)
49-
botSettings, ok := botSettingsBy.ByID[botID]
50-
if !ok {
51-
if botSettings, ok = botSettingsBy.ByCode[botID]; !ok {
52-
return nil, ErrUnknownBot
53-
}
63+
64+
botSettings := botSettingsBy.findByPlatform(platformID, botID)
65+
if botSettings == nil {
66+
return nil, ErrUnknownBot
5467
}
5568
return &BotContext{
5669
AppContext: v.appContext,

botsfw/bot_context_test.go

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net/http"
66
"testing"
77

8+
"github.com/bots-go-framework/bots-fw/botsfwconst"
89
"github.com/stretchr/testify/assert"
910
)
1011

@@ -53,7 +54,10 @@ func (p testBotSettingsProvider) provide(_ context.Context) BotSettingsBy {
5354

5455
func TestNewBotContextProvider(t *testing.T) {
5556
host := testBotHost{}
56-
settings := &BotSettings{Code: "mybot"}
57+
// Platform is required: GetBotContext is platform-scoped, and NewBotSettings
58+
// panics without one. A BotSettings with a blank Platform is malformed and no
59+
// longer resolves for any platform.
60+
settings := &BotSettings{Code: "mybot", Platform: botsfwconst.PlatformTelegram}
5761
settingsBy := BotSettingsBy{
5862
ByCode: map[string]*BotSettings{"mybot": settings},
5963
ByID: map[string]*BotSettings{},
@@ -99,3 +103,88 @@ func TestNewBotContextProvider(t *testing.T) {
99103
assert.ErrorIs(t, err, ErrUnknownBot)
100104
})
101105
}
106+
107+
// TestGetBotContext_isPlatformScoped is the security regression test.
108+
//
109+
// Before this was scoped, GetBotContext accepted platformID and ignored it,
110+
// resolving purely by ID or code. With Telegram as the only platform that was
111+
// invisible. With two, a WhatsApp webhook could resolve a Telegram bot's settings
112+
// — including its Token and WebhookSecretToken.
113+
func TestGetBotContext_isPlatformScoped(t *testing.T) {
114+
tgBot := BotSettings{
115+
Platform: botsfwconst.PlatformTelegram, Code: "debtus", ID: "debtus",
116+
Token: "TELEGRAM-SECRET-TOKEN", Profile: newTestProfile("debtus-profile"),
117+
}
118+
waBot := BotSettings{
119+
Platform: botsfwconst.PlatformWhatsApp, Code: "debtus", ID: "debtus",
120+
Token: "WHATSAPP-SECRET-TOKEN", Profile: newTestProfile("debtus-profile"),
121+
}
122+
123+
// The same product on two platforms shares a code. This must be registrable —
124+
// it used to panic with "Bot with duplicate code".
125+
settingsBy := NewBotSettingsBy(tgBot, waBot)
126+
provider := testBotSettingsProvider{settingsBy: settingsBy}
127+
bcp := NewBotContextProvider(testBotHost{}, &testAppContext{}, provider.provide)
128+
ctx := context.Background()
129+
130+
t.Run("each platform resolves its own bot", func(t *testing.T) {
131+
tg, err := bcp.GetBotContext(ctx, botsfwconst.PlatformTelegram, "debtus")
132+
assert.NoError(t, err)
133+
assert.Equal(t, "TELEGRAM-SECRET-TOKEN", tg.BotSettings.Token)
134+
135+
wa, err := bcp.GetBotContext(ctx, botsfwconst.PlatformWhatsApp, "debtus")
136+
assert.NoError(t, err)
137+
assert.Equal(t, "WHATSAPP-SECRET-TOKEN", wa.BotSettings.Token,
138+
"a WhatsApp webhook must never be handed Telegram's token")
139+
})
140+
141+
t.Run("a bot on another platform is unknown, not silently substituted", func(t *testing.T) {
142+
_, err := bcp.GetBotContext(ctx, botsfwconst.Platform("viber"), "debtus")
143+
assert.ErrorIs(t, err, ErrUnknownBot)
144+
})
145+
146+
t.Run("blank platform is rejected", func(t *testing.T) {
147+
_, err := bcp.GetBotContext(ctx, "", "debtus")
148+
assert.Error(t, err)
149+
})
150+
}
151+
152+
// TestGetBotContext_legacyFlatMapsStillCheckPlatform pins that the compatibility
153+
// fallback for a hand-built BotSettingsBy cannot reintroduce the leak it exists to
154+
// be compatible with.
155+
func TestGetBotContext_legacyFlatMapsStillCheckPlatform(t *testing.T) {
156+
tgOnly := &BotSettings{Platform: botsfwconst.PlatformTelegram, Code: "mybot"}
157+
settingsBy := BotSettingsBy{ // built by hand, no platform maps
158+
ByCode: map[string]*BotSettings{"mybot": tgOnly},
159+
ByID: map[string]*BotSettings{},
160+
}
161+
bcp := NewBotContextProvider(testBotHost{}, &testAppContext{},
162+
testBotSettingsProvider{settingsBy: settingsBy}.provide)
163+
ctx := context.Background()
164+
165+
bc, err := bcp.GetBotContext(ctx, botsfwconst.PlatformTelegram, "mybot")
166+
assert.NoError(t, err, "the legacy fallback must still resolve its own platform")
167+
assert.Equal(t, "mybot", bc.BotSettings.Code)
168+
169+
_, err = bcp.GetBotContext(ctx, botsfwconst.PlatformWhatsApp, "mybot")
170+
assert.ErrorIs(t, err, ErrUnknownBot,
171+
"the legacy fallback must NOT hand a Telegram bot to a WhatsApp webhook")
172+
}
173+
174+
// TestNewBotSettingsBy_duplicateCodeAcrossPlatforms pins that codes are unique per
175+
// platform rather than globally — the change that makes a second platform
176+
// registrable at all.
177+
func TestNewBotSettingsBy_duplicateCodeAcrossPlatforms(t *testing.T) {
178+
tg := BotSettings{Platform: botsfwconst.PlatformTelegram, Code: "debtus", Profile: newTestProfile("debtus-profile")}
179+
wa := BotSettings{Platform: botsfwconst.PlatformWhatsApp, Code: "debtus", Profile: newTestProfile("debtus-profile")}
180+
181+
assert.NotPanics(t, func() { NewBotSettingsBy(tg, wa) },
182+
"the same code on two platforms is legitimate")
183+
184+
assert.Panics(t, func() { NewBotSettingsBy(tg, tg) },
185+
"a duplicate code WITHIN a platform is still a bug")
186+
187+
assert.Panics(t, func() {
188+
NewBotSettingsBy(BotSettings{Code: "nope", Profile: newTestProfile("debtus-profile")})
189+
}, "a bot with no platform is malformed")
190+
}

botsfw/settings.go

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -154,48 +154,121 @@ type BotSettingsProvider func(ctx context.Context) BotSettingsBy
154154
// TODO: Decide if it should have map[string]*BotSettings instead of map[string]BotSettings
155155
type BotSettingsBy struct {
156156

157-
// ByCode keeps settings by bot code - it is a human-readable ID of a bot
157+
// ByCode keeps settings by bot code - it is a human-readable ID of a bot.
158+
//
159+
// Deprecated: ambiguous once more than one platform is in play, because the
160+
// same code may legitimately be used for the same product on Telegram and on
161+
// WhatsApp. First registration wins here. Use ByPlatformAndCode, or resolve
162+
// via BotContextProvider.GetBotContext which is platform-scoped.
158163
ByCode map[string]*BotSettings
159164

160165
// ByID keeps settings by bot ID - it is a machine-readable ID of a bot.
166+
//
167+
// Deprecated: ambiguous across platforms, as ByCode. Use ByPlatformAndID.
161168
ByID map[string]*BotSettings
162169

163170
ByProfile map[string][]*BotSettings
171+
172+
// ByPlatformAndCode keeps settings by platform, then bot code.
173+
//
174+
// Bot codes are only unique WITHIN a platform: "debtus" on Telegram and
175+
// "debtus" on WhatsApp are different bots with different tokens.
176+
ByPlatformAndCode map[botsfwconst.Platform]map[string]*BotSettings
177+
178+
// ByPlatformAndID keeps settings by platform, then bot ID.
179+
ByPlatformAndID map[botsfwconst.Platform]map[string]*BotSettings
180+
}
181+
182+
// findByPlatform resolves a bot by ID or code within a single platform.
183+
//
184+
// Returns nil if no such bot exists on that platform, even if one exists on
185+
// another — which is the point.
186+
func (settingsBy BotSettingsBy) findByPlatform(platform botsfwconst.Platform, botID string) *BotSettings {
187+
if byID, ok := settingsBy.ByPlatformAndID[platform]; ok {
188+
if s, ok := byID[botID]; ok {
189+
return s
190+
}
191+
}
192+
if byCode, ok := settingsBy.ByPlatformAndCode[platform]; ok {
193+
if s, ok := byCode[botID]; ok {
194+
return s
195+
}
196+
}
197+
// Fall back to the legacy flat maps for a BotSettingsBy built by hand rather
198+
// than by NewBotSettingsBy — but still verify the platform, so the fallback
199+
// cannot reintroduce the cross-platform leak it exists to be compatible with.
200+
if s, ok := settingsBy.ByID[botID]; ok && s.Platform == platform {
201+
return s
202+
}
203+
if s, ok := settingsBy.ByCode[botID]; ok && s.Platform == platform {
204+
return s
205+
}
206+
return nil
164207
}
165208

166209
// NewBotSettingsBy create settings per different keys (ID, code, API token, Locale)
210+
//
211+
// Bot codes and IDs must be unique WITHIN a platform, not globally. The same
212+
// product on two platforms legitimately shares a code — "debtus" on Telegram and
213+
// "debtus" on WhatsApp are different bots with different tokens — and rejecting
214+
// that made a second platform impossible to register.
167215
func NewBotSettingsBy(bots ...BotSettings) (settingsBy BotSettingsBy) {
168216
count := len(bots)
169217
if count == 0 {
170218
panic("NewBotSettingsBy: missing required parameter: bots")
171219
}
172220
settingsBy = BotSettingsBy{
173-
ByCode: make(map[string]*BotSettings, count),
174-
ByID: make(map[string]*BotSettings, count),
175-
ByProfile: make(map[string][]*BotSettings),
221+
ByCode: make(map[string]*BotSettings, count),
222+
ByID: make(map[string]*BotSettings, count),
223+
ByProfile: make(map[string][]*BotSettings),
224+
ByPlatformAndCode: make(map[botsfwconst.Platform]map[string]*BotSettings),
225+
ByPlatformAndID: make(map[botsfwconst.Platform]map[string]*BotSettings),
176226
}
177227
processBotSettings := func(i int, bot BotSettings) {
178228
if bot.Code == "" {
179229
panic(fmt.Sprintf("Bot with empty code at index %v", i))
180230
}
181-
if _, ok := settingsBy.ByCode[bot.Code]; ok {
182-
panic(fmt.Sprintf("Bot with duplicate code: %v", bot.Code))
183-
} else {
231+
if bot.Platform == "" {
232+
panic(fmt.Sprintf("Bot with empty platform at index %v, code=%v", i, bot.Code))
233+
}
234+
235+
byCode, ok := settingsBy.ByPlatformAndCode[bot.Platform]
236+
if !ok {
237+
byCode = make(map[string]*BotSettings, count)
238+
settingsBy.ByPlatformAndCode[bot.Platform] = byCode
239+
}
240+
if _, ok = byCode[bot.Code]; ok {
241+
panic(fmt.Sprintf("Bot with duplicate code on platform %v: %v", bot.Platform, bot.Code))
242+
}
243+
byCode[bot.Code] = &bot
244+
245+
if bot.ID != "" {
246+
byID, ok := settingsBy.ByPlatformAndID[bot.Platform]
247+
if !ok {
248+
byID = make(map[string]*BotSettings, count)
249+
settingsBy.ByPlatformAndID[bot.Platform] = byID
250+
}
251+
if _, ok = byID[bot.ID]; ok {
252+
panic(fmt.Sprintf("Bot with duplicate ID on platform %v: %v", bot.Platform, bot.ID))
253+
}
254+
byID[bot.ID] = &bot
255+
}
256+
257+
// The legacy flat maps are kept populated for compatibility, first
258+
// registration wins. They are ambiguous across platforms by construction,
259+
// which is why they are deprecated and why GetBotContext no longer trusts
260+
// them without checking Platform.
261+
if _, ok = settingsBy.ByCode[bot.Code]; !ok {
184262
settingsBy.ByCode[bot.Code] = &bot
185263
}
186264
if bot.ID != "" {
187-
if _, ok := settingsBy.ByID[bot.ID]; ok {
188-
panic(fmt.Sprintf("Bot with duplicate ID: %v", bot.ID))
189-
} else {
265+
if _, ok = settingsBy.ByID[bot.ID]; !ok {
190266
settingsBy.ByID[bot.ID] = &bot
191267
}
192268
}
269+
193270
profileID := bot.Profile.ID()
194-
if profileBots, ok := settingsBy.ByProfile[profileID]; ok {
195-
settingsBy.ByProfile[profileID] = append(profileBots, &bot)
196-
} else {
197-
settingsBy.ByProfile[profileID] = []*BotSettings{&bot}
198-
}
271+
settingsBy.ByProfile[profileID] = append(settingsBy.ByProfile[profileID], &bot)
199272
}
200273
for i, bot := range bots {
201274
processBotSettings(i, bot)

botswebhook/settings_test.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,18 +90,34 @@ func TestNewBotSettingsBy(t *testing.T) {
9090
expectsPanic: true,
9191
},
9292
{
93+
// Platform is required: bot codes are unique per platform, and a bot
94+
// with no platform can never be resolved by the platform-scoped
95+
// GetBotContext, so registering one is always a mistake.
9396
name: "single_bot",
9497
args: args{
9598
bots: []botsfw.BotSettings{
9699
{
97-
Profile: testBotProfile,
98-
Code: "TestBot",
99-
ID: "test123",
100+
Platform: botsfwconst.PlatformTelegram,
101+
Profile: testBotProfile,
102+
Code: "TestBot",
103+
ID: "test123",
100104
},
101105
},
102106
},
103107
expectsPanic: false,
104108
},
109+
{
110+
name: "bot_without_platform",
111+
args: args{
112+
bots: []botsfw.BotSettings{
113+
{
114+
Profile: testBotProfile,
115+
Code: "NoPlatformBot",
116+
},
117+
},
118+
},
119+
expectsPanic: true,
120+
},
105121
}
106122
for _, tt := range tests {
107123
t.Run(tt.name, func(t *testing.T) {
@@ -113,7 +129,13 @@ func TestNewBotSettingsBy(t *testing.T) {
113129
}()
114130
}
115131
actual := botsfw.NewBotSettingsBy(tt.args.bots...)
116-
assert.Equal(t, len(tt.args.bots), len(actual.ByCode))
132+
// Count via the platform-scoped map: ByCode is deprecated and
133+
// first-wins, so it under-counts when a code is shared across platforms.
134+
var registered int
135+
for _, byCode := range actual.ByPlatformAndCode {
136+
registered += len(byCode)
137+
}
138+
assert.Equal(t, len(tt.args.bots), registered)
117139
})
118140
}
119141
}

0 commit comments

Comments
 (0)