mirror of https://github.com/openclaw/openclaw.git
fix(telegram): add dangerous private-network media opt-in
This commit is contained in:
parent
f29c139a7a
commit
4bfa9260ce
|
|
@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Agents/sessions: release embedded runner session locks even when teardown cleanup throws, so timed-out or failed cleanup paths no longer leave sessions wedged until the stale-lock watchdog recovers them. (#59194) Thanks @samzong.
|
||||
- Slack/app manifest: add the missing `groups:read` scope to the onboarding and example Slack app manifest so apps copied from the OpenClaw templates can resolve private group conversations reliably.
|
||||
- Mobile pairing/Android: stop generating Tailscale and public mobile setup codes that point at unusable cleartext remote gateways, keep private LAN pairing allowed, and make Android reject insecure remote endpoints with clearer guidance while mixed bootstrap approvals honor operator scopes correctly. (#60128) Thanks @obviyus.
|
||||
- Telegram/media: add `channels.telegram.network.dangerouslyAllowPrivateNetwork` for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve `api.telegram.org` to private/internal/special-use addresses.
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
|
|
|
|||
|
|
@ -47783,6 +47783,16 @@
|
|||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.telegram.accounts.*.network.dangerouslyAllowPrivateNetwork",
|
||||
"kind": "channel",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.telegram.accounts.*.network.dnsResultOrder",
|
||||
"kind": "channel",
|
||||
|
|
@ -49987,6 +49997,24 @@
|
|||
"help": "Override Node autoSelectFamily for Telegram (true=enable, false=disable).",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.telegram.network.dangerouslyAllowPrivateNetwork",
|
||||
"kind": "channel",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access",
|
||||
"advanced",
|
||||
"channels",
|
||||
"network",
|
||||
"security"
|
||||
],
|
||||
"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.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.telegram.network.dnsResultOrder",
|
||||
"kind": "channel",
|
||||
|
|
|
|||
|
|
@ -914,6 +914,24 @@ channels:
|
|||
autoSelectFamily: false
|
||||
```
|
||||
|
||||
- If a trusted fake-IP or transparent proxy rewrites `api.telegram.org` to
|
||||
private/internal/special-use addresses during media downloads, you can
|
||||
opt in to the Telegram-only bypass:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
telegram:
|
||||
network:
|
||||
dangerouslyAllowPrivateNetwork: true
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`channels.telegram.network.dangerouslyAllowPrivateNetwork` weakens Telegram
|
||||
media SSRF protections. Use it only for trusted operator-controlled proxy
|
||||
environments such as fake-IP routing. Leave it off for normal public
|
||||
internet Telegram access.
|
||||
</Warning>
|
||||
|
||||
- Environment overrides (temporary):
|
||||
- `OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY=1`
|
||||
- `OPENCLAW_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY=1`
|
||||
|
|
@ -980,6 +998,7 @@ Primary reference:
|
|||
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.
|
||||
- `channels.telegram.network.dnsResultOrder`: override DNS result order (`ipv4first` or `verbatim`). Defaults to `ipv4first` on Node 22+.
|
||||
- `channels.telegram.network.dangerouslyAllowPrivateNetwork`: 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.
|
||||
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
|
||||
- `channels.telegram.webhookUrl`: enable webhook mode (requires `channels.telegram.webhookSecret`).
|
||||
- `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set).
|
||||
|
|
@ -1006,7 +1025,7 @@ Telegram-specific high-signal fields:
|
|||
- threading/replies: `replyToMode`
|
||||
- streaming: `streaming` (preview), `blockStreaming`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
|
||||
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy`
|
||||
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy`
|
||||
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
|
||||
- actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker`
|
||||
- reactions: `reactionNotifications`, `reactionLevel`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
|
|
|
|||
|
|
@ -12400,6 +12400,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
type: "string",
|
||||
enum: ["ipv4first", "verbatim"],
|
||||
},
|
||||
dangerouslyAllowPrivateNetwork: {
|
||||
description:
|
||||
"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.",
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
|
|
@ -13396,6 +13401,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
type: "string",
|
||||
enum: ["ipv4first", "verbatim"],
|
||||
},
|
||||
dangerouslyAllowPrivateNetwork: {
|
||||
description:
|
||||
"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.",
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
|
|
@ -13691,6 +13701,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
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).",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ export type TelegramNetworkConfig = {
|
|||
* Default: "ipv4first" on Node 22+ to avoid common fetch failures.
|
||||
*/
|
||||
dnsResultOrder?: "ipv4first" | "verbatim";
|
||||
/**
|
||||
* Dangerous opt-in for Telegram media downloads in trusted fake-IP or
|
||||
* transparent-proxy environments that resolve api.telegram.org to
|
||||
* private/internal/special-use addresses.
|
||||
*/
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
};
|
||||
|
||||
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
|
||||
|
|
|
|||
|
|
@ -245,6 +245,12 @@ export const TelegramAccountSchemaBase = z
|
|||
.object({
|
||||
autoSelectFamily: z.boolean().optional(),
|
||||
dnsResultOrder: z.enum(["ipv4first", "verbatim"]).optional(),
|
||||
dangerouslyAllowPrivateNetwork: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.",
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue