Skip to content

Commit bbab94c

Browse files
authored
security(feishu): bind doc create grants to trusted requester context (#31184)
Co-authored-by: Tak Hoffman <[email protected]>
1 parent e482da6 commit bbab94c

File tree

9 files changed

+117
-66
lines changed

9 files changed

+117
-66
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ Docs: https://docs.openclaw.ai
127127
- Telegram/Group allowlist ordering: evaluate chat allowlist before sender allowlist enforcement so explicitly allowlisted groups are not fail-closed by empty sender allowlists. Landed from contributor PR #30680 by @openperf. Thanks @openperf.
128128
- Telegram/Multi-account group isolation: prevent channel-level `groups` config from leaking across Telegram accounts in multi-account setups, avoiding cross-account group routing drops. Landed from contributor PR #30677 by @YUJIE2002. Thanks @YUJIE2002.
129129
- Telegram/Voice caption overflow fallback: recover from `sendVoice` caption length errors by re-sending voice without caption and delivering text separately so replies are not lost. Landed from contributor PR #31131 by @Sid-Qin. Thanks @Sid-Qin.
130+
- Feishu/Doc create permissions: remove caller-controlled owner fields from `feishu_doc` create and bind optional grant behavior to trusted Feishu requester context (`grant_to_requester`), preventing principal selection via tool arguments. (#31184) Thanks @Takhoffman.
130131
- Routing/Binding peer-kind parity: treat `peer.kind` `group` and `channel` as equivalent for binding scope matching (while keeping `direct` separate) so Slack/public channel bindings do not silently fall through. Landed from contributor PR #31135 by @Sid-Qin. Thanks @Sid-Qin.
131132
- Cron/Store EBUSY fallback: retry `rename` on `EBUSY` and use `copyFile` fallback on Windows when replacing cron store files so busy-file contention no longer causes false write failures. (#16932) Thanks @sudhanva-chakra.
132133
- Agents/FS workspace default: honor documented host file-tool default `tools.fs.workspaceOnly=false` when unset so host `write`/`edit` calls are not incorrectly workspace-restricted unless explicitly enabled. Landed from contributor PR #31128 by @SaucePackets. Thanks @SaucePackets.

extensions/feishu/src/doc-schema.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,10 @@ export const FeishuDocSchema = Type.Union([
2929
action: Type.Literal("create"),
3030
title: Type.String({ description: "Document title" }),
3131
folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
32-
owner_open_id: Type.Optional(
33-
Type.String({ description: "Open ID of the user to grant ownership permission" }),
34-
),
35-
owner_perm_type: Type.Optional(
36-
Type.Union([Type.Literal("view"), Type.Literal("edit"), Type.Literal("full_access")], {
37-
description: "Permission type (default: full_access)",
32+
grant_to_requester: Type.Optional(
33+
Type.Boolean({
34+
description:
35+
"Grant edit permission to the trusted requesting Feishu user from runtime context (default: true).",
3836
}),
3937
),
4038
}),

extensions/feishu/src/docx.test.ts

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ describe("feishu_doc image fetch hardening", () => {
340340
consoleErrorSpy.mockRestore();
341341
});
342342

343-
it("reports owner permission details when grant succeeds", async () => {
343+
it("create grants permission only to trusted Feishu requester", async () => {
344344
const registerTool = vi.fn();
345345
registerFeishuDocTools({
346346
config: {
@@ -357,27 +357,35 @@ describe("feishu_doc image fetch hardening", () => {
357357

358358
const feishuDocTool = registerTool.mock.calls
359359
.map((call) => call[0])
360-
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
360+
.map((tool) =>
361+
typeof tool === "function"
362+
? tool({ messageChannel: "feishu", requesterSenderId: "ou_123" })
363+
: tool,
364+
)
361365
.find((tool) => tool.name === "feishu_doc");
362366
expect(feishuDocTool).toBeDefined();
363367

364368
const result = await feishuDocTool.execute("tool-call", {
365369
action: "create",
366370
title: "Demo",
367-
owner_open_id: "ou_123",
368-
owner_perm_type: "edit",
369371
});
370372

371-
expect(permissionMemberCreateMock).toHaveBeenCalled();
372-
expect(result.details.owner_permission_added).toBe(true);
373-
expect(result.details.owner_open_id).toBe("ou_123");
374-
expect(result.details.owner_perm_type).toBe("edit");
373+
expect(result.details.document_id).toBe("doc_created");
374+
expect(result.details.requester_permission_added).toBe(true);
375+
expect(result.details.requester_open_id).toBe("ou_123");
376+
expect(result.details.requester_perm_type).toBe("edit");
377+
expect(permissionMemberCreateMock).toHaveBeenCalledWith(
378+
expect.objectContaining({
379+
data: expect.objectContaining({
380+
member_type: "openid",
381+
member_id: "ou_123",
382+
perm: "edit",
383+
}),
384+
}),
385+
);
375386
});
376387

377-
it("does not report owner permission details when grant fails", async () => {
378-
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
379-
permissionMemberCreateMock.mockRejectedValueOnce(new Error("permission denied"));
380-
388+
it("create skips requester grant when trusted requester identity is unavailable", async () => {
381389
const registerTool = vi.fn();
382390
registerFeishuDocTools({
383391
config: {
@@ -394,26 +402,21 @@ describe("feishu_doc image fetch hardening", () => {
394402

395403
const feishuDocTool = registerTool.mock.calls
396404
.map((call) => call[0])
397-
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
405+
.map((tool) => (typeof tool === "function" ? tool({ messageChannel: "feishu" }) : tool))
398406
.find((tool) => tool.name === "feishu_doc");
399407
expect(feishuDocTool).toBeDefined();
400408

401409
const result = await feishuDocTool.execute("tool-call", {
402410
action: "create",
403411
title: "Demo",
404-
owner_open_id: "ou_123",
405-
owner_perm_type: "edit",
406412
});
407413

408-
expect(permissionMemberCreateMock).toHaveBeenCalled();
409-
expect(result.details.owner_permission_added).toBeUndefined();
410-
expect(result.details.owner_open_id).toBeUndefined();
411-
expect(result.details.owner_perm_type).toBeUndefined();
412-
expect(consoleWarnSpy).toHaveBeenCalled();
413-
consoleWarnSpy.mockRestore();
414+
expect(permissionMemberCreateMock).not.toHaveBeenCalled();
415+
expect(result.details.requester_permission_added).toBe(false);
416+
expect(result.details.requester_permission_skipped_reason).toContain("trusted requester");
414417
});
415418

416-
it("skips permission grant when owner_open_id is omitted", async () => {
419+
it("create never grants permissions when grant_to_requester is false", async () => {
417420
const registerTool = vi.fn();
418421
registerFeishuDocTools({
419422
config: {
@@ -430,17 +433,22 @@ describe("feishu_doc image fetch hardening", () => {
430433

431434
const feishuDocTool = registerTool.mock.calls
432435
.map((call) => call[0])
433-
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
436+
.map((tool) =>
437+
typeof tool === "function"
438+
? tool({ messageChannel: "feishu", requesterSenderId: "ou_123" })
439+
: tool,
440+
)
434441
.find((tool) => tool.name === "feishu_doc");
435442
expect(feishuDocTool).toBeDefined();
436443

437444
const result = await feishuDocTool.execute("tool-call", {
438445
action: "create",
439446
title: "Demo",
447+
grant_to_requester: false,
440448
});
441449

442450
expect(permissionMemberCreateMock).not.toHaveBeenCalled();
443-
expect(result.details.owner_permission_added).toBeUndefined();
451+
expect(result.details.requester_permission_added).toBeUndefined();
444452
});
445453

446454
it("returns an error when create response omits document_id", async () => {

extensions/feishu/src/docx.ts

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -751,8 +751,7 @@ async function createDoc(
751751
client: Lark.Client,
752752
title: string,
753753
folderToken?: string,
754-
ownerOpenId?: string,
755-
ownerPermType: "view" | "edit" | "full_access" = "full_access",
754+
options?: { grantToRequester?: boolean; requesterOpenId?: string },
756755
) {
757756
const res = await client.docx.document.create({
758757
data: { title, folder_token: folderToken },
@@ -765,36 +764,48 @@ async function createDoc(
765764
if (!docToken) {
766765
throw new Error("Document creation succeeded but no document_id was returned");
767766
}
768-
let ownerPermissionAdded = false;
769-
770-
// Auto add owner permission if ownerOpenId is provided
771-
if (docToken && ownerOpenId) {
772-
try {
773-
await client.drive.permissionMember.create({
774-
path: { token: docToken },
775-
params: { type: "docx", need_notification: false },
776-
data: {
777-
member_type: "openid",
778-
member_id: ownerOpenId,
779-
perm: ownerPermType,
780-
},
781-
});
782-
ownerPermissionAdded = true;
783-
} catch (err) {
784-
console.warn("Failed to add owner permission (non-critical):", err);
767+
const shouldGrantToRequester = options?.grantToRequester !== false;
768+
const requesterOpenId = options?.requesterOpenId?.trim();
769+
const requesterPermType: "edit" = "edit";
770+
771+
let requesterPermissionAdded = false;
772+
let requesterPermissionSkippedReason: string | undefined;
773+
let requesterPermissionError: string | undefined;
774+
775+
if (shouldGrantToRequester) {
776+
if (!requesterOpenId) {
777+
requesterPermissionSkippedReason = "trusted requester identity unavailable";
778+
} else {
779+
try {
780+
await client.drive.permissionMember.create({
781+
path: { token: docToken },
782+
params: { type: "docx", need_notification: false },
783+
data: {
784+
member_type: "openid",
785+
member_id: requesterOpenId,
786+
perm: requesterPermType,
787+
},
788+
});
789+
requesterPermissionAdded = true;
790+
} catch (err) {
791+
requesterPermissionError = err instanceof Error ? err.message : String(err);
792+
}
785793
}
786794
}
787795

788796
return {
789797
document_id: docToken,
790798
title: doc?.title,
791799
url: `https://feishu.cn/docx/${docToken}`,
792-
...(ownerOpenId &&
793-
ownerPermissionAdded && {
794-
owner_permission_added: true,
795-
owner_open_id: ownerOpenId,
796-
owner_perm_type: ownerPermType,
800+
...(shouldGrantToRequester && {
801+
requester_permission_added: requesterPermissionAdded,
802+
...(requesterOpenId && { requester_open_id: requesterOpenId }),
803+
requester_perm_type: requesterPermType,
804+
...(requesterPermissionSkippedReason && {
805+
requester_permission_skipped_reason: requesterPermissionSkippedReason,
797806
}),
807+
...(requesterPermissionError && { requester_permission_error: requesterPermissionError }),
808+
}),
798809
};
799810
}
800811

@@ -1251,6 +1262,8 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
12511262
api.registerTool(
12521263
(ctx) => {
12531264
const defaultAccountId = ctx.agentAccountId;
1265+
const trustedRequesterOpenId =
1266+
ctx.messageChannel === "feishu" ? ctx.requesterSenderId?.trim() || undefined : undefined;
12541267
return {
12551268
name: "feishu_doc",
12561269
label: "Feishu Doc",
@@ -1297,13 +1310,10 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
12971310
);
12981311
case "create":
12991312
return json(
1300-
await createDoc(
1301-
client,
1302-
p.title,
1303-
p.folder_token,
1304-
p.owner_open_id,
1305-
p.owner_perm_type,
1306-
),
1313+
await createDoc(client, p.title, p.folder_token, {
1314+
grantToRequester: p.grant_to_requester,
1315+
requesterOpenId: trustedRequesterOpenId,
1316+
}),
13071317
);
13081318
case "list_blocks":
13091319
return json(await listBlocks(client, p.doc_token));
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
const resolvePluginToolsMock = vi.fn(() => []);
4+
5+
vi.mock("../plugins/tools.js", () => ({
6+
resolvePluginTools: (params: unknown) => resolvePluginToolsMock(params),
7+
}));
8+
9+
import { createOpenClawTools } from "./openclaw-tools.js";
10+
11+
describe("createOpenClawTools plugin context", () => {
12+
it("forwards trusted requester sender identity to plugin tool context", () => {
13+
createOpenClawTools({
14+
config: {} as never,
15+
requesterSenderId: "trusted-sender",
16+
senderIsOwner: true,
17+
});
18+
19+
expect(resolvePluginToolsMock).toHaveBeenCalledWith(
20+
expect.objectContaining({
21+
context: expect.objectContaining({
22+
requesterSenderId: "trusted-sender",
23+
senderIsOwner: true,
24+
}),
25+
}),
26+
);
27+
});
28+
});

src/agents/openclaw-tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ export function createOpenClawTools(options?: {
187187
sessionKey: options?.agentSessionKey,
188188
messageChannel: options?.agentChannel,
189189
agentAccountId: options?.agentAccountId,
190+
requesterSenderId: options?.requesterSenderId ?? undefined,
191+
senderIsOwner: options?.senderIsOwner ?? undefined,
190192
sandboxed: options?.sandboxed,
191193
},
192194
existingToolNames: new Set(tools.map((tool) => tool.name)),

src/plugins/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export type OpenClawPluginToolContext = {
6363
sessionKey?: string;
6464
messageChannel?: string;
6565
agentAccountId?: string;
66+
/** Trusted sender id from inbound context (runtime-provided, not tool args). */
67+
requesterSenderId?: string;
68+
/** Whether the trusted sender is an owner. */
69+
senderIsOwner?: boolean;
6670
sandboxed?: boolean;
6771
};
6872

src/security/audit.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,7 +1266,7 @@ describe("security audit", () => {
12661266
);
12671267
});
12681268

1269-
it("warns when Feishu doc tool is enabled because create supports owner_open_id", async () => {
1269+
it("warns when Feishu doc tool is enabled because create can grant requester access", async () => {
12701270
const cfg: OpenClawConfig = {
12711271
channels: {
12721272
feishu: {
@@ -1280,7 +1280,7 @@ describe("security audit", () => {
12801280
expectFinding(res, "channels.feishu.doc_owner_open_id", "warn");
12811281
});
12821282

1283-
it("does not warn for Feishu owner_open_id when doc tools are disabled", async () => {
1283+
it("does not warn for Feishu doc grant risk when doc tools are disabled", async () => {
12841284
const cfg: OpenClawConfig = {
12851285
channels: {
12861286
feishu: {

src/security/audit.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -519,11 +519,11 @@ function collectGatewayConfigFindings(
519519
findings.push({
520520
checkId: "channels.feishu.doc_owner_open_id",
521521
severity: "warn",
522-
title: "Feishu doc create can grant owner permissions",
522+
title: "Feishu doc create can grant requester permissions",
523523
detail:
524-
'channels.feishu tools include "doc"; feishu_doc action "create" accepts owner_open_id and can grant document access to that user.',
524+
'channels.feishu tools include "doc"; feishu_doc action "create" can grant document access to the trusted requesting Feishu user.',
525525
remediation:
526-
"Disable channels.feishu.tools.doc when not needed, and restrict tool access so untrusted prompts cannot set owner_open_id.",
526+
"Disable channels.feishu.tools.doc when not needed, and restrict tool access for untrusted prompts.",
527527
});
528528
}
529529

0 commit comments

Comments
 (0)