From 62f97960640a9273898120ae2c69f5dfbced219a Mon Sep 17 00:00:00 2001 From: conconfianzatartamudez-beep Date: Mon, 3 Aug 2026 22:29:27 -0500 Subject: [PATCH] fix(telegram): stop storing the bot token in the database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The URL of any file a customer sends over Telegram carries the bot token inside it — that is how the Bot API works: https://api.telegram.org/file/bot/photos/file_12.jpg That URL was stored verbatim in the message's [IMAGE_URL: ...] marker, so the token ends up in D1, shows up in the dashboard thread, and rides along in every conversation export. With that token anyone can read every message the bot receives, reply as the bot, and repoint its webhook — no access to Cloudflare or to the dashboard required. For anyone reselling bots, the token that leaks belongs to their client, not to them. Rule: mask on the way in, restore only at the moment the file is fetched. - New src/telegramFiles.ts: maskTelegramToken() / unmaskTelegramToken(). - agent.ts masks before writing the marker that gets persisted. - agent.ts restores it right before building the multimodal message, the only place the real URL is needed. Non-Telegram image URLs pass through untouched. Not in this PR: cleaning rows already stored with the token. That is a migration and belongs on its own. Anyone who has had Telegram connected for a while should also rotate the token with BotFather. 5 new tests. 442 pass, typecheck clean. --- src/agent.ts | 13 +++++++++++-- src/telegramFiles.ts | 39 +++++++++++++++++++++++++++++++++++++ test/telegramFiles.test.ts | 40 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 src/telegramFiles.ts create mode 100644 test/telegramFiles.test.ts diff --git a/src/agent.ts b/src/agent.ts index dc62f5b2..59cd1d6f 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -18,6 +18,7 @@ import { CustomerFactsRepo } from "./db/facts"; import { createModel } from "./llm/provider"; import { costOfUsage } from "./pricing"; import type { ChannelId } from "./channels/shared"; +import { maskTelegramToken, unmaskTelegramToken } from "./telegramFiles"; export interface SupportAgentState { conversationId: string | null; @@ -141,7 +142,10 @@ export class SupportAgent extends Agent { } else { processedText = (processedText || "(imagen sin caption)") + - `\n[IMAGE_URL: ${payload.imageUrl}]`; + // MASKED: a Telegram file URL carries the bot token inside, and this + // marker gets persisted in D1 (and shown in the dashboard, and + // included in exports). See src/telegramFiles.ts. + `\n[IMAGE_URL: ${maskTelegramToken(payload.imageUrl)}]`; } } @@ -227,7 +231,12 @@ export class SupportAgent extends Agent { if (lastUserMsg) { const imgMatch = lastUserMsg.content.match(/\[IMAGE_URL: (.+?)\]/); if (imgMatch && isPro(this.env)) { - const imageUrl = imgMatch[1]; + // The token was masked before storing; it goes back in only here, to + // fetch the file. It never leaves this call. + const imageUrl = unmaskTelegramToken( + imgMatch[1], + this.env.TELEGRAM_BOT_TOKEN, + ); const cleanText = lastUserMsg.content .replace(/\n?\[IMAGE_URL: .+?\]/, "") .trim(); diff --git a/src/telegramFiles.ts b/src/telegramFiles.ts new file mode 100644 index 00000000..4f389942 --- /dev/null +++ b/src/telegramFiles.ts @@ -0,0 +1,39 @@ +/** + * The Telegram bot token travels INSIDE the URL of every file a customer sends: + * + * https://api.telegram.org/file/bot/photos/file_12.jpg + * + * That URL used to be stored verbatim in the `[IMAGE_URL: …]` marker of the + * message, which means the bot token ends up in D1 — and in the dashboard + * thread, and in every conversation export. + * + * With that token anyone can read every message the bot receives, reply as the + * bot, and repoint its webhook. No access to Cloudflare or to the dashboard + * needed: the text of a stored message is enough. For anyone reselling bots, + * the token that leaks is not theirs — it belongs to the business they set it + * up for. + * + * Rule: the token is MASKED on the way in and put back only at the moment the + * file is actually fetched. + */ + +/** Placeholder that replaces the token in anything we persist. */ +export const TELEGRAM_TOKEN_MASK = "bot__TOKEN__"; + +/** `https://api.telegram.org/file/bot/x.jpg` → `…/bot__TOKEN__/x.jpg` */ +const RE_FILE_URL = /^(https:\/\/api\.telegram\.org\/file\/)bot[^/]+(\/.*)$/; + +/** Takes the token out of a Telegram file URL, so it can be stored safely. */ +export function maskTelegramToken(url: string): string { + return url.replace(RE_FILE_URL, `$1${TELEGRAM_TOKEN_MASK}$2`); +} + +/** + * Puts the token back, right before fetching the file. A URL that was never + * masked (or that is not from Telegram) comes back untouched, so this is safe + * to call on any image URL. + */ +export function unmaskTelegramToken(url: string, token: string | undefined): string { + if (!token || !url.includes(TELEGRAM_TOKEN_MASK)) return url; + return url.replace(TELEGRAM_TOKEN_MASK, `bot${token}`); +} diff --git a/test/telegramFiles.test.ts b/test/telegramFiles.test.ts new file mode 100644 index 00000000..b5b19827 --- /dev/null +++ b/test/telegramFiles.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { + maskTelegramToken, + unmaskTelegramToken, + TELEGRAM_TOKEN_MASK, +} from "../src/telegramFiles"; + +// A token shaped like a real one, but obviously fake. +const TOKEN = "8123456789:AAHfakefakefakefakefakefakefake"; +const REAL = `https://api.telegram.org/file/bot${TOKEN}/photos/file_12.jpg`; + +describe("telegram file URLs — the bot token must not be stored", () => { + it("takes the token out of the URL", () => { + const masked = maskTelegramToken(REAL); + expect(masked).not.toContain(TOKEN); + expect(masked).toBe( + `https://api.telegram.org/file/${TELEGRAM_TOKEN_MASK}/photos/file_12.jpg`, + ); + }); + + it("puts it back, byte for byte, to fetch the file", () => { + expect(unmaskTelegramToken(maskTelegramToken(REAL), TOKEN)).toBe(REAL); + }); + + it("leaves other image URLs alone", () => { + const otra = "https://example.com/una-foto.png"; + expect(maskTelegramToken(otra)).toBe(otra); + expect(unmaskTelegramToken(otra, TOKEN)).toBe(otra); + }); + + it("does not blow up when there is no token configured", () => { + const masked = maskTelegramToken(REAL); + expect(unmaskTelegramToken(masked, undefined)).toBe(masked); + }); + + it("keeps the file path intact, including nested folders", () => { + const anidada = `https://api.telegram.org/file/bot${TOKEN}/voice/a/b/c.ogg`; + expect(unmaskTelegramToken(maskTelegramToken(anidada), TOKEN)).toBe(anidada); + }); +});