Skip to content

fix(msteams): prefer personal conversation in findByUserId to prevent DM misrouting#53458

Closed
openperf wants to merge 4 commits intoopenclaw:mainfrom
openperf:fix/msteams-findByUserId-prefer-personal
Closed

fix(msteams): prefer personal conversation in findByUserId to prevent DM misrouting#53458
openperf wants to merge 4 commits intoopenclaw:mainfrom
openperf:fix/msteams-findByUserId-prefer-personal

Conversation

@openperf
Copy link
Copy Markdown
Contributor

Problem

Fixes #51947

When a user has both a 1:1 DM and a channel/group conversation with the bot, proactive sends
initiated from a DM session may be delivered to the wrong conversation. For example, an agent
sending an Adaptive Card via the send tool in a 1:1 chat could have that card appear in a
team channel instead.

Root cause

For DMs, the inbound context sets To as user:{aadObjectId}. When the send tool later
resolves the target via resolveMSTeamsSendContext, it calls findByUserId in the conversation
store, which iterates all stored conversations and returns the first entry matching the
user's AAD object ID or Bot Framework ID — with no awareness of conversation type or recency.

Since Object.entries() iteration order depends on insertion order in the JSON store, the
result is non-deterministic from the user's perspective: if the channel/group conversation
was stored before the personal DM, the proactive message is delivered there instead.

Both the filesystem store (conversation-store-fs.ts) and the in-memory store
(conversation-store-memory.ts) share this behavior.

Solution

Update findByUserId in both store implementations to collect all matching entries for a
user, then apply a preference:

  1. Prefer personal (1:1) conversations — when a user lookup is performed (typically for
    DM-initiated proactive sends), the personal conversation is the semantically correct target.
  2. Fall back deterministically — when no personal conversation exists, the fs store returns
    the most recently seen entry (by lastSeenAt timestamp, which already exists in the
    persisted data), and the memory store returns the first match.

This approach was chosen over changing the To format (Option 2 in the issue) or threading
session context (Option 3/4) because:

  • It fixes the bug at the source (findByUserId) without changing the inbound context format,
    avoiding downstream breakage in code paths that parse user: prefix for DM detection.
  • It leverages the conversationType field already stored in every conversation reference.
  • It fixes all callers of findByUserId, not just the DM inbound path.
  • The lastSeenAt field already exists in the fs store data, so no schema changes are needed.

Changed files

File Change
extensions/msteams/src/conversation-store-fs.ts findByUserId collects all matches, prefers personal, falls back to most recent
extensions/msteams/src/conversation-store-memory.ts findByUserId collects all matches, prefers personal, falls back to first match
extensions/msteams/src/conversation-store-fs.test.ts Adds tests for personal preference and recency fallback
extensions/msteams/src/conversation-store-memory.test.ts Adds tests for personal preference, fallback, and single-conversation case

Testing

New test cases cover:

  • Personal preference: user has group + personal + channel conversations → findByUserId
    returns the personal one regardless of insertion order
  • Recency fallback (fs): user has only group conversations → returns the most recently
    seen entry
  • First-match fallback (memory): user has only group conversations → returns the first
    match deterministically
  • Single conversation: user has only one conversation → behavior unchanged

Existing tests continue to pass (no behavioral change for single-conversation users).

Reproduction scenario

  1. Have a bot added to both a 1:1 chat and a team channel
  2. In the 1:1 chat, ask the bot to send you an Adaptive Card (or any proactive message via
    the send tool)
  3. Before fix: the card may appear in the channel instead of the 1:1 chat
  4. After fix: the card is always delivered to the 1:1 chat

@openclaw-barnacle openclaw-barnacle bot added channel: msteams Channel integration: msteams size: S labels Mar 24, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 24, 2026

Greptile Summary

This PR fixes a DM misrouting bug in the MS Teams integration where proactive sends could be delivered to a team channel instead of the intended 1:1 chat when a user has multiple conversations with the bot. The fix updates findByUserId in both conversation-store-fs.ts and conversation-store-memory.ts to collect all matching conversations for a user and then deterministically select the best one — preferring personal (1:1) conversations over group/channel, and falling back to the most recently seen entry (fs store) or first match (memory store) when no personal conversation exists.

