From 5ce869f86c6a1a63ae031aa7e4528ecbe1de6958 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Nov 2025 05:34:08 +0100 Subject: [PATCH] fix: accept file/media tokens safely and improve web media send --- src/auto-reply/reply.ts | 23 +++++++++++++++++++---- src/index.core.test.ts | 25 +++++++++++++++++++++++++ src/provider-web.ts | 18 ++++++++++++------ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 336bab6f9..2dee6a7ef 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -24,11 +24,17 @@ import { sendTypingIndicator } from "../twilio/typing.js"; import type { TwilioRequester } from "../twilio/types.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { logError } from "../logger.js"; +import { ensureMediaHosted } from "../media/host.js"; type GetReplyOptions = { onReplyStart?: () => Promise | void; }; +function normalizeMediaSource(src: string) { + if (src.startsWith("file://")) return src.replace("file://", ""); + return src; +} + function summarizeClaudeMetadata(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const obj = payload as Record; @@ -295,8 +301,8 @@ const mediaNote = if (mediaLine) { const after = mediaLine.replace(/^MEDIA:\s*/i, ""); const parts = after.trim().split(/\s+/); - if (parts.length === 1 && parts[0]) { - mediaFromCommand = parts[0]; + if (parts[0]) { + mediaFromCommand = normalizeMediaSource(parts[0]); } trimmed = rawStdout .split("\n") @@ -310,10 +316,14 @@ const mediaNote = const looksLikeUrl = mediaFromCommand ? /^https?:\/\//i.test(mediaFromCommand) : false; + const looksLikePath = mediaFromCommand + ? mediaFromCommand.startsWith("/") || mediaFromCommand.startsWith("./") + : false; if ( !mediaFromCommand || hasWhitespace || - (!looksLikeUrl && mediaFromCommand.length > 1024) + (!looksLikeUrl && !looksLikePath) || + mediaFromCommand.length > 1024 ) { mediaFromCommand = undefined; } @@ -440,11 +450,16 @@ export async function autoReplyIfConfigured( } try { + let mediaUrl = replyResult.mediaUrl; + if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) { + const hosted = await ensureMediaHosted(mediaUrl); + mediaUrl = hosted.url; + } await client.messages.create({ from: replyFrom, to: replyTo, body: replyResult.text ?? "", - ...(replyResult.mediaUrl ? { mediaUrl: [replyResult.mediaUrl] } : {}), + ...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}), }); if (isVerbose()) { console.log( diff --git a/src/index.core.test.ts b/src/index.core.test.ts index f772790ee..65c45983a 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -146,6 +146,31 @@ describe("config and templating", () => { expect(result?.mediaUrl).toBe("https://example.com/img.jpg"); }); + it("extracts first MEDIA token even with trailing text", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "hello\nMEDIA:/tmp/pic.png extra words here\n", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + }, + }, + }; + const result = await index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + expect(result?.mediaUrl).toBe("/tmp/pic.png"); + }); + it("ignores invalid MEDIA lines with whitespace", async () => { const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ stdout: "hello\nMEDIA: not a url with spaces\nrest\n", diff --git a/src/provider-web.ts b/src/provider-web.ts index 78ff9144a..f5adae8e9 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -117,12 +117,15 @@ export async function sendMessageWeb( logVerbose(`Presence update skipped: ${String(err)}`); } let payload: AnyMessageContent = { text: body }; - if (options.mediaUrl) { - const media = await loadWebMedia(options.mediaUrl); - payload = { - image: media.buffer, - caption: body || undefined, - mimetype: media.contentType, + if (options.mediaUrl) { + const normalized = options.mediaUrl.startsWith("file://") + ? options.mediaUrl.replace("file://", "") + : options.mediaUrl; + const media = await loadWebMedia(options.mediaUrl); + payload = { + image: media.buffer, + caption: body || undefined, + mimetype: media.contentType, }; } logInfo( @@ -514,6 +517,9 @@ async function downloadInboundMedia( async function loadWebMedia( mediaUrl: string, ): Promise<{ buffer: Buffer; contentType?: string }> { + if (mediaUrl.startsWith("file://")) { + mediaUrl = mediaUrl.replace("file://", ""); + } if (/^https?:\/\//i.test(mediaUrl)) { const res = await fetch(mediaUrl); if (!res.ok || !res.body) {