diff --git a/extensions/telegram/src/accounts.test.ts b/extensions/telegram/src/accounts.test.ts index 6ab3390a3ab..ed487db10cf 100644 --- a/extensions/telegram/src/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -451,6 +451,7 @@ describe("resolveTelegramMediaRuntimeOptions", () => { network: { dangerouslyAllowPrivateNetwork: false, }, + trustedLocalFileRoots: ["/srv/telegram/cache"], accounts: { work: { botToken: "123:work", @@ -458,6 +459,7 @@ describe("resolveTelegramMediaRuntimeOptions", () => { network: { dangerouslyAllowPrivateNetwork: true, }, + trustedLocalFileRoots: ["/var/lib/telegram-bot-api"], }, }, }, @@ -470,6 +472,7 @@ describe("resolveTelegramMediaRuntimeOptions", () => { expect(resolved).toEqual({ token: "123:work", apiRoot: "http://tg-proxy.internal:8081", + trustedLocalFileRoots: ["/var/lib/telegram-bot-api"], dangerouslyAllowPrivateNetwork: true, transport: undefined, }); @@ -484,6 +487,7 @@ describe("resolveTelegramMediaRuntimeOptions", () => { network: { dangerouslyAllowPrivateNetwork: true, }, + trustedLocalFileRoots: ["/srv/telegram/cache"], accounts: { work: { botToken: "123:work", @@ -499,6 +503,7 @@ describe("resolveTelegramMediaRuntimeOptions", () => { expect(resolved).toEqual({ token: "123:work", apiRoot: "http://tg-proxy.internal:8081", + trustedLocalFileRoots: ["/srv/telegram/cache"], dangerouslyAllowPrivateNetwork: true, transport: undefined, }); diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index 84a5b4344e4..dafd9e9a25b 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -62,6 +62,7 @@ export type TelegramMediaRuntimeOptions = { token: string; transport?: TelegramTransport; apiRoot?: string; + trustedLocalFileRoots?: readonly string[]; dangerouslyAllowPrivateNetwork?: boolean; }; @@ -179,6 +180,7 @@ export function resolveTelegramMediaRuntimeOptions(params: { token: params.token, transport: params.transport, apiRoot: accountCfg?.apiRoot, + trustedLocalFileRoots: accountCfg?.trustedLocalFileRoots, dangerouslyAllowPrivateNetwork: accountCfg?.network?.dangerouslyAllowPrivateNetwork, }; } diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 904eb008cec..248e3721639 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -6,12 +6,27 @@ import type { TelegramContext } from "./types.js"; const saveMediaBuffer = vi.fn(); const fetchRemoteMedia = vi.fn(); +const readFileWithinRoot = vi.fn(); + +vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({ + readFileWithinRoot: (...args: unknown[]) => readFileWithinRoot(...args), +})); vi.mock("./delivery.resolve-media.runtime.js", () => { + class MediaFetchError extends Error { + code: string; + + constructor(code: string, message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "MediaFetchError"; + this.code = code; + } + } return { fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args), formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)), logVerbose: () => {}, + MediaFetchError, resolveTelegramApiBase: (apiRoot?: string) => apiRoot?.trim() ? apiRoot.replace(/\/+$/u, "") : "https://api.telegram.org", retryAsync, @@ -186,6 +201,7 @@ describe("resolveMedia getFile retry", () => { vi.useFakeTimers(); fetchRemoteMedia.mockReset(); saveMediaBuffer.mockReset(); + readFileWithinRoot.mockReset(); }); afterEach(() => { @@ -407,40 +423,134 @@ describe("resolveMedia getFile retry", () => { ); }); - it("uses local absolute file paths directly for media downloads", async () => { + it("copies trusted local absolute file paths into inbound media storage for media downloads", async () => { const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); + readFileWithinRoot.mockResolvedValueOnce({ + buffer: Buffer.from("pdf-data"), + realPath: "/var/lib/telegram-bot-api/file.pdf", + stat: { size: 8 }, + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/inbound/file.pdf", + contentType: "application/pdf", + }); const result = await resolveMediaWithDefaults( makeCtx("document", getFile, { mime_type: "application/pdf" }), + { trustedLocalFileRoots: ["/var/lib/telegram-bot-api"] }, ); expect(fetchRemoteMedia).not.toHaveBeenCalled(); - expect(saveMediaBuffer).not.toHaveBeenCalled(); + expect(readFileWithinRoot).toHaveBeenCalledWith({ + rootDir: "/var/lib/telegram-bot-api", + relativePath: "file.pdf", + maxBytes: MAX_MEDIA_BYTES, + }); + expect(saveMediaBuffer).toHaveBeenCalledWith( + Buffer.from("pdf-data"), + "application/pdf", + "inbound", + MAX_MEDIA_BYTES, + "file.pdf", + ); expect(result).toEqual( expect.objectContaining({ - path: "/var/lib/telegram-bot-api/file.pdf", + path: "/tmp/inbound/file.pdf", contentType: "application/pdf", placeholder: "", }), ); }); - it("uses local absolute file paths directly for sticker downloads", async () => { + it("copies trusted local absolute file paths into inbound media storage for sticker downloads", async () => { const getFile = vi .fn() .mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/sticker.webp" }); + readFileWithinRoot.mockResolvedValueOnce({ + buffer: Buffer.from("sticker-data"), + realPath: "/var/lib/telegram-bot-api/sticker.webp", + stat: { size: 12 }, + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/inbound/sticker.webp", + contentType: "image/webp", + }); - const result = await resolveMediaWithDefaults(makeCtx("sticker", getFile)); + const result = await resolveMediaWithDefaults(makeCtx("sticker", getFile), { + trustedLocalFileRoots: ["/var/lib/telegram-bot-api"], + }); expect(fetchRemoteMedia).not.toHaveBeenCalled(); - expect(saveMediaBuffer).not.toHaveBeenCalled(); + expect(readFileWithinRoot).toHaveBeenCalledWith({ + rootDir: "/var/lib/telegram-bot-api", + relativePath: "sticker.webp", + maxBytes: MAX_MEDIA_BYTES, + }); + expect(saveMediaBuffer).toHaveBeenCalledWith( + Buffer.from("sticker-data"), + undefined, + "inbound", + MAX_MEDIA_BYTES, + "sticker.webp", + ); expect(result).toEqual( expect.objectContaining({ - path: "/var/lib/telegram-bot-api/sticker.webp", + path: "/tmp/inbound/sticker.webp", placeholder: "", }), ); }); + + it("maps trusted local absolute path read failures to MediaFetchError", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); + readFileWithinRoot.mockRejectedValueOnce(new Error("file not found")); + + await expect( + resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" }), { + trustedLocalFileRoots: ["/var/lib/telegram-bot-api"], + }), + ).rejects.toEqual( + expect.objectContaining({ + name: "MediaFetchError", + code: "fetch_failed", + message: expect.stringContaining("/var/lib/telegram-bot-api/file.pdf"), + }), + ); + }); + + it("maps oversized trusted local absolute path reads to MediaFetchError", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); + readFileWithinRoot.mockRejectedValueOnce(new Error("file exceeds limit")); + + await expect( + resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" }), { + trustedLocalFileRoots: ["/var/lib/telegram-bot-api"], + }), + ).rejects.toEqual( + expect.objectContaining({ + name: "MediaFetchError", + code: "fetch_failed", + message: expect.stringContaining("file exceeds limit"), + }), + ); + }); + + it("rejects absolute Bot API file paths outside trustedLocalFileRoots", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); + + await expect( + resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" })), + ).rejects.toEqual( + expect.objectContaining({ + name: "MediaFetchError", + code: "fetch_failed", + message: expect.stringContaining("outside trustedLocalFileRoots"), + }), + ); + + expect(readFileWithinRoot).not.toHaveBeenCalled(); + expect(fetchRemoteMedia).not.toHaveBeenCalled(); + }); }); describe("resolveMedia original filename preservation", () => { diff --git a/extensions/telegram/src/bot/delivery.resolve-media.runtime.ts b/extensions/telegram/src/bot/delivery.resolve-media.runtime.ts index d7f2fdb3ba1..827574d5bfb 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.runtime.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.runtime.ts @@ -1,12 +1,13 @@ import { logVerbose, retryAsync, warn } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveTelegramApiBase, shouldRetryTelegramTransportFallback } from "../fetch.js"; -import { fetchRemoteMedia, saveMediaBuffer } from "../telegram-media.runtime.js"; +import { fetchRemoteMedia, MediaFetchError, saveMediaBuffer } from "../telegram-media.runtime.js"; export { fetchRemoteMedia, formatErrorMessage, logVerbose, + MediaFetchError, resolveTelegramApiBase, retryAsync, saveMediaBuffer, diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index 073b381167e..47a6dcb67c2 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -1,11 +1,13 @@ import path from "node:path"; import { GrammyError } from "grammy"; +import { readFileWithinRoot } from "openclaw/plugin-sdk/infra-runtime"; import type { TelegramTransport } from "../fetch.js"; import { cacheSticker, getCachedSticker } from "../sticker-cache.js"; import { fetchRemoteMedia, formatErrorMessage, logVerbose, + MediaFetchError, resolveTelegramApiBase, retryAsync, saveMediaBuffer, @@ -152,36 +154,77 @@ function resolveRequiredTelegramTransport(transport?: TelegramTransport): Telegr }; } -function resolveOptionalTelegramTransport(transport?: TelegramTransport): TelegramTransport | null { - try { - return resolveRequiredTelegramTransport(transport); - } catch { - return null; - } -} - /** Default idle timeout for Telegram media downloads (30 seconds). */ const TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000; +function resolveTrustedLocalTelegramRoot( + filePath: string, + trustedLocalFileRoots?: readonly string[], +): { rootDir: string; relativePath: string } | null { + if (!path.isAbsolute(filePath)) { + return null; + } + for (const rootDir of trustedLocalFileRoots ?? []) { + const relativePath = path.relative(rootDir, filePath); + if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + continue; + } + return { rootDir, relativePath }; + } + return null; +} + async function downloadAndSaveTelegramFile(params: { filePath: string; token: string; - transport: TelegramTransport; + transport?: TelegramTransport; maxBytes: number; telegramFileName?: string; mimeType?: string; apiRoot?: string; + trustedLocalFileRoots?: readonly string[]; dangerouslyAllowPrivateNetwork?: boolean; }) { - if (path.isAbsolute(params.filePath)) { - return { path: params.filePath, contentType: params.mimeType }; + const trustedLocalFile = resolveTrustedLocalTelegramRoot( + params.filePath, + params.trustedLocalFileRoots, + ); + if (trustedLocalFile) { + let localFile; + try { + localFile = await readFileWithinRoot({ + rootDir: trustedLocalFile.rootDir, + relativePath: trustedLocalFile.relativePath, + maxBytes: params.maxBytes, + }); + } catch (err) { + throw new MediaFetchError( + "fetch_failed", + `Failed to read local Telegram Bot API media from ${params.filePath}: ${formatErrorMessage(err)}`, + { cause: err }, + ); + } + return await saveMediaBuffer( + localFile.buffer, + params.mimeType, + "inbound", + params.maxBytes, + params.telegramFileName ?? path.basename(localFile.realPath), + ); } + if (path.isAbsolute(params.filePath)) { + throw new MediaFetchError( + "fetch_failed", + `Telegram Bot API returned absolute file path ${params.filePath} outside trustedLocalFileRoots`, + ); + } + const transport = resolveRequiredTelegramTransport(params.transport); const apiBase = resolveTelegramApiBase(params.apiRoot); const url = `${apiBase}/file/bot${params.token}/${params.filePath}`; const fetched = await fetchRemoteMedia({ url, - fetchImpl: params.transport.sourceFetch, - dispatcherAttempts: params.transport.dispatcherAttempts, + fetchImpl: transport.sourceFetch, + dispatcherAttempts: transport.dispatcherAttempts, shouldRetryFetchError: shouldRetryTelegramTransportFallback, filePathHint: params.filePath, maxBytes: params.maxBytes, @@ -205,6 +248,7 @@ async function resolveStickerMedia(params: { token: string; transport?: TelegramTransport; apiRoot?: string; + trustedLocalFileRoots?: readonly string[]; dangerouslyAllowPrivateNetwork?: boolean; }): Promise< | { @@ -236,17 +280,13 @@ async function resolveStickerMedia(params: { logVerbose("telegram: getFile returned no file_path for sticker"); return null; } - const resolvedTransport = resolveOptionalTelegramTransport(transport); - if (!resolvedTransport) { - logVerbose("telegram: fetch not available for sticker download"); - return null; - } const saved = await downloadAndSaveTelegramFile({ filePath: file.file_path, token, - transport: resolvedTransport, + transport, maxBytes, apiRoot: params.apiRoot, + trustedLocalFileRoots: params.trustedLocalFileRoots, dangerouslyAllowPrivateNetwork: params.dangerouslyAllowPrivateNetwork, }); @@ -304,6 +344,7 @@ export async function resolveMedia(params: { token: string; transport?: TelegramTransport; apiRoot?: string; + trustedLocalFileRoots?: readonly string[]; dangerouslyAllowPrivateNetwork?: boolean; }): Promise<{ path: string; @@ -311,7 +352,15 @@ export async function resolveMedia(params: { placeholder: string; stickerMetadata?: StickerMetadata; } | null> { - const { ctx, maxBytes, token, transport, apiRoot, dangerouslyAllowPrivateNetwork } = params; + const { + ctx, + maxBytes, + token, + transport, + apiRoot, + trustedLocalFileRoots, + dangerouslyAllowPrivateNetwork, + } = params; const msg = ctx.message; const stickerResolved = await resolveStickerMedia({ msg, @@ -320,6 +369,7 @@ export async function resolveMedia(params: { token, transport, apiRoot, + trustedLocalFileRoots, dangerouslyAllowPrivateNetwork, }); if (stickerResolved !== undefined) { @@ -342,11 +392,12 @@ export async function resolveMedia(params: { const saved = await downloadAndSaveTelegramFile({ filePath: file.file_path, token, - transport: resolveRequiredTelegramTransport(transport), + transport, maxBytes, telegramFileName: metadata.fileName, mimeType: metadata.mimeType, apiRoot, + trustedLocalFileRoots, dangerouslyAllowPrivateNetwork, }); const placeholder = resolveTelegramMediaPlaceholder(msg) ?? ""; diff --git a/extensions/telegram/src/config-ui-hints.ts b/extensions/telegram/src/config-ui-hints.ts index cca9ff294d0..13b4ecacbfd 100644 --- a/extensions/telegram/src/config-ui-hints.ts +++ b/extensions/telegram/src/config-ui-hints.ts @@ -69,6 +69,10 @@ export const telegramChannelConfigUiHints = { label: "Telegram API Root URL", help: "Custom Telegram Bot API root URL. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked.", }, + trustedLocalFileRoots: { + label: "Telegram Trusted Local File Roots", + help: "Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths inside these roots are read directly; all other absolute paths are rejected.", + }, autoTopicLabel: { label: "Telegram Auto Topic Label", help: "Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: '...' } for custom prompt.", diff --git a/extensions/telegram/src/telegram-media.runtime.ts b/extensions/telegram/src/telegram-media.runtime.ts index 1dbe3fb8877..f95d0969aca 100644 --- a/extensions/telegram/src/telegram-media.runtime.ts +++ b/extensions/telegram/src/telegram-media.runtime.ts @@ -1,5 +1,6 @@ export { fetchRemoteMedia, getAgentScopedMediaLocalRoots, + MediaFetchError, saveMediaBuffer, } from "openclaw/plugin-sdk/media-runtime"; diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 0da7ce008c3..448a7b683e5 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -229,6 +229,8 @@ export type TelegramAccountConfig = { ackReaction?: string; /** Custom Telegram Bot API root URL (e.g. "https://my-proxy.example.com" or a local Bot API server). */ apiRoot?: string; + /** Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. */ + trustedLocalFileRoots?: string[]; /** Auto-rename DM forum topics on first message using LLM. Default: true. */ autoTopicLabel?: AutoTopicLabelConfig; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 4931049a062..f2c3b7e5065 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -298,6 +298,12 @@ export const TelegramAccountSchemaBase = z errorPolicy: TelegramErrorPolicySchema, errorCooldownMs: z.number().int().nonnegative().optional(), apiRoot: z.string().url().optional(), + trustedLocalFileRoots: z + .array(z.string()) + .optional() + .describe( + "Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.", + ), autoTopicLabel: AutoTopicLabelSchema, }) .strict();