Key changes:

  • conversation-store-fs.ts: Collects all user matches, prefers personal, falls back to highest lastSeenAt timestamp using Array.prototype.toSorted (safe on Node ≥ 22). Uses a necessary type cast to access lastSeenAt since list() narrows the reference type to StoredConversationReference, which omits the field — the field is always present at runtime in this store.
  • conversation-store-memory.ts: Same personal-preference logic; falls back to first insertion-order match as the memory store has no timestamps.
  • Tests: New tests in both test files cover personal preference with out-of-order insertion, non-personal fallback, and single-conversation stability. The recency fallback test relies on a 50 ms wall-clock delay which is acceptable given that lastSeenAt is set from new Date().toISOString() during each upsert.

Confidence Score: 5/5

  • Safe to merge — targeted, well-tested fix with no behavioural change for single-conversation users.
  • The fix is minimal and surgical: it only changes the selection logic inside findByUserId without touching any other code paths. Single-conversation users hit the early-return path and see no change. The personal-preference branch resolves the reported bug deterministically, and the recency-based fallback makes the non-personal case predictable rather than insertion-order dependent. Both new behaviours are covered by new tests. No schema changes, no public API changes, and the Node ≥ 22 engine requirement means toSorted is available.
  • No files require special attention.

Reviews (1): Last reviewed commit: "fix(msteams): prefer personal conversati..." | Re-trigger Greptile

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 726c42dd20

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

openperf added a commit to openperf/moltbot that referenced this pull request Mar 24, 2026
…e check

The rest of the codebase lowercases conversationType before comparison
(graph.ts, file-consent-helpers.ts, messenger.ts, monitor-handler.ts,
reply-dispatcher.ts). Align findByUserId to do the same so upstream
values like "Personal" or "PERSONAL" are correctly recognized.

Addresses Codex review feedback on openclaw#53458.
@openperf openperf force-pushed the fix/msteams-findByUserId-prefer-personal branch from e539f94 to a10611d Compare March 24, 2026 07:39
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 24, 2026

🔒 Aisle Security Analysis

We found 3 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium TypeError DoS in findByUserId via non-string conversationType .toLowerCase() call (FS store)
2 🟡 Medium TypeError DoS in findByUserId via non-string conversationType .toLowerCase() call (memory store)
3 🟡 Medium Conversation reference lookup not scoped by tenant, enabling cross-tenant proactive message misrouting
Vulnerabilities

1. 🟡 TypeError DoS in findByUserId via non-string conversationType .toLowerCase() call (FS store)

Property Value
Severity Medium
CWE CWE-248
Location extensions/msteams/src/conversation-store-fs.ts:138-140

Description

findByUserId attempts to prefer a personal conversation by calling .toLowerCase() on conversationType.

  • conversationType is loaded from the persisted JSON state file (msteams-conversations.json).
  • The code uses optional chaining on the value (conversationType?.toLowerCase()), which only guards against null/undefined.
  • If a corrupted/tampered state file (or otherwise untrusted data) sets conversationType to a non-string (e.g., object/number), the expression will attempt to call an undefined toLowerCase function and throw a TypeError.
  • The exception is not caught in findByUserId, so it can crash the request/turn and cause a denial of service.

Vulnerable code:

const personal = matches.find(
  (m) => m.reference.conversation?.conversationType?.toLowerCase() === "personal",
);

Recommendation

Harden the check to only call .toLowerCase() on strings.

const ct = m.reference.conversation?.conversationType;
return typeof ct === "string" && ct.toLowerCase() === "personal";

Alternatively, use optional-call chaining on the method, but still ensure the value is a string if the semantics matter:

const ct = m.reference.conversation?.conversationType;
return ct?.toLowerCase?.() === "personal";

(Prefer the typeof ct === "string" guard to avoid treating objects with a custom toLowerCase as valid.)


2. 🟡 TypeError DoS in findByUserId via non-string conversationType .toLowerCase() call (memory store)

Property Value
Severity Medium
CWE CWE-248
Location extensions/msteams/src/conversation-store-memory.ts:57-59

Description

findByUserId in the in-memory conversation store attempts to pick a personal conversation by calling .toLowerCase() on conversationType.

  • Optional chaining (conversationType?.toLowerCase()) only prevents calls when conversationType is null/undefined.
  • If a stored reference contains a non-string conversationType (e.g., object/number from malformed/untrusted activity payload or internal corruption), the .toLowerCase() property access/call will throw a TypeError.
  • The exception is not caught in findByUserId, potentially crashing the handler/turn and causing denial of service.

Vulnerable code:

matches.find(
  (m) => m.reference.conversation?.conversationType?.toLowerCase() === "personal",
)

Recommendation

