@@ -30,6 +30,26 @@ const shouldSuppressFeishuTextForVoiceMediaMock = vi.hoisted(
3030 ? params . ttsSupplement . visibleTextAlreadyDelivered === true
3131 : params . audioAsVoice === true || / \. (?: o g g | o p u s ) (?: [ ? # ] | $ ) / i. test ( params . mediaUrl ?? "" ) ,
3232) ;
33+ const resolvePinnedHostnameWithPolicyMock = vi . hoisted ( ( ) =>
34+ vi . fn ( async ( hostname : string ) => {
35+ if ( hostname === "files.example.test" ) {
36+ throw new Error ( "Blocked: resolves to private/internal/special-use IP address" ) ;
37+ }
38+ return {
39+ hostname,
40+ addresses : [ "93.184.216.34" ] ,
41+ lookup : vi . fn ( ) ,
42+ } ;
43+ } ) ,
44+ ) ;
45+
46+ vi . mock ( "openclaw/plugin-sdk/ssrf-runtime" , async ( importOriginal ) => {
47+ const actual = await importOriginal < Record < string , unknown > > ( ) ;
48+ return {
49+ ...actual ,
50+ resolvePinnedHostnameWithPolicy : resolvePinnedHostnameWithPolicyMock ,
51+ } ;
52+ } ) ;
3353
3454vi . mock ( "./media.js" , ( ) => ( {
3555 sendMediaFeishu : sendMediaFeishuMock ,
@@ -193,6 +213,7 @@ afterAll(() => {
193213 vi . doUnmock ( "./client.js" ) ;
194214 vi . doUnmock ( "./drive.js" ) ;
195215 vi . doUnmock ( "./comment-reaction.js" ) ;
216+ vi . doUnmock ( "openclaw/plugin-sdk/ssrf-runtime" ) ;
196217 vi . resetModules ( ) ;
197218} ) ;
198219
@@ -496,7 +517,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
496517 expectFeishuResult ( result , "native_card_msg" ) ;
497518 } ) ;
498519
499- it ( "falls back to plain text if local-image media send fails" , async ( ) => {
520+ it ( "does not leak local-image paths if auto- send fails" , async ( ) => {
500521 const { dir, file } = await createTmpImage ( ) ;
501522 sendMediaFeishuMock . mockRejectedValueOnce ( new Error ( "upload failed" ) ) ;
502523 try {
@@ -509,7 +530,8 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
509530
510531 expect ( sendMediaFeishuMock ) . toHaveBeenCalledTimes ( 1 ) ;
511532 expect ( sendMessageCall ( ) ?. to ) . toBe ( "chat_1" ) ;
512- expect ( sendMessageCall ( ) ?. text ) . toBe ( file ) ;
533+ expect ( sendMessageCall ( ) ?. text ) . toBe ( "Media upload failed. Please try again." ) ;
534+ expect ( sendMessageCall ( ) ?. text ) . not . toContain ( file ) ;
513535 expect ( sendMessageCall ( ) ?. accountId ) . toBe ( "main" ) ;
514536 } finally {
515537 await fs . rm ( dir , { recursive : true , force : true } ) ;
@@ -2074,6 +2096,26 @@ describe("feishuOutbound comment-thread routing", () => {
20742096 expectFeishuResult ( result , "reply_msg" ) ;
20752097 } ) ;
20762098
2099+ it . each ( [
2100+ [ "local path" , path . join ( os . tmpdir ( ) , "openclaw-feishu-comment-local-voice.mp3" ) ] ,
2101+ [ "loopback URL" , "http://127.0.0.1:3000/tmp/openclaw-voice.mp3" ] ,
2102+ ] ) ( "does not leak a %s in comment-thread media fallbacks" , async ( _label , mediaUrl ) => {
2103+ const result = await feishuOutbound . sendMedia ?.( {
2104+ cfg : emptyConfig ,
2105+ to : "comment:docx:doxcn123:7623358762119646411" ,
2106+ text : "see attachment" ,
2107+ mediaUrl,
2108+ accountId : "main" ,
2109+ } ) ;
2110+
2111+ expect ( commentThreadParams ( ) ?. content ) . toBe (
2112+ "see attachment\n\nMedia upload failed. Please try again." ,
2113+ ) ;
2114+ expect ( commentThreadParams ( ) ?. content ) . not . toContain ( mediaUrl ) ;
2115+ expect ( sendMediaFeishuMock ) . not . toHaveBeenCalled ( ) ;
2116+ expectFeishuResult ( result , "reply_msg" ) ;
2117+ } ) ;
2118+
20772119 it ( "preserves comment-thread routing when deliverCommentThreadText falls back to add_comment" , async ( ) => {
20782120 deliverCommentThreadTextMock . mockResolvedValueOnce ( {
20792121 delivery_mode : "add_comment" ,
@@ -2511,6 +2553,32 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
25112553 expect ( sendMessageCall ( ) ?. text ) . toBe ( "spoken reply\n\n📎 https://example.com/reply.mp3" ) ;
25122554 } ) ;
25132555
2556+ it . each ( [
2557+ [ "local path" , path . join ( os . tmpdir ( ) , "openclaw-feishu-local-voice.mp3" ) ] ,
2558+ [ "file URL" , "file:///tmp/openclaw-feishu-local-voice.mp3" ] ,
2559+ [ "relative path" , "./outbound/openclaw-feishu-local-voice.mp3" ] ,
2560+ [ "loopback URL" , "http://127.0.0.1:3000/tmp/openclaw-voice.mp3" ] ,
2561+ [ "localhost URL" , "https://localhost/tmp/openclaw-voice.mp3" ] ,
2562+ [ "private-DNS URL" , "https://files.example.test/openclaw-voice.mp3" ] ,
2563+ [ "credentialed URL" , "https://[email protected] /openclaw-voice.mp3" ] , 2564+ [ "control-character URL" , "https://example.com/\nhttp://127.0.0.1/private" ] ,
2565+ ] ) ( "does not leak a %s in the upload failure fallback" , async ( _label , mediaUrl ) => {
2566+ sendMediaFeishuMock . mockRejectedValueOnce ( new Error ( "upload failed" ) ) ;
2567+
2568+ await feishuOutbound . sendMedia ?.( {
2569+ cfg : emptyConfig ,
2570+ to : "chat_1" ,
2571+ text : "spoken reply" ,
2572+ mediaUrl,
2573+ audioAsVoice : true ,
2574+ accountId : "main" ,
2575+ } ) ;
2576+
2577+ expect ( sendMessageFeishuMock ) . toHaveBeenCalledTimes ( 1 ) ;
2578+ expect ( sendMessageCall ( ) ?. text ) . toBe ( "spoken reply\n\nMedia upload failed. Please try again." ) ;
2579+ expect ( sendMessageCall ( ) ?. text ) . not . toContain ( mediaUrl ) ;
2580+ } ) ;
2581+
25142582 it ( "forwards replyToId to text caption send" , async ( ) => {
25152583 await feishuOutbound . sendMedia ?.( {
25162584 cfg : emptyConfig ,
0 commit comments