fix: harden msteams mentions and fallback links (#15436) (thanks @hyojin)

This commit is contained in:
Peter Steinberger
2026-02-13 15:08:48 +01:00
parent 604dc700a6
commit 106d605519
5 changed files with 102 additions and 8 deletions

View File

@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent.
- MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive.

View File

@@ -121,6 +121,18 @@ describe("parseMentions", () => {
expect(result.entities[0]?.mentioned.id).toBe("28:abc-123");
});
it("accepts Bot Framework IDs with non-hex payloads (29:xxx)", () => {
const result = parseMentions("@[Bot](29:08q2j2o3jc09au90eucae)");
expect(result.entities).toHaveLength(1);
expect(result.entities[0]?.mentioned.id).toBe("29:08q2j2o3jc09au90eucae");
});
it("accepts org-scoped IDs with extra segments (8:orgid:...)", () => {
const result = parseMentions("@[User](8:orgid:2d8c2d2c-1111-2222-3333-444444444444)");
expect(result.entities).toHaveLength(1);
expect(result.entities[0]?.mentioned.id).toBe("8:orgid:2d8c2d2c-1111-2222-3333-444444444444");
});
it("accepts AAD object IDs (UUIDs)", () => {
const result = parseMentions("@[User](a1b2c3d4-e5f6-7890-abcd-ef1234567890)");
expect(result.entities).toHaveLength(1);

View File

@@ -25,17 +25,17 @@ export type MentionInfo = {
/**
* Check whether an ID looks like a valid Teams user/bot identifier.
* Accepts:
* - Bot Framework IDs: "28:xxx..." or "29:xxx..."
* - Bot Framework IDs: "28:xxx..." / "29:xxx..." / "8:orgid:..."
* - AAD object IDs (UUIDs): "d5318c29-33ac-4e6b-bd42-57b8b793908f"
*
* This prevents false positives from text like `@[表示名](ユーザーID)`
* that appears in code snippets or documentation within messages.
* Keep this permissive enough for real Teams IDs while still rejecting
* documentation placeholders like `@[表示名](ユーザーID)`.
*/
const TEAMS_ID_PATTERN =
/^(?:\d+:[a-f0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i;
const TEAMS_BOT_ID_PATTERN = /^\d+:[a-z0-9._=-]+(?::[a-z0-9._=-]+)*$/i;
const AAD_OBJECT_ID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
function isValidTeamsId(id: string): boolean {
return TEAMS_ID_PATTERN.test(id);
return TEAMS_BOT_ID_PATTERN.test(id) || AAD_OBJECT_ID_PATTERN.test(id);
}
/**

View File

@@ -1,6 +1,21 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StoredConversationReference } from "./conversation-store.js";
const graphUploadMockState = vi.hoisted(() => ({
uploadAndShareOneDrive: vi.fn(),
}));
vi.mock("./graph-upload.js", async () => {
const actual = await vi.importActual<typeof import("./graph-upload.js")>("./graph-upload.js");
return {
...actual,
uploadAndShareOneDrive: graphUploadMockState.uploadAndShareOneDrive,
};
});
import {
type MSTeamsAdapter,
renderReplyPayloadsToMessages,
@@ -36,6 +51,13 @@ const runtimeStub = {
describe("msteams messenger", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
graphUploadMockState.uploadAndShareOneDrive.mockReset();
graphUploadMockState.uploadAndShareOneDrive.mockResolvedValue({
itemId: "item123",
webUrl: "https://onedrive.example.com/item123",
shareUrl: "https://onedrive.example.com/share/item123",
name: "upload.txt",
});
});
describe("renderReplyPayloadsToMessages", () => {
@@ -153,6 +175,64 @@ describe("msteams messenger", () => {
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
});
it("preserves parsed mentions when appending OneDrive fallback file links", async () => {
const tmpDir = await mkdtemp(path.join(os.tmpdir(), "msteams-mention-"));
const localFile = path.join(tmpDir, "note.txt");
await writeFile(localFile, "hello");
try {
const sent: Array<{ text?: string; entities?: unknown[] }> = [];
const ctx = {
sendActivity: async (activity: unknown) => {
sent.push(activity as { text?: string; entities?: unknown[] });
return { id: "id:one" };
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
};
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: {
...baseRef,
conversation: {
...baseRef.conversation,
conversationType: "channel",
},
},
context: ctx,
messages: [{ text: "Hello @[John](29:08q2j2o3jc09au90eucae)", mediaUrl: localFile }],
tokenProvider: {
getAccessToken: async () => "token",
},
});
expect(ids).toEqual(["id:one"]);
expect(graphUploadMockState.uploadAndShareOneDrive).toHaveBeenCalledOnce();
expect(sent).toHaveLength(1);
expect(sent[0]?.text).toContain("Hello <at>John</at>");
expect(sent[0]?.text).toContain(
"📎 [upload.txt](https://onedrive.example.com/share/item123)",
);
expect(sent[0]?.entities).toEqual([
{
type: "mention",
text: "<at>John</at>",
mentioned: {
id: "29:08q2j2o3jc09au90eucae",
name: "John",
},
},
]);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("retries thread sends on throttling (429)", async () => {
const attempts: string[] = [];
const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];

View File

@@ -358,7 +358,8 @@ async function buildActivity(
// Bot Framework doesn't support "reference" attachment type for sending
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
activity.text = msg.text ? `${msg.text}\n\n${fileLink}` : fileLink;
const existingText = typeof activity.text === "string" ? activity.text : undefined;
activity.text = existingText ? `${existingText}\n\n${fileLink}` : fileLink;
return activity;
}