Skip to content

Commit 71d08b4

Browse files
committed
Fix Feishu local media fallback leaks
1 parent 86bdfec commit 71d08b4

5 files changed

Lines changed: 356 additions & 17 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {
2+
isBlockedHostnameOrIp,
3+
resolvePinnedHostnameWithPolicy,
4+
SsrFBlockedError,
5+
} from "openclaw/plugin-sdk/ssrf-runtime";
6+
import { LocalMediaAccessError } from "openclaw/plugin-sdk/web-media";
7+
8+
const FEISHU_MEDIA_UPLOAD_FAILURE_FALLBACK_TEXT = "Media upload failed. Please try again.";
9+
10+
const PRIVATE_MEDIA_FETCH_FAILURE_RE =
11+
/\b(?:LocalMediaAccessError|SsrFBlockedError|Blocked hostname|private\/internal|special-use IP address|local media path|invalid-file-url|network-path-not-allowed|path-not-allowed|Host media read requires explicit localRoots)\b/i;
12+
13+
export function buildFeishuMediaUploadFailureFallbackText(params: { text?: string }): string {
14+
const text = params.text?.trim();
15+
return [text, FEISHU_MEDIA_UPLOAD_FAILURE_FALLBACK_TEXT].filter(Boolean).join("\n\n");
16+
}
17+
18+
export async function isPublicFeishuMediaReference(value: string): Promise<boolean> {
19+
const raw = value.trim();
20+
try {
21+
const parsed = new URL(raw);
22+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
23+
return false;
24+
}
25+
if (isBlockedHostnameOrIp(parsed.hostname)) {
26+
return false;
27+
}
28+
await resolvePinnedHostnameWithPolicy(parsed.hostname);
29+
return true;
30+
} catch {
31+
return false;
32+
}
33+
}
34+
35+
function* walkErrorChain(error: unknown, seen = new Set()): Generator {
36+
if (error === null || error === undefined || seen.has(error)) {
37+
return;
38+
}
39+
seen.add(error);
40+
yield error;
41+
if (typeof error !== "object") {
42+
return;
43+
}
44+
const record = error as {
45+
cause?: unknown;
46+
primaryError?: unknown;
47+
attemptErrors?: unknown;
48+
};
49+
yield* walkErrorChain(record.cause, seen);
50+
yield* walkErrorChain(record.primaryError, seen);
51+
const attemptErrors = Array.isArray(record.attemptErrors) ? record.attemptErrors : [];
52+
for (const attemptError of attemptErrors) {
53+
yield* walkErrorChain(attemptError, seen);
54+
}
55+
}
56+
57+
function errorText(error: unknown): string {
58+
if (error instanceof Error) {
59+
return `${error.name}: ${error.message}`;
60+
}
61+
return typeof error === "string" ? error : "";
62+
}
63+
64+
export function isPrivateFeishuMediaFetchFailure(error: unknown): boolean {
65+
for (const entry of walkErrorChain(error)) {
66+
if (entry instanceof LocalMediaAccessError || entry instanceof SsrFBlockedError) {
67+
return true;
68+
}
69+
if (PRIVATE_MEDIA_FETCH_FAILURE_RE.test(errorText(entry))) {
70+
return true;
71+
}
72+
}
73+
return false;
74+
}
75+
76+
export async function buildFeishuMediaFallbackText(params: {
77+
text?: string;
78+
mediaUrl?: string;
79+
error?: unknown;
80+
}): Promise<string> {
81+
const mediaUrl = params.mediaUrl?.trim();
82+
if (
83+
mediaUrl &&
84+
!isPrivateFeishuMediaFetchFailure(params.error) &&
85+
(await isPublicFeishuMediaReference(mediaUrl))
86+
) {
87+
const text = params.text?.trim();
88+
return [text, `📎 ${mediaUrl}`].filter(Boolean).join("\n\n");
89+
}
90+
return buildFeishuMediaUploadFailureFallbackText({ text: params.text });
91+
}

extensions/feishu/src/outbound.test.ts

