fix(feishu): fail fast on local media allowlist violations #51347

This commit is contained in:
jepson-liu 2026-03-21 09:57:55 +08:00
parent 5e417b44e1
commit 6d81281811
2 changed files with 141 additions and 0 deletions

View File

@ -121,6 +121,35 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
}
});
it("throws clear guidance when local-image path is outside allowlist", async () => {
const { dir, file } = await createTmpImage();
sendMediaFeishuMock.mockRejectedValueOnce({
name: "LocalMediaAccessError",
code: "path-not-allowed",
message: `Local media path is not under an allowed directory: ${file}`,
});
try {
let message = "";
try {
await sendText({
cfg: {} as any,
to: "chat_1",
text: file,
accountId: "main",
mediaLocalRoots: ["/state/media", "/state/workspace"],
});
} catch (err) {
message = err instanceof Error ? err.message : String(err);
}
expect(message).toContain("channels.feishu.mediaLocalRoots is unsupported");
expect(message).toContain("Allowed local media roots: /state/media, /state/workspace");
expect(message).toContain("macOS note: OpenClaw uses os.tmpdir()");
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it("uses markdown cards when renderMode=card", async () => {
const result = await sendText({
cfg: {
@ -357,3 +386,57 @@ describe("feishuOutbound.sendMedia renderMode", () => {
);
});
});
describe("feishuOutbound.sendMedia failure handling", () => {
beforeEach(() => {
resetOutboundMocks();
});
it("throws non-zero guidance for path-not-allowed media paths", async () => {
sendMediaFeishuMock.mockRejectedValueOnce({
name: "LocalMediaAccessError",
code: "path-not-allowed",
message: "Local media path is not under an allowed directory: /tmp/test.pdf",
});
let message = "";
try {
await feishuOutbound.sendMedia?.({
cfg: {} as any,
to: "chat_1",
text: "",
mediaUrl: "/tmp/test.pdf",
mediaLocalRoots: ["/state/media", "/state/workspace"],
accountId: "main",
});
} catch (err) {
message = err instanceof Error ? err.message : String(err);
}
expect(message).toContain("command exits with non-zero status");
expect(message).toContain("channels.feishu.mediaLocalRoots is unsupported");
expect(message).toContain("Allowed local media roots: /state/media, /state/workspace");
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
it("keeps URL-link fallback for non-allowlist media failures", async () => {
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upstream upload failed"));
await feishuOutbound.sendMedia?.({
cfg: {} as any,
to: "chat_1",
text: "",
mediaUrl: "https://example.com/file.pdf",
accountId: "main",
});
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1",
text: "📎 https://example.com/file.pdf",
accountId: "main",
}),
);
});
});

View File

@ -1,6 +1,7 @@
import fs from "fs";
import path from "path";
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { getDefaultMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import type { ChannelOutboundAdapter } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { sendMediaFeishu } from "./media.js";
@ -59,6 +60,53 @@ function resolveReplyToMessageId(params: {
return trimmed || undefined;
}
function findLocalMediaPathNotAllowedError(err: unknown): unknown {
let current: unknown = err;
let depth = 0;
while (current && typeof current === "object" && depth < 5) {
const maybe = current as { name?: unknown; code?: unknown; cause?: unknown };
if (maybe.name === "LocalMediaAccessError" && maybe.code === "path-not-allowed") {
return current;
}
current = maybe.cause;
depth += 1;
}
return null;
}
function buildFeishuLocalMediaGuidanceError(params: {
err: unknown;
mediaUrl: string;
mediaLocalRoots?: readonly string[];
}): Error {
const roots =
params.mediaLocalRoots && params.mediaLocalRoots.length > 0
? [...params.mediaLocalRoots]
: [...getDefaultMediaLocalRoots()];
const stagingDir = roots.find((root) => path.basename(root) === "media") ?? "(stateDir)/media";
const details =
params.err instanceof Error
? params.err.message
: typeof params.err === "object" &&
params.err &&
"message" in params.err &&
typeof (params.err as { message?: unknown }).message === "string"
? ((params.err as { message: string }).message ?? "")
: String(params.err);
return new Error(
[
"Feishu media upload failed; local media path is outside the allowed roots (command exits with non-zero status).",
"channels.feishu.mediaLocalRoots is unsupported for Feishu and ignored.",
`Rejected path: ${params.mediaUrl}`,
`Allowed local media roots: ${roots.join(", ")}`,
`Recommended staging directory: ${stagingDir}`,
"macOS note: OpenClaw uses os.tmpdir() (typically /var/folders/.../T), not /tmp.",
`Details: ${details}`,
].join(" "),
{ cause: params.err instanceof Error ? params.err : undefined },
);
}
async function sendOutboundText(params: {
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
to: string;
@ -110,6 +158,13 @@ export const feishuOutbound: ChannelOutboundAdapter = {
mediaLocalRoots,
});
} catch (err) {
if (findLocalMediaPathNotAllowedError(err)) {
throw buildFeishuLocalMediaGuidanceError({
err,
mediaUrl: localImagePath,
mediaLocalRoots,
});
}
console.error(`[feishu] local image path auto-send failed:`, err);
// fall through to plain text as last resort
}
@ -179,6 +234,9 @@ export const feishuOutbound: ChannelOutboundAdapter = {
replyToMessageId,
});
} catch (err) {
if (findLocalMediaPathNotAllowedError(err)) {
throw buildFeishuLocalMediaGuidanceError({ err, mediaUrl, mediaLocalRoots });
}
// Log the error for debugging
console.error(`[feishu] sendMediaFeishu failed:`, err);
// Fallback to URL link if upload fails