Merge 585f6ecf94d96a257eb9a614063b8a8c2dfe1361 into 8a05c05596ca9ba0735dafd8e359885de4c2c969
This commit is contained in:
commit
8b170ec405
@ -172,6 +172,14 @@ const FeishuSharedConfigShape = {
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
/**
|
||||
* Allowed roots for local media paths (sendMedia / local file uploads). Array of absolute
|
||||
* directory paths, or "any" to allow any local path.
|
||||
* Security: "any" bypasses path-containment checks and grants unrestricted filesystem read
|
||||
* access to the process. Prefer explicit path arrays in production; use "any" only in
|
||||
* trusted environments (e.g. dev or locked-down hosts).
|
||||
*/
|
||||
localRoots: z.union([z.literal("any"), z.array(z.string())]).optional(),
|
||||
httpTimeoutMs: z.number().int().positive().max(300_000).optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
renderMode: RenderModeSchema,
|
||||
|
||||
@ -343,6 +343,94 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses channels.feishu.localRoots 'any' and passes readFile for local paths", async () => {
|
||||
loadWebMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("local-file"),
|
||||
fileName: "pic.png",
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
resolveFeishuAccountMock.mockReturnValueOnce({
|
||||
configured: true,
|
||||
accountId: "main",
|
||||
config: { localRoots: "any" },
|
||||
});
|
||||
|
||||
await sendMediaFeishu({
|
||||
cfg: { channels: { feishu: { localRoots: "any" } } } as any,
|
||||
to: "user:ou_target",
|
||||
mediaUrl: "/local/feishu/workspace/ask_official.png",
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
||||
"/local/feishu/workspace/ask_official.png",
|
||||
expect.objectContaining({
|
||||
localRoots: "any",
|
||||
readFile: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses channels.feishu.localRoots array over context mediaLocalRoots", async () => {
|
||||
loadWebMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("local-file"),
|
||||
fileName: "doc.pdf",
|
||||
kind: "document",
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
|
||||
const channelRoots = ["/custom/feishu/root"];
|
||||
resolveFeishuAccountMock.mockReturnValueOnce({
|
||||
configured: true,
|
||||
accountId: "main",
|
||||
config: { localRoots: channelRoots },
|
||||
});
|
||||
await sendMediaFeishu({
|
||||
cfg: { channels: { feishu: { localRoots: channelRoots } } } as any,
|
||||
to: "user:ou_target",
|
||||
mediaUrl: "/custom/feishu/root/file.pdf",
|
||||
mediaLocalRoots: ["/other/context/root"],
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
||||
"/custom/feishu/root/file.pdf",
|
||||
expect.objectContaining({
|
||||
localRoots: channelRoots,
|
||||
}),
|
||||
);
|
||||
// readFile bypass must NOT be injected for a plain array of roots.
|
||||
const callArg = loadWebMediaMock.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(callArg).not.toHaveProperty("readFile");
|
||||
});
|
||||
|
||||
it("honors explicit empty localRoots (no fallback to context mediaLocalRoots)", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValueOnce({
|
||||
configured: true,
|
||||
accountId: "main",
|
||||
config: { localRoots: [] },
|
||||
});
|
||||
loadWebMediaMock.mockRejectedValueOnce(
|
||||
new (class extends Error {
|
||||
code = "path-not-allowed";
|
||||
name = "LocalMediaAccessError";
|
||||
})(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
to: "user:ou_target",
|
||||
mediaUrl: "/some/local/file.png",
|
||||
mediaLocalRoots: ["/allowed/context/root"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "path-not-allowed" });
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
||||
"/some/local/file.png",
|
||||
expect.objectContaining({ localRoots: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when media URL fetch is blocked", async () => {
|
||||
loadWebMediaMock.mockRejectedValueOnce(
|
||||
new Error("Blocked: resolves to private/internal IP address"),
|
||||
|
||||
@ -9,6 +9,7 @@ import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
||||
import { resolveFeishuSendTarget } from "./send-target.js";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
|
||||
const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
|
||||
|
||||
@ -519,10 +520,29 @@ function resolveFeishuOutboundMediaKind(params: { fileName: string; contentType?
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve effective localRoots for loadWebMedia from merged account config (so
|
||||
* channels.feishu.accounts.<id>.localRoots overrides top-level). Supports "any" or string[].
|
||||
*/
|
||||
function resolveFeishuMediaLocalRoots(params: {
|
||||
feishuConfig: FeishuConfig | undefined;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
}): readonly string[] | "any" | undefined {
|
||||
const channelRoots = params.feishuConfig?.localRoots;
|
||||
if (channelRoots === "any") {
|
||||
return "any";
|
||||
}
|
||||
// Honor explicit array (including empty): [] means disable local-path reads for Feishu.
|
||||
if (Array.isArray(channelRoots)) {
|
||||
return channelRoots;
|
||||
}
|
||||
return params.mediaLocalRoots?.length ? params.mediaLocalRoots : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload and send media (image or file) from URL, local path, or buffer.
|
||||
* When mediaUrl is a local path, mediaLocalRoots (from core outbound context)
|
||||
* must be passed so loadWebMedia allows the path (post CVE-2026-26321).
|
||||
* When mediaUrl is a local path, allowed roots come from merged Feishu config
|
||||
* (channels.feishu.localRoots or channels.feishu.accounts.<id>.localRoots) or core mediaLocalRoots.
|
||||
*/
|
||||
export async function sendMediaFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
@ -533,7 +553,7 @@ export async function sendMediaFeishu(params: {
|
||||
replyToMessageId?: string;
|
||||
replyInThread?: boolean;
|
||||
accountId?: string;
|
||||
/** Allowed roots for local path reads; required for local filePath to work. */
|
||||
/** Allowed roots for local path reads (from core); overridden by channels.feishu.localRoots. */
|
||||
mediaLocalRoots?: readonly string[];
|
||||
}): Promise<SendMediaResult> {
|
||||
const {
|
||||
@ -561,11 +581,25 @@ export async function sendMediaFeishu(params: {
|
||||
buffer = mediaBuffer;
|
||||
name = fileName ?? "file";
|
||||
} else if (mediaUrl) {
|
||||
const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
|
||||
const localRoots = resolveFeishuMediaLocalRoots({
|
||||
feishuConfig: account.config,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
const loadOptions: {
|
||||
maxBytes: number;
|
||||
optimizeImages: boolean;
|
||||
localRoots: readonly string[] | "any" | undefined;
|
||||
readFile?: (filePath: string) => Promise<Buffer>;
|
||||
} = {
|
||||
maxBytes: mediaMaxBytes,
|
||||
optimizeImages: false,
|
||||
localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
|
||||
});
|
||||
localRoots,
|
||||
};
|
||||
// Core requires readFile override when localRoots is "any" (unsafe-bypass guard).
|
||||
if (localRoots === "any") {
|
||||
loadOptions.readFile = (filePath: string) => fs.promises.readFile(filePath);
|
||||
}
|
||||
const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, loadOptions);
|
||||
buffer = loaded.buffer;
|
||||
name = fileName ?? loaded.fileName ?? "file";
|
||||
contentType = loaded.contentType;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user