From 0f775fa25e41b63c8df8fe5107eeb63d5ac3d92b Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Tue, 7 Jul 2026 17:36:54 +0800
Subject: [PATCH] fix(msteams): bound Microsoft Graph API response reads in
graph-upload to prevent OOM (#97784)
* fix(msteams): bound Microsoft Graph API response reads in graph-upload to prevent OOM
* test(msteams): prove graph upload oversized JSON rejection
* test(msteams): tighten Graph response bound proof
---------
Co-authored-by: Peter Steinberger
---
CHANGELOG.md | 1 +
extensions/msteams/src/graph-upload.test.ts | 58 ++++++++++++++++++++-
extensions/msteams/src/graph-upload.ts | 29 ++++++-----
3 files changed, 72 insertions(+), 16 deletions(-)
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");