Telegram/documents: sanitize binary payloads to prevent prompt input inflation (#66877)

Merged via squash.

Prepared head SHA: 09a87c184f
Co-authored-by: martinfrancois <14319020+martinfrancois@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
François Martin
2026-04-15 02:53:00 +02:00
committed by GitHub
parent 0c4e0d7030
commit 734bb9c2e7
8 changed files with 278 additions and 18 deletions

View File

@@ -221,14 +221,29 @@ export async function buildTelegramInboundContextPayload(params: {
: ""
}]\n`
: "";
const buildReplySupplementalLines = (params: { body?: string }) => {
const lines: string[] = [];
const forwardAnnotation = replyForwardAnnotation.trimEnd();
if (forwardAnnotation) {
lines.push(forwardAnnotation);
}
if (params.body) {
lines.push(params.body);
}
return lines.length > 0 ? `\n${lines.join("\n")}` : "";
};
const replySuffix = visibleReplyTarget
? visibleReplyTarget.kind === "quote"
? `\n\n[Quoting ${visibleReplyTarget.sender}${
visibleReplyTarget.id ? ` id:${visibleReplyTarget.id}` : ""
}]\n${replyForwardAnnotation}"${visibleReplyTarget.body}"\n[/Quoting]`
}]${buildReplySupplementalLines({
body: visibleReplyTarget.body ? `"${visibleReplyTarget.body}"` : undefined,
})}\n[/Quoting]`
: `\n\n[Replying to ${visibleReplyTarget.sender}${
visibleReplyTarget.id ? ` id:${visibleReplyTarget.id}` : ""
}]\n${replyForwardAnnotation}${visibleReplyTarget.body}\n[/Replying]`
}]${buildReplySupplementalLines({
body: visibleReplyTarget.body,
})}\n[/Replying]`
: "";
const forwardPrefix = visibleForwardOrigin
? `[Forwarded from ${visibleForwardOrigin.from}${
@@ -427,7 +442,7 @@ export async function buildTelegramInboundContextPayload(params: {
});
if (visibleReplyTarget && shouldLogVerbose()) {
const preview = visibleReplyTarget.body.replace(/\s+/g, " ").slice(0, 120);
const preview = (visibleReplyTarget.body ?? "").replace(/\s+/g, " ").slice(0, 120);
logVerbose(
`telegram reply-context: replyToId=${visibleReplyTarget.id} replyToSender=${visibleReplyTarget.sender} replyToBody="${preview}"`,
);

View File

@@ -1280,6 +1280,39 @@ describe("createTelegramBot", () => {
expect(payload.ReplyToSender).toBe("Ada");
});
it("keeps reply linkage while omitting filtered binary reply captions", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "Sure, see below",
date: 1736380800,
reply_to_message: {
message_id: 9001,
caption: "PK\x00\x03\x04binary",
from: { first_name: "Ada" },
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toContain("[Replying to Ada id:9001]");
expect(payload.Body).not.toContain("PK");
expect(payload.Body).not.toContain("unsafe reply text omitted");
expect(payload.ReplyToBody).toBeUndefined();
expect(payload.ReplyToId).toBe("9001");
expect(payload.ReplyToSender).toBe("Ada");
});
it("includes replied image media in inbound context for text replies", async () => {
onSpy.mockClear();
replySpy.mockClear();

View File

@@ -102,14 +102,18 @@ export function isBinaryContent(text: string): boolean {
return false;
}
export function resolveTelegramTextContent(text: unknown, caption?: unknown): string {
const raw = typeof text === "string" ? text : typeof caption === "string" ? caption : "";
return isBinaryContent(raw) ? "" : raw;
}
export function getTelegramTextParts(
msg: Pick<Message, "text" | "caption" | "entities" | "caption_entities">,
): {
text: string;
entities: TelegramTextEntity[];
} {
const raw = msg.text ?? msg.caption ?? "";
const text = isBinaryContent(raw) ? "" : raw;
const text = resolveTelegramTextContent(msg.text, msg.caption);
const entities = text ? (msg.entities ?? msg.caption_entities ?? []) : [];
return { text, entities };
}

View File

@@ -325,7 +325,6 @@ describe("describeReplyTarget", () => {
from: { id: 42, first_name: "Alice", is_bot: false },
},
} as any);
// Should not throw when reply text is malformed; return null instead.
expect(result).toBeNull();
});
@@ -347,6 +346,65 @@ describe("describeReplyTarget", () => {
expect(result?.kind).toBe("reply");
});
it("drops binary reply captions with no safe fallback", () => {
const result = describeReplyTarget({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private" },
reply_to_message: {
message_id: 1,
date: 900,
chat: { id: 1, type: "private" },
caption: "PK\x00\x03\x04binary",
from: { id: 42, first_name: "Alice", is_bot: false },
},
} as any);
expect(result?.id).toBe("1");
expect(result?.sender).toBe("Alice");
expect(result?.body).toBeUndefined();
});
it("falls back to reply text when quote text is binary", () => {
const result = describeReplyTarget({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private" },
quote: {
text: "\x00\x01\x02binary quote",
},
reply_to_message: {
message_id: 1,
date: 900,
chat: { id: 1, type: "private" },
text: "Original message",
from: { id: 42, first_name: "Alice", is_bot: false },
},
} as any);
expect(result?.body).toBe("Original message");
expect(result?.kind).toBe("reply");
});
it("falls back to external reply text when external quote text is binary", () => {
const result = describeReplyTarget({
message_id: 5,
date: 1300,
chat: { id: 1, type: "private" },
text: "Comment on forwarded message",
external_reply: {
message_id: 4,
date: 1200,
chat: { id: 1, type: "private" },
text: "Forwarded from elsewhere",
quote: {
text: "PK\x00\x03\x04binary quote",
},
from: { id: 123, first_name: "Eve", is_bot: false },
},
} as any);
expect(result?.body).toBe("Forwarded from elsewhere");
expect(result?.kind).toBe("reply");
});
it("extracts forwarded context from reply_to_message (issue #9619)", () => {
// When user forwards a message with a comment, the comment message has
// reply_to_message pointing to the forwarded message. We should extract

View File

@@ -20,6 +20,7 @@ import {
hasBotMention,
isBinaryContent,
normalizeForwardedContext,
resolveTelegramTextContent,
resolveTelegramMediaPlaceholder,
type TelegramForwardedContext,
} from "./body-helpers.js";
@@ -40,6 +41,10 @@ export {
const TELEGRAM_GENERAL_TOPIC_ID = 1;
function hadUnsafeTelegramText(raw: unknown, sanitized: string): boolean {
return typeof raw === "string" && raw.trim().length > 0 && sanitized.trim().length === 0;
}
export type TelegramThreadSpec = {
id?: number;
scope: "dm" | "forum" | "none";
@@ -330,7 +335,7 @@ export type TelegramReplyTarget = {
sender: string;
senderId?: string;
senderUsername?: string;
body: string;
body?: string;
kind: "reply" | "quote";
/** Forward context if the reply target was itself a forwarded message (issue #9619). */
forwardedFrom?: TelegramForwardedContext;
@@ -339,28 +344,30 @@ export type TelegramReplyTarget = {
export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
const reply = msg.reply_to_message;
const externalReply = (msg as Message & { external_reply?: Message }).external_reply;
const quoteText =
const rawQuoteText =
msg.quote?.text ??
(externalReply as (Message & { quote?: { text?: string } }) | undefined)?.quote?.text;
const quoteText = resolveTelegramTextContent(rawQuoteText);
let body = "";
let kind: TelegramReplyTarget["kind"] = "reply";
const filteredQuoteText = hadUnsafeTelegramText(rawQuoteText, quoteText);
if (typeof quoteText === "string") {
body = quoteText.trim();
if (body) {
kind = "quote";
}
body = quoteText.trim();
if (body) {
kind = "quote";
}
const replyLike = reply ?? externalReply;
let filteredReplyText = false;
if (!body && replyLike) {
const replyBody = (
const rawReplyText =
typeof replyLike.text === "string"
? replyLike.text
: typeof replyLike.caption === "string"
? replyLike.caption
: ""
).trim();
: undefined;
const replyBody = resolveTelegramTextContent(rawReplyText).trim();
filteredReplyText = hadUnsafeTelegramText(rawReplyText, replyBody);
body = replyBody;
if (!body) {
body = resolveTelegramMediaPlaceholder(replyLike) ?? "";
@@ -372,7 +379,10 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
}
}
}
if (!body) {
if (!body && !replyLike) {
return null;
}
if (!body && !filteredQuoteText && !filteredReplyText) {
return null;
}
const sender = replyLike ? buildSenderName(replyLike) : undefined;
@@ -386,7 +396,7 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
sender: senderLabel,
senderId: replyLike?.from?.id != null ? String(replyLike.from.id) : undefined,
senderUsername: replyLike?.from?.username ?? undefined,
body,
body: body || undefined,
kind,
forwardedFrom,
};