Guard the type before calling .toLowerCase().

const ct = m.reference.conversation?.conversationType;
return typeof ct === "string" && ct.toLowerCase() === "personal";

If you prefer a shorter form, use optional-call chaining but be aware it allows objects with a toLowerCase function:

const ct = m.reference.conversation?.conversationType;
return ct?.toLowerCase?.() === "personal";

3. 🟡 Conversation reference lookup not scoped by tenant, enabling cross-tenant proactive message misrouting

Property Value
Severity Medium
CWE CWE-284
Location extensions/msteams/src/conversation-store-fs.ts:121-151

Description

findByUserId() selects a stored conversation reference solely by matching reference.user.aadObjectId or reference.user.id.

  • Stored entries include reference.conversation.tenantId, but it is not used during lookup.
  • If the bot is installed in multiple tenants (or stores entries across environments), a collision or reuse of user identifiers across tenants can cause the wrong conversation reference to be returned.
  • The new selection logic (prefer conversationType: "personal") increases the chance of deterministically choosing the wrong tenant’s personal chat when multiple matches exist, leading to proactive messages being sent to an unintended recipient (data exposure / conversation hijack).

Vulnerable code:

if (reference.user?.aadObjectId === target || reference.user?.id === target) {
  matches.push(entry);
}
...
const personal = matches.find(
  (m) => m.reference.conversation?.conversationType?.toLowerCase() === "personal",
);

Recommendation

Scope conversation reference storage and lookup by tenant (and ideally by bot/app installation).

Option A (recommended): store keyed by tenantId + user id

  • Require callers of findByUserId to pass tenantId.
  • Filter matches by reference.conversation.tenantId.

Example:

async function findByUser(params: { tenantId: string; userId: string }) {
  const target = params.userId.trim();
  const tenantId = params.tenantId;
  const matches = (await list()).filter((e) =>
    (e.reference.user?.aadObjectId === target || e.reference.user?.id === target) &&
    e.reference.conversation?.tenantId === tenantId,
  );// then apply personal/lastSeen selection within the tenant
}

Option B: maintain separate stores per tenant (separate file per tenant) so collisions cannot occur.

Also consider verifying that proactive send flows always provide tenant context (from config/recipient) so lookups cannot accidentally cross tenants.


Analyzed PR: #53458 at commit b050e2f

Last updated on: 2026-03-27T05:38:00Z

Latest run failed. Keeping previous successful results. Trace ID: 019d2dd58f9e81390f18d4d935bd7ece.

Last updated on: 2026-03-27T08:34:16Z

@openperf openperf force-pushed the fix/msteams-findByUserId-prefer-personal branch from a10611d to 6b6352e Compare March 26, 2026 05:48
openperf added a commit to openperf/moltbot that referenced this pull request Mar 26, 2026
…e check

The rest of the codebase lowercases conversationType before comparison
(graph.ts, file-consent-helpers.ts, messenger.ts, monitor-handler.ts,
reply-dispatcher.ts). Align findByUserId to do the same so upstream
values like "Personal" or "PERSONAL" are correctly recognized.

Addresses Codex review feedback on openclaw#53458.
… DM misrouting

When a user has both a 1:1 DM and a channel/group conversation with the
bot, findByUserId returned whichever entry happened to appear first in
the store iteration order. This caused proactive sends initiated from a
DM session (e.g. agent sending an Adaptive Card via the send tool) to be
delivered to the wrong conversation.

Collect all matching entries and prefer the one with
conversationType === "personal". When no personal conversation exists,
the fs store falls back to the most recently seen entry (by lastSeenAt)
and the memory store falls back to the first match.

Fixes openclaw#51947
…e check

The rest of the codebase lowercases conversationType before comparison
(graph.ts, file-consent-helpers.ts, messenger.ts, monitor-handler.ts,
reply-dispatcher.ts). Align findByUserId to do the same so upstream
values like "Personal" or "PERSONAL" are correctly recognized.

Addresses Codex review feedback on openclaw#53458.
@openperf openperf force-pushed the fix/msteams-findByUserId-prefer-personal branch from 6b6352e to b050e2f Compare March 27, 2026 05:35
@openperf openperf force-pushed the fix/msteams-findByUserId-prefer-personal branch from e9111fa to 3dd4696 Compare March 27, 2026 08:46
@openperf openperf closed this Mar 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: msteams Channel integration: msteams size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

proactive send from DM may route to wrong conversation when user has multiple conversations

1 participant