fix(msteams): bind file consent invokes to conversation

This commit is contained in:
Peter Steinberger
2026-02-26 02:49:41 +01:00
parent 1f004e6640
commit 347f7b9550
3 changed files with 241 additions and 4 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.
- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
- Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling.
- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.
- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231)

View File

@@ -0,0 +1,220 @@
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import type { MSTeamsAdapter } from "./messenger.js";
import {
type MSTeamsActivityHandler,
type MSTeamsMessageHandlerDeps,
registerMSTeamsHandlers,
} from "./monitor-handler.js";
import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js";
import type { MSTeamsPollStore } from "./polls.js";
import { setMSTeamsRuntime } from "./runtime.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
const fileConsentMockState = vi.hoisted(() => ({
uploadToConsentUrl: vi.fn(),
}));
vi.mock("./file-consent.js", async () => {
const actual = await vi.importActual<typeof import("./file-consent.js")>("./file-consent.js");
return {
...actual,
uploadToConsentUrl: fileConsentMockState.uploadToConsentUrl,
};
});
const runtimeStub: PluginRuntime = {
logging: {
shouldLogVerbose: () => false,
},
channel: {
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: () => ({
enqueue: async () => {},
}),
},
},
} as unknown as PluginRuntime;
function createDeps(): MSTeamsMessageHandlerDeps {
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
};
const conversationStore: MSTeamsConversationStore = {
upsert: async () => {},
get: async () => null,
list: async () => [],
remove: async () => false,
findByUserId: async () => null,
};
const pollStore: MSTeamsPollStore = {
createPoll: async () => {},
getPoll: async () => null,
recordVote: async () => null,
};
return {
cfg: {} as OpenClawConfig,
runtime: {
error: vi.fn(),
} as unknown as RuntimeEnv,
appId: "test-app-id",
adapter,
tokenProvider: {
getAccessToken: async () => "token",
},
textLimit: 4000,
mediaMaxBytes: 8 * 1024 * 1024,
conversationStore,
pollStore,
log: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
}
function createActivityHandler(): MSTeamsActivityHandler {
let handler: MSTeamsActivityHandler;
handler = {
onMessage: () => handler,
onMembersAdded: () => handler,
run: async () => {},
};
return handler;
}
function createInvokeContext(params: {
conversationId: string;
uploadId: string;
action: "accept" | "decline";
}): { context: MSTeamsTurnContext; sendActivity: ReturnType<typeof vi.fn> } {
const sendActivity = vi.fn(async () => ({ id: "activity-id" }));
const uploadInfo =
params.action === "accept"
? {
name: "secret.txt",
uploadUrl: "https://upload.example.com/put",
contentUrl: "https://content.example.com/file",
uniqueId: "unique-id",
fileType: "txt",
}
: undefined;
return {
context: {
activity: {
type: "invoke",
name: "fileConsent/invoke",
conversation: { id: params.conversationId },
value: {
type: "fileUpload",
action: params.action,
uploadInfo,
context: { uploadId: params.uploadId },
},
},
sendActivity,
sendActivities: async () => [],
} as unknown as MSTeamsTurnContext,
sendActivity,
};
}
describe("msteams file consent invoke authz", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
clearPendingUploads();
fileConsentMockState.uploadToConsentUrl.mockReset();
fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined);
});
it("uploads when invoke conversation matches pending upload conversation", async () => {
const uploadId = storePendingUpload({
buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
filename: "secret.txt",
contentType: "text/plain",
conversationId: "19:victim@thread.v2",
});
const deps = createDeps();
const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
const { context, sendActivity } = createInvokeContext({
conversationId: "19:victim@thread.v2;messageid=abc123",
uploadId,
action: "accept",
});
await handler.run?.(context);
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://upload.example.com/put",
}),
);
expect(getPendingUpload(uploadId)).toBeUndefined();
expect(sendActivity).toHaveBeenCalledWith(
expect.objectContaining({
type: "invokeResponse",
}),
);
});
it("rejects cross-conversation accept invoke and keeps pending upload", async () => {
const uploadId = storePendingUpload({
buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
filename: "secret.txt",
contentType: "text/plain",
conversationId: "19:victim@thread.v2",
});
const deps = createDeps();
const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
const { context, sendActivity } = createInvokeContext({
conversationId: "19:attacker@thread.v2",
uploadId,
action: "accept",
});
await handler.run?.(context);
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
expect(getPendingUpload(uploadId)).toBeDefined();
expect(sendActivity).toHaveBeenCalledWith(
"The file upload request has expired. Please try sending the file again.",
);
expect(sendActivity).toHaveBeenCalledWith(
expect.objectContaining({
type: "invokeResponse",
}),
);
});
it("ignores cross-conversation decline invoke and keeps pending upload", async () => {
const uploadId = storePendingUpload({
buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
filename: "secret.txt",
contentType: "text/plain",
conversationId: "19:victim@thread.v2",
});
const deps = createDeps();
const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
const { context, sendActivity } = createInvokeContext({
conversationId: "19:attacker@thread.v2",
uploadId,
action: "decline",
});
await handler.run?.(context);
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
expect(getPendingUpload(uploadId)).toBeDefined();
expect(sendActivity).toHaveBeenCalledTimes(1);
expect(sendActivity).toHaveBeenCalledWith(
expect.objectContaining({
type: "invokeResponse",
}),
);
});
});

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js";
import { normalizeMSTeamsConversationId } from "./inbound.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
@@ -42,6 +43,8 @@ async function handleFileConsentInvoke(
context: MSTeamsTurnContext,
log: MSTeamsMonitorLogger,
): Promise<boolean> {
const expiredUploadMessage =
"The file upload request has expired. Please try sending the file again.";
const activity = context.activity;
if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") {
return false;
@@ -57,9 +60,24 @@ async function handleFileConsentInvoke(
typeof consentResponse.context?.uploadId === "string"
? consentResponse.context.uploadId
: undefined;
const pendingFile = getPendingUpload(uploadId);
if (pendingFile) {
const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId);
const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
if (!invokeConversationId || pendingConversationId !== invokeConversationId) {
log.info("file consent conversation mismatch", {
uploadId,
expectedConversationId: pendingConversationId,
receivedConversationId: invokeConversationId || undefined,
});
if (consentResponse.action === "accept") {
await context.sendActivity(expiredUploadMessage);
}
return true;
}
}
if (consentResponse.action === "accept" && consentResponse.uploadInfo) {
const pendingFile = getPendingUpload(uploadId);
if (pendingFile) {
log.debug?.("user accepted file consent, uploading", {
uploadId,
@@ -101,9 +119,7 @@ async function handleFileConsentInvoke(
}
} else {
log.debug?.("pending file not found for consent", { uploadId });
await context.sendActivity(
"The file upload request has expired. Please try sending the file again.",
);
await context.sendActivity(expiredUploadMessage);
}
} else {
// User declined