fix: accept file/media tokens safely and improve web media send

main
Peter Steinberger 2025-11-25 05:34:08 +01:00
parent 9bf35d3272
commit 5ce869f86c
3 changed files with 56 additions and 10 deletions

View File

@ -24,11 +24,17 @@ import { sendTypingIndicator } from "../twilio/typing.js";
import type { TwilioRequester } from "../twilio/types.js"; import type { TwilioRequester } from "../twilio/types.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { logError } from "../logger.js"; import { logError } from "../logger.js";
import { ensureMediaHosted } from "../media/host.js";
type GetReplyOptions = { type GetReplyOptions = {
onReplyStart?: () => Promise<void> | void; onReplyStart?: () => Promise<void> | void;
}; };
function normalizeMediaSource(src: string) {
if (src.startsWith("file://")) return src.replace("file://", "");
return src;
}
function summarizeClaudeMetadata(payload: unknown): string | undefined { function summarizeClaudeMetadata(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object") return undefined; if (!payload || typeof payload !== "object") return undefined;
const obj = payload as Record<string, unknown>; const obj = payload as Record<string, unknown>;
@ -295,8 +301,8 @@ const mediaNote =
if (mediaLine) { if (mediaLine) {
const after = mediaLine.replace(/^MEDIA:\s*/i, ""); const after = mediaLine.replace(/^MEDIA:\s*/i, "");
const parts = after.trim().split(/\s+/); const parts = after.trim().split(/\s+/);
if (parts.length === 1 && parts[0]) { if (parts[0]) {
mediaFromCommand = parts[0]; mediaFromCommand = normalizeMediaSource(parts[0]);
} }
trimmed = rawStdout trimmed = rawStdout
.split("\n") .split("\n")
@ -310,10 +316,14 @@ const mediaNote =
const looksLikeUrl = mediaFromCommand const looksLikeUrl = mediaFromCommand
? /^https?:\/\//i.test(mediaFromCommand) ? /^https?:\/\//i.test(mediaFromCommand)
: false; : false;
const looksLikePath = mediaFromCommand
? mediaFromCommand.startsWith("/") || mediaFromCommand.startsWith("./")
: false;
if ( if (
!mediaFromCommand || !mediaFromCommand ||
hasWhitespace || hasWhitespace ||
(!looksLikeUrl && mediaFromCommand.length > 1024) (!looksLikeUrl && !looksLikePath) ||
mediaFromCommand.length > 1024
) { ) {
mediaFromCommand = undefined; mediaFromCommand = undefined;
} }
@ -440,11 +450,16 @@ export async function autoReplyIfConfigured(
} }
try { try {
let mediaUrl = replyResult.mediaUrl;
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
const hosted = await ensureMediaHosted(mediaUrl);
mediaUrl = hosted.url;
}
await client.messages.create({ await client.messages.create({
from: replyFrom, from: replyFrom,
to: replyTo, to: replyTo,
body: replyResult.text ?? "", body: replyResult.text ?? "",
...(replyResult.mediaUrl ? { mediaUrl: [replyResult.mediaUrl] } : {}), ...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
}); });
if (isVerbose()) { if (isVerbose()) {
console.log( console.log(

View File

@ -146,6 +146,31 @@ describe("config and templating", () => {
expect(result?.mediaUrl).toBe("https://example.com/img.jpg"); 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 () => { it("ignores invalid MEDIA lines with whitespace", async () => {
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
stdout: "hello\nMEDIA: not a url with spaces\nrest\n", stdout: "hello\nMEDIA: not a url with spaces\nrest\n",

View File

@ -117,12 +117,15 @@ export async function sendMessageWeb(
logVerbose(`Presence update skipped: ${String(err)}`); logVerbose(`Presence update skipped: ${String(err)}`);
} }
let payload: AnyMessageContent = { text: body }; let payload: AnyMessageContent = { text: body };
if (options.mediaUrl) { if (options.mediaUrl) {
const media = await loadWebMedia(options.mediaUrl); const normalized = options.mediaUrl.startsWith("file://")
payload = { ? options.mediaUrl.replace("file://", "")
image: media.buffer, : options.mediaUrl;
caption: body || undefined, const media = await loadWebMedia(options.mediaUrl);
mimetype: media.contentType, payload = {
image: media.buffer,
caption: body || undefined,
mimetype: media.contentType,
}; };
} }
logInfo( logInfo(
@ -514,6 +517,9 @@ async function downloadInboundMedia(
async function loadWebMedia( async function loadWebMedia(
mediaUrl: string, mediaUrl: string,
): Promise<{ buffer: Buffer; contentType?: string }> { ): Promise<{ buffer: Buffer; contentType?: string }> {
if (mediaUrl.startsWith("file://")) {
mediaUrl = mediaUrl.replace("file://", "");
}
if (/^https?:\/\//i.test(mediaUrl)) { if (/^https?:\/\//i.test(mediaUrl)) {
const res = await fetch(mediaUrl); const res = await fetch(mediaUrl);
if (!res.ok || !res.body) { if (!res.ok || !res.body) {