fix(telegram): add dangerous private-network media opt-in

This commit is contained in:
Peter Steinberger
2026-04-03 18:36:27 +09:00
parent f29c139a7a
commit 4bfa9260ce
11 changed files with 125 additions and 19 deletions

View File

@@ -6,6 +6,7 @@ import {
} from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import { mergeTelegramAccountConfig } from "./accounts.js";
import {
hasInboundMedia,
isRecoverableMediaGroupError,
@@ -84,7 +85,9 @@ export function createTelegramInboundBufferRuntime(params: {
runtime,
telegramTransport,
} = params;
const telegramCfg = cfg.channels?.telegram;
const telegramCfg = accountId
? mergeTelegramAccountConfig(cfg, accountId)
: cfg.channels?.telegram;
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS =
typeof opts.testTimings?.textFragmentGapMs === "number" &&
@@ -158,6 +161,7 @@ export function createTelegramInboundBufferRuntime(params: {
opts.token,
telegramTransport,
telegramCfg?.apiRoot,
telegramCfg?.network?.dangerouslyAllowPrivateNetwork,
);
if (!media) {
return [];
@@ -188,7 +192,14 @@ export function createTelegramInboundBufferRuntime(params: {
for (const { ctx } of entry.messages) {
let media;
try {
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport, telegramCfg?.apiRoot);
media = await resolveMedia(
ctx,
mediaMaxBytes,
opts.token,
telegramTransport,
telegramCfg?.apiRoot,
telegramCfg?.network?.dangerouslyAllowPrivateNetwork,
);
} catch (mediaErr) {
if (!isRecoverableMediaGroupError(mediaErr)) {
throw mediaErr;

View File

@@ -387,6 +387,7 @@ export const registerTelegramHandlers = ({
opts.token,
telegramTransport,
telegramCfg.apiRoot,
telegramCfg.network?.dangerouslyAllowPrivateNetwork,
);
} catch (mediaErr) {
if (!isRecoverableMediaGroupError(mediaErr)) {
@@ -495,6 +496,7 @@ export const registerTelegramHandlers = ({
opts.token,
telegramTransport,
telegramCfg.apiRoot,
telegramCfg.network?.dangerouslyAllowPrivateNetwork,
);
if (!media) {
return [];
@@ -1019,6 +1021,7 @@ export const registerTelegramHandlers = ({
opts.token,
telegramTransport,
telegramCfg.apiRoot,
telegramCfg.network?.dangerouslyAllowPrivateNetwork,
);
} catch (mediaErr) {
if (isMediaSizeLimitError(mediaErr)) {

View File

@@ -546,19 +546,32 @@ describe("resolveMedia original filename preservation", () => {
expect(result).not.toBeNull();
});
it("opts into private-network Telegram media downloads only when explicitly configured", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
mockPdfFetchAndSave("file_42.pdf");
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN, undefined, undefined, true);
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
ssrfPolicy: {
hostnameAllowlist: ["api.telegram.org"],
allowPrivateNetwork: true,
allowRfc2544BenchmarkRange: true,
},
}),
);
expect(result).not.toBeNull();
});
it("constructs correct download URL with custom apiRoot for documents", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
mockPdfFetchAndSave("file_42.pdf");
const customApiRoot = "http://192.168.1.50:8081/custom-bot-api";
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(
ctx,
MAX_MEDIA_BYTES,
BOT_TOKEN,
undefined,
customApiRoot,
);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN, undefined, customApiRoot);
// Verify the URL uses the custom apiRoot, not the default Telegram API
expect(fetchRemoteMedia).toHaveBeenCalledWith(
@@ -583,13 +596,7 @@ describe("resolveMedia original filename preservation", () => {
const customApiRoot = "http://localhost:8081/bot";
const ctx = makeCtx("sticker", getFile);
const result = await resolveMedia(
ctx,
MAX_MEDIA_BYTES,
BOT_TOKEN,
undefined,
customApiRoot,
);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN, undefined, customApiRoot);
// Verify the URL uses the custom apiRoot for sticker downloads
expect(fetchRemoteMedia).toHaveBeenCalledWith(

View File

@@ -18,7 +18,7 @@ const FILE_TOO_BIG_RE = /file is too big/i;
const GrammyErrorCtor: typeof GrammyError | undefined =
typeof GrammyError === "function" ? GrammyError : undefined;
function buildTelegramMediaSsrfPolicy(apiRoot?: string) {
function buildTelegramMediaSsrfPolicy(apiRoot?: string, dangerouslyAllowPrivateNetwork?: boolean) {
const hostnames = ["api.telegram.org"];
let allowedHostnames: string[] | undefined;
if (apiRoot) {
@@ -41,6 +41,7 @@ function buildTelegramMediaSsrfPolicy(apiRoot?: string) {
// enforcing SSRF checks on the resolved and redirected targets.
hostnameAllowlist: hostnames,
...(allowedHostnames ? { allowedHostnames } : {}),
...(dangerouslyAllowPrivateNetwork ? { allowPrivateNetwork: true } : {}),
allowRfc2544BenchmarkRange: true,
};
}
@@ -169,6 +170,7 @@ async function downloadAndSaveTelegramFile(params: {
telegramFileName?: string;
mimeType?: string;
apiRoot?: string;
dangerouslyAllowPrivateNetwork?: boolean;
}) {
if (path.isAbsolute(params.filePath)) {
return { path: params.filePath, contentType: params.mimeType };
@@ -183,7 +185,7 @@ async function downloadAndSaveTelegramFile(params: {
filePathHint: params.filePath,
maxBytes: params.maxBytes,
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot),
ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot, params.dangerouslyAllowPrivateNetwork),
});
const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath;
return saveMediaBuffer(
@@ -202,6 +204,7 @@ async function resolveStickerMedia(params: {
token: string;
transport?: TelegramTransport;
apiRoot?: string;
dangerouslyAllowPrivateNetwork?: boolean;
}): Promise<
| {
path: string;
@@ -243,6 +246,7 @@ async function resolveStickerMedia(params: {
transport: resolvedTransport,
maxBytes,
apiRoot: params.apiRoot,
dangerouslyAllowPrivateNetwork: params.dangerouslyAllowPrivateNetwork,
});
// Check sticker cache for existing description
@@ -299,6 +303,7 @@ export async function resolveMedia(
token: string,
transport?: TelegramTransport,
apiRoot?: string,
dangerouslyAllowPrivateNetwork?: boolean,
): Promise<{
path: string;
contentType?: string;
@@ -313,6 +318,7 @@ export async function resolveMedia(
token,
transport,
apiRoot,
dangerouslyAllowPrivateNetwork,
});
if (stickerResolved !== undefined) {
return stickerResolved;
@@ -339,6 +345,7 @@ export async function resolveMedia(
telegramFileName: metadata.fileName,
mimeType: metadata.mimeType,
apiRoot,
dangerouslyAllowPrivateNetwork,
});
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
return { path: saved.path, contentType: saved.contentType, placeholder };

View File

@@ -53,6 +53,10 @@ export const telegramChannelConfigUiHints = {
label: "Telegram autoSelectFamily",
help: "Override Node autoSelectFamily for Telegram (true=enable, false=disable).",
},
"network.dangerouslyAllowPrivateNetwork": {
label: "Telegram Dangerously Allow Private Network",
help: "Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses.",
},
timeoutSeconds: {
label: "Telegram API Timeout (seconds)",
help: "Max seconds before Telegram API requests are aborted (default: 500 per grammY).",