@@ -18,6 +18,26 @@ const shouldSuppressFeishuTextForVoiceMediaMock = vi.hoisted(
1818 ( ) => ( params : { mediaUrl ?: string ; audioAsVoice ?: boolean } ) =>
1919 params . audioAsVoice === true || / \. (?: o g g | o p u s ) (?: [ ? # ] | $ ) / 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
2242vi . 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 ,
0 commit comments