Lines changed: 174 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ const shouldSuppressFeishuTextForVoiceMediaMock = vi.hoisted(
1818
() => (params: { mediaUrl?: string; audioAsVoice?: boolean }) =>
1919
params.audioAsVoice === true || /\.(?:ogg|opus)(?:[?#]|$)/i.test(params.mediaUrl ?? ""),
2020
);
21+
const resolvePinnedHostnameWithPolicyMock = vi.hoisted(() =>
22+
vi.fn(async (hostname: string) => {
23+
if (hostname === "files.example.test") {
24+
throw new Error("Blocked: resolves to private/internal/special-use IP address");
25+
}
26+
return {
27+
hostname,
28+
addresses: ["93.184.216.34"],
29+
lookup: vi.fn(),
30+
};
31+
}),
32+
);
33+
34+
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
35+
const actual = await importOriginal<Record<string, unknown>>();
36+
return {
37+
...actual,
38+
resolvePinnedHostnameWithPolicy: resolvePinnedHostnameWithPolicyMock,
39+
};
40+
});
2141

2242
vi.mock("./media.js", () => ({
2343
sendMediaFeishu: sendMediaFeishuMock,
@@ -143,6 +163,7 @@ afterAll(() => {
143163
vi.doUnmock("./client.js");
144164
vi.doUnmock("./drive.js");
145165
vi.doUnmock("./comment-reaction.js");
166+
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
146167
vi.resetModules();
147168
});
148169

@@ -324,7 +345,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
324345
expect(sendMessageCall()?.accountId).toBe("main");
325346
});
326347

327-
it("falls back to plain text if local-image media send fails", async () => {
348+
it("does not leak local-image paths if auto-send fails", async () => {
328349
const { dir, file } = await createTmpImage();
329350
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
330351
try {
@@ -337,7 +358,8 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
337358

338359
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
339360
expect(sendMessageCall()?.to).toBe("chat_1");
340-
expect(sendMessageCall()?.text).toBe(file);
361+
expect(sendMessageCall()?.text).toBe("Media upload failed. Please try again.");
362+
expect(sendMessageCall()?.text).not.toContain(file);
341363
expect(sendMessageCall()?.accountId).toBe("main");
342364
} finally {
343365
await fs.rm(dir, { recursive: true, force: true });
@@ -923,6 +945,44 @@ describe("feishuOutbound comment-thread routing", () => {
923945
expectFeishuResult(result, "reply_msg");
924946
});
925947

948+
it("does not leak local media paths in comment-thread media fallbacks", async () => {
949+
const mediaPath = path.join(os.tmpdir(), "openclaw-feishu-comment-local-voice.mp3");
950+
951+
const result = await feishuOutbound.sendMedia?.({
952+
cfg: emptyConfig,
953+
to: "comment:docx:doxcn123:7623358762119646411",
954+
text: "see attachment",
955+
mediaUrl: mediaPath,
956+
accountId: "main",
957+
});
958+
959+
expect(commentThreadParams()?.content).toBe(
960+
"see attachment\n\nMedia upload failed. Please try again.",
961+
);
962+
expect(commentThreadParams()?.content).not.toContain(mediaPath);
963+
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
964+
expectFeishuResult(result, "reply_msg");
965+
});
966+
967+
it("does not leak loopback URLs in comment-thread media fallbacks", async () => {
968+
const mediaUrl = "http://127.0.0.1:3000/tmp/openclaw-voice.mp3";
969+
970+
const result = await feishuOutbound.sendMedia?.({
971+
cfg: emptyConfig,
972+
to: "comment:docx:doxcn123:7623358762119646411",
973+
text: "see attachment",
974+
mediaUrl,
975+
accountId: "main",
976+
});
977+
978+
expect(commentThreadParams()?.content).toBe(
979+
"see attachment\n\nMedia upload failed. Please try again.",
980+
);
981+
expect(commentThreadParams()?.content).not.toContain(mediaUrl);
982+
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
983+
expectFeishuResult(result, "reply_msg");
984+
});
985+
926986
it("preserves comment-thread routing when deliverCommentThreadText falls back to add_comment", async () => {
927987
deliverCommentThreadTextMock.mockResolvedValueOnce({
928988
delivery_mode: "add_comment",
@@ -1222,6 +1282,118 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
12221282
expect(sendMessageCall()?.text).toBe("spoken reply\n\n📎 https://example.com/reply.mp3");
12231283
});
12241284

1285+
it("does not leak local media paths in the upload failure fallback", async () => {
1286+
const mediaPath = path.join(os.tmpdir(), "openclaw-feishu-local-voice.mp3");
1287+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
1288+
1289+
await feishuOutbound.sendMedia?.({
1290+
cfg: emptyConfig,
1291+
to: "chat_1",
1292+
text: "spoken reply",
1293+
mediaUrl: mediaPath,
1294+
audioAsVoice: true,
1295+
accountId: "main",
1296+
});
1297+
1298+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1299+
expect(sendMessageCall()?.text).toBe("spoken reply\n\nMedia upload failed. Please try again.");
1300+
expect(sendMessageCall()?.text).not.toContain(mediaPath);
1301+
});
1302+
1303+
it("does not leak file URLs in the upload failure fallback", async () => {
1304+
const mediaUrl = "file:///tmp/openclaw-feishu-local-voice.mp3";
1305+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
1306+
1307+
await feishuOutbound.sendMedia?.({
1308+
cfg: emptyConfig,
1309+
to: "chat_1",
1310+
text: "spoken reply",
1311+
mediaUrl,
1312+
audioAsVoice: true,
1313+
accountId: "main",
1314+
});
1315+
1316+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1317+
expect(sendMessageCall()?.text).toBe("spoken reply\n\nMedia upload failed. Please try again.");
1318+
expect(sendMessageCall()?.text).not.toContain(mediaUrl);
1319+
});
1320+
1321+
it("does not leak relative local media references in the upload failure fallback", async () => {
1322+
const mediaUrl = "./outbound/openclaw-feishu-local-voice.mp3";
1323+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
1324+
1325+
await feishuOutbound.sendMedia?.({
1326+
cfg: emptyConfig,
1327+
to: "chat_1",
1328+
text: "spoken reply",
1329+
mediaUrl,
1330+
audioAsVoice: true,
1331+
accountId: "main",
1332+
});
1333+
1334+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1335+
expect(sendMessageCall()?.text).toBe("spoken reply\n\nMedia upload failed. Please try again.");
1336+
expect(sendMessageCall()?.text).not.toContain(mediaUrl);
1337+
});
1338+
1339+
it("does not leak loopback HTTP media references in the upload failure fallback", async () => {
1340+
const mediaUrl = "http://127.0.0.1:3000/tmp/openclaw-voice.mp3";
1341+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
1342+
1343+
await feishuOutbound.sendMedia?.({
1344+
cfg: emptyConfig,
1345+
to: "chat_1",
1346+
text: "spoken reply",
1347+
mediaUrl,
1348+
audioAsVoice: true,
1349+
accountId: "main",
1350+
});
1351+
1352+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1353+
expect(sendMessageCall()?.text).toBe("spoken reply\n\nMedia upload failed. Please try again.");
1354+
expect(sendMessageCall()?.text).not.toContain(mediaUrl);
1355+
});
1356+
1357+
it("does not leak localhost media references in the upload failure fallback", async () => {
1358+
const mediaUrl = "https://localhost/tmp/openclaw-voice.mp3";
1359+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
1360+
1361+
await feishuOutbound.sendMedia?.({
1362+
cfg: emptyConfig,
1363+
to: "chat_1",
1364+
text: "spoken reply",
1365+
mediaUrl,
1366+
audioAsVoice: true,
1367+
accountId: "main",
1368+
});
1369+
1370+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1371+
expect(sendMessageCall()?.text).toBe("spoken reply\n\nMedia upload failed. Please try again.");
1372+
expect(sendMessageCall()?.text).not.toContain(mediaUrl);
1373+
});
1374+
1375+
it("does not leak URLs rejected by private-DNS media checks", async () => {
1376+
const mediaUrl = "https://files.example.test/openclaw-voice.mp3";
1377+
sendMediaFeishuMock.mockRejectedValueOnce(
1378+
new Error(
1379+
`Failed to fetch media from ${mediaUrl}: Blocked: resolves to private/internal/special-use IP address`,
1380+
),
1381+
);
1382+
1383+
await feishuOutbound.sendMedia?.({
1384+
cfg: emptyConfig,
1385+
to: "chat_1",
1386+
text: "spoken reply",
1387+
mediaUrl,
1388+
audioAsVoice: true,
1389+
accountId: "main",
1390+
});
1391+
1392+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1393+
expect(sendMessageCall()?.text).toBe("spoken reply\n\nMedia upload failed. Please try again.");
1394+
expect(sendMessageCall()?.text).not.toContain(mediaUrl);
1395+
});
1396+
12251397
it("forwards replyToId to text caption send", async () => {
12261398
await feishuOutbound.sendMedia?.({
12271399
cfg: emptyConfig,

extensions/feishu/src/outbound.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ import { cleanupAmbientCommentTypingReaction } from "./comment-reaction.js";
2828
import { parseFeishuCommentTarget } from "./comment-target.js";
2929
import { deliverCommentThreadText } from "./drive.js";
3030
import { resolveFeishuIdentityHeaderTitle } from "./identity-header.js";
31+
import {
32+
buildFeishuMediaFallbackText,
33+
buildFeishuMediaUploadFailureFallbackText,
34+
isPublicFeishuMediaReference,
35+
} from "./media-fallback.js";
3136
import { sendMediaFeishu, shouldSuppressFeishuTextForVoiceMedia } from "./media.js";
3237
import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js";
3338
import { buildFeishuPresentationCardElements } from "./presentation-card.js";
@@ -592,7 +597,14 @@ export const feishuOutbound: ChannelOutboundAdapter = {
592597
});
593598
} catch (err) {
594599
console.error(`[feishu] local image path auto-send failed:`, err);
595-
// fall through to plain text as last resort
600+
return await sendOutboundText({
601+
cfg,
602+
to,
603+
text: buildFeishuMediaUploadFailureFallbackText({}),
604+
accountId: accountId ?? undefined,
605+
replyToMessageId,
606+
replyInThread,
607+
});
596608
}
597609
}
598610

@@ -653,11 +665,18 @@ export const feishuOutbound: ChannelOutboundAdapter = {
653665
});
654666
const commentTarget = parseFeishuCommentTarget(to);
655667
if (commentTarget) {
656-
const commentText = [text?.trim(), mediaUrl?.trim()].filter(Boolean).join("\n\n");
668+
const trimmedMediaUrl = mediaUrl?.trim();
669+
const commentMediaText =
670+
trimmedMediaUrl && (await isPublicFeishuMediaReference(trimmedMediaUrl))
671+
? trimmedMediaUrl
672+
: trimmedMediaUrl
673+
? buildFeishuMediaUploadFailureFallbackText({})
674+
: undefined;
675+
const commentText = [text?.trim(), commentMediaText].filter(Boolean).join("\n\n");
657676
return await sendOutboundText({
658677
cfg,
659678
to,
660-
text: commentText || mediaUrl || text || "",
679+
text: commentText || text || "",
661680
accountId: accountId ?? undefined,
662681
replyToMessageId,
663682
replyInThread,
@@ -710,8 +729,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
710729
} catch (err) {
711730
// Log the error for debugging
712731
console.error(`[feishu] sendMediaFeishu failed:`, err);
713-
// Fallback to URL link if upload fails
714-
const fallbackText = [text?.trim(), `📎 ${mediaUrl}`].filter(Boolean).join("\n\n");
732+
const fallbackText = await buildFeishuMediaFallbackText({ text, mediaUrl, error: err });
715733
return await sendOutboundText({
716734
cfg,
717735
to,

0 commit comments

Comments
 (0)