From 0d318a8627e8aa35af042d456b7d93bc28ba48f4 Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Tue, 26 May 2026 14:34:20 -0700 Subject: [PATCH] Filter `rest` to drop colliding keys --- packages/apps/src/contexts/activity.test.ts | 79 +++++++++++++++++++++ packages/apps/src/contexts/activity.ts | 31 ++++++++ 2 files changed, 110 insertions(+) diff --git a/packages/apps/src/contexts/activity.test.ts b/packages/apps/src/contexts/activity.test.ts index 108260f8d..d2457e058 100644 --- a/packages/apps/src/contexts/activity.test.ts +++ b/packages/apps/src/contexts/activity.test.ts @@ -641,4 +641,83 @@ describe('ActivityContext', () => { }); }); }); + + describe('constructor — prototype method shadowing', () => { + it('drops context properties that would shadow prototype methods', () => { + const activity = buildIncomingMessageActivity('Hello world'); + const malicious = { + send: jest.fn(), + reply: jest.fn(), + quote: jest.fn(), + signin: jest.fn(), + signout: jest.fn(), + }; + + const ctx = new ActivityContext({ + appId: 'test-app', + activity, + ref: mockRef, + log: mockLogger, + api: mockApiClient, + appGraph: {} as GraphClient, + userGraph: {} as GraphClient, + storage: mockStorage, + connectionName: 'test-connection', + next: jest.fn(), + activitySender: mockSender, + ...malicious, + } as any); + + for (const name of ['send', 'reply', 'quote', 'signin', 'signout'] as const) { + expect(Object.prototype.hasOwnProperty.call(ctx, name)).toBe(false); + expect(ctx[name]).toBe(ActivityContext.prototype[name]); + } + }); + + it('still allows new properties from extra context', () => { + const activity = buildIncomingMessageActivity('Hello world'); + const ctx = new ActivityContext({ + appId: 'test-app', + activity, + ref: mockRef, + log: mockLogger, + api: mockApiClient, + appGraph: {} as GraphClient, + userGraph: {} as GraphClient, + storage: mockStorage, + connectionName: 'test-connection', + next: jest.fn(), + activitySender: mockSender, + customField: 'still here', + } as any); + + expect((ctx as any).customField).toBe('still here'); + }); + + it('routes ctx.send() to the prototype method even when a colliding key is supplied', async () => { + const activity = buildIncomingMessageActivity('Hello world'); + const maliciousSend = jest.fn(); + + const ctx = new ActivityContext({ + appId: 'test-app', + activity, + ref: mockRef, + log: mockLogger, + api: mockApiClient, + appGraph: {} as GraphClient, + userGraph: {} as GraphClient, + storage: mockStorage, + connectionName: 'test-connection', + next: jest.fn(), + activitySender: mockSender, + // Simulates a plugin's onActivity context attempting to inject its own send. + send: maliciousSend, + } as any); + + await ctx.send({ type: 'message', text: 'real send' }); + + expect(maliciousSend).not.toHaveBeenCalled(); + expect(mockSender.send).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index e9072cae4..5b81a376b 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -237,6 +237,16 @@ export class ActivityContext)[key]; + } + } + Object.assign(this, rest); this.activitySender = activitySender; this.next = next; @@ -485,3 +495,24 @@ export class ActivityContext = (() => { + const names = new Set(); + let proto: object | null = ActivityContext.prototype; + while (proto && proto !== Object.prototype) { + for (const name of Object.getOwnPropertyNames(proto)) { + if (name === 'constructor') continue; + const descriptor = Object.getOwnPropertyDescriptor(proto, name); + if (!descriptor) continue; + if (typeof descriptor.value === 'function' || typeof descriptor.get === 'function') { + names.add(name); + } + } + proto = Object.getPrototypeOf(proto); + } + return names; +})();