mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-05 22:32:12 +00:00
fix(telegram): trust local bot api media roots
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>";
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
fetchRemoteMedia,
|
||||
getAgentScopedMediaLocalRoots,
|
||||
MediaFetchError,
|
||||
saveMediaBuffer,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user