fix(telegram): trust local bot api media roots

This commit is contained in:
Ayaan Zaidi 2026-04-04 11:10:22 +05:30
parent c91b6bf322
commit cfc52fcf2b
9 changed files with 211 additions and 29 deletions

View File

@ -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,
});

View File

@ -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,
};
}

View File

@ -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: "<media:document>",
}),
);
});
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: "<media:sticker>",
}),
);
});
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", () => {

View File

@ -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,

View File

@ -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) ?? "<media:document>";

View File

@ -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.",

View File

@ -1,5 +1,6 @@
export {
fetchRemoteMedia,
getAgentScopedMediaLocalRoots,
MediaFetchError,
saveMediaBuffer,
} from "openclaw/plugin-sdk/media-runtime";

View File

@ -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;
};

View File

@ -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();