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); + }); +});