diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ac96377633..e67639b987e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Microsoft Teams Graph response bounds:** cap successful file-upload and chat JSON reads so oversized Microsoft Graph responses cannot be buffered without limit. (#97784) Thanks @Alix-007. - **Packaged speech runtime:** stop treating package-backed `speech-core` as a bundled plugin sidecar, restoring TTS startup in npm installs while release checks keep true activation-bypassing facades package-complete. (#89899, #89425) Thanks @zhangguiping-xydt. - **Codex app-server protocol:** require app-server 0.142 or newer, remove pre-0.142 wire-shape compatibility, and teach Codex to retrieve deferred native `spawn_agent` through `tool_search` so native subagent task mirroring works on search-capable models. (#101221) - **Android hardware keyboard chat:** send with unmodified Enter on physical keyboards while preserving Shift+Enter and other modified Enter combinations for multiline input. (#101239) Thanks @3ninyt3nin-creator. diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts index 8ea828d26455..f00cee480c57 100644 --- a/extensions/msteams/src/graph-upload.test.ts +++ b/extensions/msteams/src/graph-upload.test.ts @@ -1,6 +1,6 @@ // Msteams tests cover graph upload plugin behavior. -import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env"; -import { describe, expect, it, vi } from "vitest"; +import { withFetchPreconnect, withServer } from "openclaw/plugin-sdk/test-env"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildTeamsFileInfoCard } from "./graph-chat.js"; import { resolveGraphChatId, uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js"; @@ -249,6 +249,60 @@ describe("resolveGraphChatId", () => { }); }); +describe("graph upload response limits", () => { + const tokenProvider = { + getAccessToken: vi.fn(async () => "graph-token"), + }; + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("rejects an oversized upload response from a real loopback server", async () => { + await withServer( + (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + const chunk = Buffer.alloc(64 * 1024, 0x20); + let remaining = 257; // >16 MiB when combined with the JSON prefix. + res.write( + '{"id":"item-big","webUrl":"https://example.com/big","name":"big.txt","padding":"', + ); + const writeNext = () => { + if (remaining <= 0) { + res.end('"}'); + return; + } + remaining -= 1; + if (res.write(chunk)) { + setImmediate(writeNext); + } else { + res.once("drain", writeNext); + } + }; + writeNext(); + }, + async (baseUrl) => { + const realFetch = globalThis.fetch.bind(globalThis); + vi.stubGlobal( + "fetch", + withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const loopback = new URL(`${url.pathname}${url.search}`, baseUrl); + return realFetch(loopback, init); + }), + ); + + await expect( + uploadToOneDrive({ buffer: Buffer.from("x"), filename: "big.txt", tokenProvider }), + ).rejects.toThrow( + "msteams.graph-upload.uploadOneDriveFile: JSON response exceeds 16777216 bytes", + ); + }, + ); + }); +}); + describe("buildTeamsFileInfoCard", () => { it("extracts a unique id from quoted etags and lowercases file extensions", () => { expect( diff --git a/extensions/msteams/src/graph-upload.ts b/extensions/msteams/src/graph-upload.ts index 42fc8d2a9b75..f313160dbe0d 100644 --- a/extensions/msteams/src/graph-upload.ts +++ b/extensions/msteams/src/graph-upload.ts @@ -9,6 +9,7 @@ * - Getting chat members for per-user sharing */ +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; import { createMSTeamsHttpError } from "./http-error.js"; import { buildUserAgent } from "./user-agent.js"; @@ -54,11 +55,11 @@ export async function uploadToOneDrive(params: { throw await createMSTeamsHttpError(res, "OneDrive upload failed"); } - const data = (await res.json()) as { + const data = await readProviderJsonResponse<{ id?: string; webUrl?: string; name?: string; - }; + }>(res, "msteams.graph-upload.uploadOneDriveFile"); if (!data.id || !data.webUrl || !data.name) { throw new Error("OneDrive upload response missing required fields"); @@ -106,9 +107,9 @@ async function createSharingLink(params: { throw await createMSTeamsHttpError(res, "Create sharing link failed"); } - const data = (await res.json()) as { + const data = await readProviderJsonResponse<{ link?: { webUrl?: string }; - }; + }>(res, "msteams.graph-upload.createOneDriveSharingLink"); if (!data.link?.webUrl) { throw new Error("Create sharing link response missing webUrl"); @@ -200,11 +201,11 @@ export async function uploadToSharePoint(params: { throw await createMSTeamsHttpError(res, "SharePoint upload failed"); } - const data = (await res.json()) as { + const data = await readProviderJsonResponse<{ id?: string; webUrl?: string; name?: string; - }; + }>(res, "msteams.graph-upload.uploadSharePointFile"); if (!data.id || !data.webUrl || !data.name) { throw new Error("SharePoint upload response missing required fields"); @@ -260,11 +261,11 @@ export async function getDriveItemProperties(params: { throw await createMSTeamsHttpError(res, "Get driveItem properties failed"); } - const data = (await res.json()) as { + const data = await readProviderJsonResponse<{ eTag?: string; webDavUrl?: string; name?: string; - }; + }>(res, "msteams.graph-upload.getDriveItemProperties"); if (!data.eTag || !data.webDavUrl || !data.name) { throw new Error("DriveItem response missing required properties (eTag, webDavUrl, or name)"); @@ -331,9 +332,9 @@ export async function resolveGraphChatId(params: { return null; } - const data = (await res.json()) as { + const data = await readProviderJsonResponse<{ value?: Array<{ id?: string }>; - }; + }>(res, "msteams.graph-upload.getOneOnOneChatId"); const chats = data.value ?? []; @@ -371,12 +372,12 @@ async function getChatMembers(params: { throw await createMSTeamsHttpError(res, "Get chat members failed"); } - const data = (await res.json()) as { + const data = await readProviderJsonResponse<{ value?: Array<{ userId?: string; displayName?: string; }>; - }; + }>(res, "msteams.graph-upload.getChatMembers"); return (data.value ?? []) .map((m) => ({ @@ -435,9 +436,9 @@ async function createSharePointSharingLink(params: { throw await createMSTeamsHttpError(res, "Create SharePoint sharing link failed"); } - const data = (await res.json()) as { + const data = await readProviderJsonResponse<{ link?: { webUrl?: string }; - }; + }>(res, "msteams.graph-upload.createSharePointSharingLink"); if (!data.link?.webUrl) { throw new Error("Create SharePoint sharing link response missing webUrl");