mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(msteams): bind file consent invokes to conversation
This commit is contained in:
@@ -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)
|
||||
|
||||
220
extensions/msteams/src/monitor-handler.file-consent.test.ts
Normal file
220
extensions/msteams/src/monitor-handler.file-consent.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user