diff --git a/.changeset/tough-panthers-argue.md b/.changeset/tough-panthers-argue.md new file mode 100644 index 00000000..1e5c6892 --- /dev/null +++ b/.changeset/tough-panthers-argue.md @@ -0,0 +1,13 @@ +--- +"@chat-adapter/shared": minor +"@chat-adapter/slack": patch +"@chat-adapter/discord": patch +"@chat-adapter/telegram": patch +"@chat-adapter/whatsapp": patch +--- + +guard attachment downloads across the remaining adapters + +Slack, Discord, and WhatsApp attachment downloads now go through the shared guarded downloader: private and internal addresses are refused (as URL literals, through DNS resolution, and after redirects), responses are capped at 25 MB, and downloads time out after 30 seconds. Slack sends the bot token only on hops to trusted Slack origins, and WhatsApp keeps its access token on Meta's media hosts and the configured Graph origin. Telegram enforces the same size cap and timeout with the Web Fetch API so downloads keep working in runtimes like Cloudflare Workers. + +`downloadAttachment` in `@chat-adapter/shared` now resolves `headers` per hop (pass a function to control what each redirect target receives), forwards the resolved headers to custom transports, and accepts an `onResponse` hook to reject unexpected final responses before the body is read. diff --git a/apps/docs/content/adapters/official/discord.mdx b/apps/docs/content/adapters/official/discord.mdx index 3aebabbf..bb1a08c7 100644 --- a/apps/docs/content/adapters/official/discord.mdx +++ b/apps/docs/content/adapters/official/discord.mdx @@ -209,6 +209,10 @@ Call `discord.setThreadTitle(thread.id, title)` to rename an existing Discord th ## Advanced +### Inbound attachments + +Incoming attachments expose a lazy `fetchData()` that downloads from Discord's CDN anonymously. Downloads refuse private and internal addresses (including after redirects), are limited to 25 MB, and time out after 30 seconds. + ### HTTP Interactions vs Gateway Discord has two ways to receive events: diff --git a/apps/docs/content/adapters/official/slack.mdx b/apps/docs/content/adapters/official/slack.mdx index f732a791..9d9e5649 100644 --- a/apps/docs/content/adapters/official/slack.mdx +++ b/apps/docs/content/adapters/official/slack.mdx @@ -612,6 +612,10 @@ The package still installs the full Slack adapter dependencies. The subpaths kee ## Advanced +### Inbound attachments + +Incoming file attachments expose a lazy `fetchData()`. Downloads go through a guarded fetcher that refuses private and internal addresses (including after redirects), limits responses to 25 MB, and times out after 30 seconds. The bot token is sent only to trusted Slack origins and never follows a redirect to another host. Override `createFileTransport()` in a subclass to route downloads through a proxy. + ### Agents Everything for building an AI agent on Slack: the Agent messaging experience (`agent_view`), the Assistants API (suggested prompts, status, titles), native streaming, and feedback buttons. diff --git a/apps/docs/content/adapters/official/telegram.mdx b/apps/docs/content/adapters/official/telegram.mdx index 198e01fb..9195e164 100644 --- a/apps/docs/content/adapters/official/telegram.mdx +++ b/apps/docs/content/adapters/official/telegram.mdx @@ -199,6 +199,10 @@ Create a bot via [BotFather](https://t.me/BotFather): ## Advanced +### Inbound attachments + +Incoming file attachments expose a lazy `fetchData()` served from the configured Bot API host. Downloads are limited to 25 MB and time out after 30 seconds. They use the Web Fetch API, so file downloads keep working in runtimes like Cloudflare Workers. + ### Polling for local development ```typescript title="lib/bot.ts" lineNumbers diff --git a/apps/docs/content/adapters/official/whatsapp.mdx b/apps/docs/content/adapters/official/whatsapp.mdx index 0ee31025..7f22e9ac 100644 --- a/apps/docs/content/adapters/official/whatsapp.mdx +++ b/apps/docs/content/adapters/official/whatsapp.mdx @@ -162,6 +162,10 @@ From your Meta app dashboard, copy: ## Advanced +### Inbound attachments + +Incoming media attachments expose a lazy `fetchData()`. Media is downloaded only from Meta's `fbcdn.net` and `fbsbx.com` hosts or the configured Graph origin. Downloads refuse private and internal addresses, are limited to 25 MB, and time out after 30 seconds, and the access token never follows a redirect off those hosts. Pass a custom transport to `downloadMedia()` to route downloads through a proxy. + ### Webhook flow WhatsApp uses two webhook mechanisms: diff --git a/packages/adapter-discord/README.md b/packages/adapter-discord/README.md index 157727c8..335e5a6e 100644 --- a/packages/adapter-discord/README.md +++ b/packages/adapter-discord/README.md @@ -215,6 +215,10 @@ Discord caps a Components v2 message at 40 total components and 4000 characters across all text. When a card exceeds either limit the adapter throws a `ValidationError` rather than letting Discord reject the request. +## Inbound attachments + +Incoming attachments expose a lazy `fetchData()` that downloads from Discord's CDN anonymously. Downloads refuse private and internal addresses (including after redirects), are limited to 25 MB, and time out after 30 seconds. + ## Configuration All options are auto-detected from environment variables when not provided. diff --git a/packages/adapter-discord/src/index.test.ts b/packages/adapter-discord/src/index.test.ts index fc0df1eb..41bfd429 100644 --- a/packages/adapter-discord/src/index.test.ts +++ b/packages/adapter-discord/src/index.test.ts @@ -1438,15 +1438,36 @@ describe("rehydrateAttachment", () => { it("rebuilds fetchData to download the attachment from its CDN url", async () => { const url = "https://cdn.discordapp.com/attachments/1/2/photo.png?ex=abc&is=def&hm=123"; - const fetch = vi - .spyOn(globalThis, "fetch") - .mockResolvedValue(new Response("photo", { status: 200 })); + const transfer = vi.fn(async () => Buffer.from("photo")); + class Adapter extends DiscordAdapter { + protected override downloadAttachment(target: string): Promise { + return transfer(target); + } + } + const custom = new Adapter({ + botToken: "test-token", + publicKey: testPublicKey, + applicationId: "test-app-id", + logger: mockLogger, + }); - const attachment = adapter.rehydrateAttachment({ type: "image", url }); + const attachment = custom.rehydrateAttachment({ type: "image", url }); const data = await attachment.fetchData?.(); expect(data?.toString()).toBe("photo"); - expect(fetch).toHaveBeenCalledWith(url); + expect(transfer).toHaveBeenCalledWith(url); + }); + + it("rejects internal attachment urls before the network", async () => { + const url = "https://169.254.169.254/latest/meta-data"; + const fetch = vi.spyOn(globalThis, "fetch"); + + const attachment = adapter.rehydrateAttachment({ type: "image", url }); + + await expect(attachment.fetchData?.()).rejects.toThrow( + "Refusing to fetch an internal attachment URL" + ); + expect(fetch).not.toHaveBeenCalled(); }); it("returns the attachment unchanged when it has no url", () => { diff --git a/packages/adapter-discord/src/index.ts b/packages/adapter-discord/src/index.ts index b60abba2..b9b842fb 100644 --- a/packages/adapter-discord/src/index.ts +++ b/packages/adapter-discord/src/index.ts @@ -8,6 +8,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { + downloadAttachment, extractCard, extractFiles, NetworkError, @@ -2071,25 +2072,18 @@ export class DiscordAdapter implements Adapter { } protected async downloadAttachment(url: string): Promise { - let response: Response; try { - response = await fetch(url); + return await downloadAttachment(url, { adapter: "discord" }); } catch (error) { + if (error instanceof NetworkError) { + throw error; + } throw new NetworkError( "discord", "Failed to download Discord attachment", error instanceof Error ? error : undefined ); } - - if (!response.ok) { - throw new NetworkError( - "discord", - `Failed to download Discord attachment: ${response.status}` - ); - } - - return Buffer.from(await response.arrayBuffer()); } /** diff --git a/packages/adapter-shared/src/download.test.ts b/packages/adapter-shared/src/download.test.ts index 56fcda92..34eff543 100644 --- a/packages/adapter-shared/src/download.test.ts +++ b/packages/adapter-shared/src/download.test.ts @@ -183,6 +183,60 @@ describe("guarded attachment downloads", () => { ).resolves.toEqual(Buffer.from("media")); }); + it("resolves headers per hop and drops credentials on redirects", async () => { + const transport = vi + .fn< + ( + url: URL, + signal: AbortSignal, + headers?: Record + ) => Promise + >() + .mockResolvedValueOnce( + response("", 302, { location: "https://cdn.example.net/file" }) + ) + .mockResolvedValueOnce(response("file contents")); + + await expect( + downloadAttachment("https://files.example.com/file", { + adapter: "test", + headers: (url) => + url.hostname === "files.example.com" + ? { authorization: "Bearer secret" } + : undefined, + transport, + }) + ).resolves.toEqual(Buffer.from("file contents")); + + const [firstHeaders, secondHeaders] = transport.mock.calls.map( + (call) => call[2] + ); + expect(firstHeaders).toMatchObject({ + authorization: "Bearer secret", + "user-agent": "Vercel.ChatSDK", + }); + expect(secondHeaders).not.toHaveProperty("authorization"); + expect(secondHeaders).toMatchObject({ "user-agent": "Vercel.ChatSDK" }); + }); + + it("rejects responses that fail the onResponse check", async () => { + const transport = vi.fn(async () => + response("sign in", 200, { "content-type": "text/html" }) + ); + + await expect( + downloadAttachment("https://files.example.com/file", { + adapter: "test", + onResponse: (message) => { + if (message.headers["content-type"]?.includes("text/html")) { + throw new NetworkError("test", "Unexpected HTML response"); + } + }, + transport, + }) + ).rejects.toThrow("Unexpected HTML response"); + }); + it("rejects redirects to internal addresses", async () => { const transport = vi.fn(async () => response("", 302, { @@ -215,7 +269,8 @@ describe("guarded attachment downloads", () => { ).resolves.toEqual(Buffer.from("file contents")); expect(transport).toHaveBeenLastCalledWith( new URL("https://cdn.example.net/file"), - expect.any(AbortSignal) + expect.any(AbortSignal), + expect.objectContaining({ "user-agent": "Vercel.ChatSDK" }) ); }); diff --git a/packages/adapter-shared/src/download.ts b/packages/adapter-shared/src/download.ts index e74d65bd..eee4a781 100644 --- a/packages/adapter-shared/src/download.ts +++ b/packages/adapter-shared/src/download.ts @@ -53,19 +53,27 @@ type Resolver = ( /** * Issues one request and resolves with the raw response. Downloads pass an * AbortSignal carrying the overall deadline; honor it so timeouts propagate. - * Supply your own transport to route downloads through a proxy or custom - * egress. + * The resolved request headers for the hop are passed along. Supply your own + * transport to route downloads through a proxy or custom egress. */ export type AttachmentTransport = ( url: URL, - signal: AbortSignal + signal: AbortSignal, + headers?: Record ) => Promise; export interface DownloadAttachmentOptions { /** Adapter name used to tag thrown errors, e.g. "teams". */ adapter: string; - /** Extra request headers merged over the defaults. */ - headers?: Record; + /** + * Extra request headers merged over the defaults, sent on every hop + * including redirect targets. Pass a function to decide per hop; when + * sending credentials, use the function form (or a hosts allowlist) so a + * redirect cannot carry them to an untrusted host. + */ + headers?: + | Record + | ((url: URL) => Record | undefined); /** * Optional host allowlist. When set, every fetched URL (including * redirect targets) must be one of these hosts or a subdomain of one; @@ -74,6 +82,12 @@ export interface DownloadAttachmentOptions { hosts?: readonly string[]; /** Maximum decoded body size in bytes. Defaults to 25 MB. */ limit?: number; + /** + * Called with the final response before its body is read; throw to reject + * the download (e.g. on an unexpected content type). Redirect and error + * statuses never reach it. + */ + onResponse?: (response: IncomingMessage) => void; /** Maximum redirects to follow. Defaults to 5. */ redirects?: number; /** @@ -179,22 +193,15 @@ export function validateAttachmentUrl( return url; } -function createTransport( - adapter: string, - headers?: Record -): AttachmentTransport { +function createTransport(adapter: string): AttachmentTransport { const lookup = createResolver(adapter); - return (url, signal) => + return (url, signal, headers) => new Promise((fulfill, reject) => { const request = secure( url, { agent: false, - headers: { - "accept-encoding": "gzip, deflate, br", - "user-agent": "Vercel.ChatSDK", - ...headers, - }, + headers, lookup, signal, }, @@ -276,16 +283,21 @@ export async function downloadAttachment( headers, hosts, limit = LIMIT, + onResponse, redirects = REDIRECTS, timeoutMs = TIMEOUT, transport, } = options; - const send = transport ?? createTransport(adapter, headers); + const send = transport ?? createTransport(adapter); const signal = AbortSignal.timeout(timeoutMs); let url = validateAttachmentUrl(value, adapter, hosts); try { for (let hop = 0; hop <= redirects; hop += 1) { - const response = await send(url, signal); + const response = await send(url, signal, { + "accept-encoding": "gzip, deflate, br", + "user-agent": "Vercel.ChatSDK", + ...(typeof headers === "function" ? headers(url) : headers), + }); const status = response.statusCode ?? 0; if (STATUSES.has(status)) { const location = response.headers.location; @@ -309,6 +321,14 @@ export async function downloadAttachment( `Failed to fetch file: ${status} ${response.statusMessage ?? ""}`.trim() ); } + if (onResponse) { + try { + onResponse(response); + } catch (error) { + response.destroy(); + throw error; + } + } return await readAttachmentBody(response, adapter, limit); } throw new NetworkError(adapter, "Too many attachment redirects"); diff --git a/packages/adapter-slack/README.md b/packages/adapter-slack/README.md index f2ee5d46..325b4ce3 100644 --- a/packages/adapter-slack/README.md +++ b/packages/adapter-slack/README.md @@ -356,6 +356,10 @@ After creating the app, go to **Basic Information** → **App Credentials** and 4. Set **Request URL** to `https://your-domain.com/api/webhooks/slack` 5. Add a description and click **Save** +## Inbound attachments + +Incoming file attachments expose a lazy `fetchData()`. Downloads go through a guarded fetcher that refuses private and internal addresses (including after redirects), limits responses to 25 MB, and times out after 30 seconds. The bot token is sent only to trusted Slack origins and never follows a redirect to another host. Override `createFileTransport()` in a subclass to route downloads through a proxy. + ## Configuration All options are auto-detected from environment variables when not provided. You can call `createSlackAdapter()` with no arguments if the env vars are set. diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index ce40decb..c2662d8c 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -3,7 +3,13 @@ */ import { createHmac, randomBytes } from "node:crypto"; -import { AuthenticationError, ValidationError } from "@chat-adapter/shared"; +import type { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; +import { + type AttachmentTransport, + AuthenticationError, + ValidationError, +} from "@chat-adapter/shared"; import { connectWebhookContract, createMockChatInstance, @@ -33,6 +39,23 @@ import { const FILE_ID_PATTERN = /^file-/; +// Captures guarded file downloads at the transport seam; the resolved +// per-hop headers show which token (if any) each hop would send. +class TransportSlackAdapter extends SlackAdapter { + readonly fileTransport = vi.fn( + async (): Promise => + Object.assign(Readable.from([Buffer.from("file-bytes")]), { + headers: { "content-type": "application/octet-stream" }, + statusCode: 200, + statusMessage: "OK", + }) as IncomingMessage + ); + + protected override createFileTransport(): AttachmentTransport { + return this.fileTransport; + } +} + // Mock @slack/socket-mode const mockSocketStart = vi.fn().mockResolvedValue({}); const mockSocketDisconnect = vi.fn().mockResolvedValue(undefined); @@ -1261,7 +1284,7 @@ describe("parseMessage", () => { it("downloads external message files without resolving the bot token", async () => { const token = vi.fn().mockResolvedValue("xoxb-test"); - const adapter = createSlackAdapter({ + const adapter = new TransportSlackAdapter({ botToken: token, signingSecret: "test-secret", logger: mockLogger, @@ -1280,26 +1303,15 @@ describe("parseMessage", () => { }, ], }); - const fetchMock = vi.fn().mockResolvedValue( - new Response(new ArrayBuffer(8), { - status: 200, - headers: { "content-type": "application/octet-stream" }, - }) - ); - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; - try { - await message.attachments?.[0].fetchData?.(); + await message.attachments?.[0].fetchData?.(); - expect(token).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalledWith( - "https://docs.google.com/document/d/external", - { headers: undefined } - ); - } finally { - globalThis.fetch = originalFetch; - } + expect(token).not.toHaveBeenCalled(); + expect(adapter.fileTransport).toHaveBeenCalledWith( + new URL("https://docs.google.com/document/d/external"), + expect.any(AbortSignal), + expect.not.objectContaining({ authorization: expect.anything() }) + ); }); it("handles different file types", () => { @@ -2658,48 +2670,36 @@ describe("installationProvider", () => { const state = createMockState(); const chatInstance = createMockChatInstance({ state }); - const adapter = createSlackAdapter({ + const adapter = new TransportSlackAdapter({ signingSecret: secret, logger: mockLogger, installationProvider: mockProvider, }); await adapter.initialize(chatInstance); - const fetchMock = vi.fn().mockResolvedValue( - new Response(new ArrayBuffer(8), { - status: 200, - headers: { "content-type": "application/octet-stream" }, - }) - ); - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; - - try { - const rehydrated = adapter.rehydrateAttachment({ - type: "image", + const rehydrated = adapter.rehydrateAttachment({ + type: "image", + url: "https://files.slack.com/img.png", + fetchMetadata: { url: "https://files.slack.com/img.png", - fetchMetadata: { - url: "https://files.slack.com/img.png", - teamId: "T_REHYDRATE", - }, - }); + teamId: "T_REHYDRATE", + }, + }); - expect(rehydrated.fetchData).toBeDefined(); - await rehydrated.fetchData?.(); + expect(rehydrated.fetchData).toBeDefined(); + await rehydrated.fetchData?.(); - expect(mockProvider.getInstallation).toHaveBeenCalledWith( - "T_REHYDRATE", - false - ); - expect(fetchMock).toHaveBeenCalledWith( - "https://files.slack.com/img.png", - expect.objectContaining({ - headers: { Authorization: "Bearer xoxb-rehydrate-token" }, - }) - ); - } finally { - globalThis.fetch = originalFetch; - } + expect(mockProvider.getInstallation).toHaveBeenCalledWith( + "T_REHYDRATE", + false + ); + expect(adapter.fileTransport).toHaveBeenCalledWith( + new URL("https://files.slack.com/img.png"), + expect.any(AbortSignal), + expect.objectContaining({ + authorization: "Bearer xoxb-rehydrate-token", + }) + ); }); it("rehydrateAttachment does not send installation tokens off Slack", async () => { @@ -2709,7 +2709,7 @@ describe("installationProvider", () => { botUserId: "U_BOT_REHYDRATE", }), }; - const adapter = createSlackAdapter({ + const adapter = new TransportSlackAdapter({ signingSecret: secret, logger: mockLogger, installationProvider: mockProvider, @@ -2717,35 +2717,24 @@ describe("installationProvider", () => { await adapter.initialize( createMockChatInstance({ state: createMockState() }) ); - const fetchMock = vi.fn().mockResolvedValue( - new Response(new ArrayBuffer(8), { - status: 200, - headers: { "content-type": "application/octet-stream" }, - }) - ); - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; - try { - const rehydrated = adapter.rehydrateAttachment({ - type: "file", + const rehydrated = adapter.rehydrateAttachment({ + type: "file", + url: "https://attacker.example/file.txt", + fetchMetadata: { url: "https://attacker.example/file.txt", - fetchMetadata: { - url: "https://attacker.example/file.txt", - teamId: "T_REHYDRATE", - }, - }); + teamId: "T_REHYDRATE", + }, + }); - await rehydrated.fetchData?.(); + await rehydrated.fetchData?.(); - expect(mockProvider.getInstallation).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalledWith( - "https://attacker.example/file.txt", - { headers: undefined } - ); - } finally { - globalThis.fetch = originalFetch; - } + expect(mockProvider.getInstallation).not.toHaveBeenCalled(); + expect(adapter.fileTransport).toHaveBeenCalledWith( + new URL("https://attacker.example/file.txt"), + expect.any(AbortSignal), + expect.not.objectContaining({ authorization: expect.anything() }) + ); }); it("rehydrateAttachment uses enterprise_id when isEnterpriseInstall is true", async () => { @@ -2758,46 +2747,34 @@ describe("installationProvider", () => { const state = createMockState(); const chatInstance = createMockChatInstance({ state }); - const adapter = createSlackAdapter({ + const adapter = new TransportSlackAdapter({ signingSecret: secret, logger: mockLogger, installationProvider: mockProvider, }); await adapter.initialize(chatInstance); - const fetchMock = vi.fn().mockResolvedValue( - new Response(new ArrayBuffer(8), { - status: 200, - headers: { "content-type": "application/octet-stream" }, - }) - ); - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; - - try { - const rehydrated = adapter.rehydrateAttachment({ - type: "image", + const rehydrated = adapter.rehydrateAttachment({ + type: "image", + url: "https://files.slack.com/img.png", + fetchMetadata: { url: "https://files.slack.com/img.png", - fetchMetadata: { - url: "https://files.slack.com/img.png", - teamId: "T_WORKSPACE", - enterpriseId: "E_ORG", - isEnterpriseInstall: "true", - }, - }); + teamId: "T_WORKSPACE", + enterpriseId: "E_ORG", + isEnterpriseInstall: "true", + }, + }); - await rehydrated.fetchData?.(); + await rehydrated.fetchData?.(); - expect(mockProvider.getInstallation).toHaveBeenCalledWith("E_ORG", true); - expect(fetchMock).toHaveBeenCalledWith( - "https://files.slack.com/img.png", - expect.objectContaining({ - headers: { Authorization: "Bearer xoxb-ent-rehydrate-token" }, - }) - ); - } finally { - globalThis.fetch = originalFetch; - } + expect(mockProvider.getInstallation).toHaveBeenCalledWith("E_ORG", true); + expect(adapter.fileTransport).toHaveBeenCalledWith( + new URL("https://files.slack.com/img.png"), + expect.any(AbortSignal), + expect.objectContaining({ + authorization: "Bearer xoxb-ent-rehydrate-token", + }) + ); }); it("rehydrateAttachment throws when installationProvider returns null", async () => { @@ -4842,15 +4819,8 @@ describe("Attachment.fetchData token resolution", () => { ], }; - function createMockFetchResponse(): Response { - return new Response(new ArrayBuffer(8), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - } - it("snapshots ctx token at attachment creation in multi-workspace mode", async () => { - const adapter = createSlackAdapter({ + const adapter = new TransportSlackAdapter({ signingSecret: "test-signing-secret", logger: mockLogger, }); @@ -4867,23 +4837,15 @@ describe("Attachment.fetchData token resolution", () => { ); expect(attachment).toBeDefined(); - const fetchMock = vi - .fn() - .mockImplementation(() => Promise.resolve(createMockFetchResponse())); - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; - try { - // Call fetchData OUTSIDE the requestContext frame to confirm the - // captured ctxToken is used (we are no longer inside AsyncLocalStorage). - await attachment?.fetchData?.(); - } finally { - globalThis.fetch = originalFetch; - } + // Call fetchData OUTSIDE the requestContext frame to confirm the + // captured ctxToken is used (we are no longer inside AsyncLocalStorage). + await attachment?.fetchData?.(); - expect(fetchMock).toHaveBeenCalledWith( - "https://files.slack.com/file.pdf", + expect(adapter.fileTransport).toHaveBeenCalledWith( + new URL("https://files.slack.com/file.pdf"), + expect.any(AbortSignal), expect.objectContaining({ - headers: { Authorization: "Bearer xoxb-team-snapshot" }, + authorization: "Bearer xoxb-team-snapshot", }) ); }); @@ -4892,7 +4854,7 @@ describe("Attachment.fetchData token resolution", () => { const tokens = ["xoxb-stale", "xoxb-fresh"]; let i = 0; const resolver = vi.fn(() => tokens[i++]); - const adapter = createSlackAdapter({ + const adapter = new TransportSlackAdapter({ botToken: resolver, signingSecret: "test-signing-secret", logger: mockLogger, @@ -4904,31 +4866,24 @@ describe("Attachment.fetchData token resolution", () => { expect(attachment).toBeDefined(); expect(resolver).not.toHaveBeenCalled(); - const fetchMock = vi - .fn() - .mockImplementation(() => Promise.resolve(createMockFetchResponse())); - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; - try { - // First fetch picks up the first resolver value. - await attachment?.fetchData?.(); - expect(fetchMock).toHaveBeenLastCalledWith( - "https://files.slack.com/file.pdf", - expect.objectContaining({ - headers: { Authorization: "Bearer xoxb-stale" }, - }) - ); - // A subsequent fetchData() re-invokes the resolver and picks up rotation. - await attachment?.fetchData?.(); - expect(fetchMock).toHaveBeenLastCalledWith( - "https://files.slack.com/file.pdf", - expect.objectContaining({ - headers: { Authorization: "Bearer xoxb-fresh" }, - }) - ); - } finally { - globalThis.fetch = originalFetch; - } + // First fetch picks up the first resolver value. + await attachment?.fetchData?.(); + expect(adapter.fileTransport).toHaveBeenLastCalledWith( + new URL("https://files.slack.com/file.pdf"), + expect.any(AbortSignal), + expect.objectContaining({ + authorization: "Bearer xoxb-stale", + }) + ); + // A subsequent fetchData() re-invokes the resolver and picks up rotation. + await attachment?.fetchData?.(); + expect(adapter.fileTransport).toHaveBeenLastCalledWith( + new URL("https://files.slack.com/file.pdf"), + expect.any(AbortSignal), + expect.objectContaining({ + authorization: "Bearer xoxb-fresh", + }) + ); expect(resolver).toHaveBeenCalledTimes(2); }); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 7c4e2699..58df1da5 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -2,7 +2,9 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { timingSafeEqual } from "node:crypto"; import { AdapterRateLimitError, + type AttachmentTransport, AuthenticationError, + downloadAttachment, extractCard, extractFiles, NetworkError, @@ -3913,26 +3915,46 @@ export class SlackAdapter implements Adapter { if (isSlackAuthUrl(url, this.slackApiUrl)) { value = typeof token === "function" ? await token() : token; } - const response = await fetch(url, { - headers: value ? { Authorization: `Bearer ${value}` } : undefined, - }); - if (!response.ok) { - throw new NetworkError( - "slack", - `Failed to fetch file: ${response.status} ${response.statusText}` - ); - } - const contentType = response.headers.get("content-type") ?? ""; - if (contentType.includes("text/html")) { + try { + return await downloadAttachment(url, { + adapter: "slack", + // The bot token is sent only on hops to trusted Slack origins, so a + // redirect cannot carry it to another host. + headers: (target) => + value && isSlackAuthUrl(target.href, this.slackApiUrl) + ? { authorization: `Bearer ${value}` } + : undefined, + transport: this.createFileTransport(), + onResponse: (response) => { + const contentType = response.headers["content-type"] ?? ""; + if (contentType.includes("text/html")) { + throw new NetworkError( + "slack", + "Failed to download file from Slack: received HTML login page instead of file data. " + + `Ensure your Slack app has the "files:read" OAuth scope. ` + + `URL: ${url}` + ); + } + }, + }); + } catch (error) { + if (error instanceof NetworkError) { + throw error; + } throw new NetworkError( "slack", - "Failed to download file from Slack: received HTML login page instead of file data. " + - `Ensure your Slack app has the "files:read" OAuth scope. ` + - `URL: ${url}` + "Failed to fetch Slack file", + error instanceof Error ? error : undefined ); } - const arrayBuffer = await response.arrayBuffer(); - return Buffer.from(arrayBuffer); + } + + /** + * Transport used for guarded file downloads. Subclasses can return a + * custom AttachmentTransport, e.g. to route downloads through a proxy. + */ + protected createFileTransport(): AttachmentTransport | undefined { + return undefined; } rehydrateAttachment(attachment: Attachment): Attachment { diff --git a/packages/adapter-telegram/README.md b/packages/adapter-telegram/README.md index 321fb46e..0a108281 100644 --- a/packages/adapter-telegram/README.md +++ b/packages/adapter-telegram/README.md @@ -145,6 +145,10 @@ void bot.initialize(); console.log(telegram.runtimeMode); // "webhook" | "polling" ``` +## Inbound attachments + +Incoming file attachments expose a lazy `fetchData()` served from the configured Bot API host. Downloads are limited to 25 MB and time out after 30 seconds. They use the Web Fetch API, so file downloads keep working in runtimes like Cloudflare Workers. + ## Configuration Most options are auto-detected from environment variables when not provided. `nativeStreaming` and `streamingEditIntervalMs` are config only and have no environment variables. diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 5172f27a..bd8696ab 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -473,7 +473,8 @@ describe("bot token resolver", () => { .mockResolvedValueOnce({ ok: true, arrayBuffer: async () => expected.buffer, - } as Response); + headers: new Headers(), + } as unknown as Response); const adapter = createTelegramAdapter({ botToken: "telegram-token", mode: "webhook", diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index c0393659..a79fddc3 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -82,6 +82,52 @@ import type { } from "./types"; const TELEGRAM_API_BASE = "https://api.telegram.org"; +const TELEGRAM_FILE_LIMIT = 25 * 1024 * 1024; +const TELEGRAM_FILE_TIMEOUT_MS = 30_000; + +// Web-API only (no Node Buffer or streams) so file downloads keep working in +// runtimes like Cloudflare Workers. +async function readTelegramFile( + response: Response, + fileId: string +): Promise { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > TELEGRAM_FILE_LIMIT) { + await response.body?.cancel(); + throw new NetworkError( + "telegram", + `Telegram file ${fileId} exceeds the download limit` + ); + } + const reader = response.body?.getReader(); + if (!reader) { + return await response.arrayBuffer(); + } + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + size += value.length; + if (size > TELEGRAM_FILE_LIMIT) { + await reader.cancel(); + throw new NetworkError( + "telegram", + `Telegram file ${fileId} exceeds the download limit` + ); + } + chunks.push(value); + } + const data = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + data.set(chunk, offset); + offset += chunk.length; + } + return data.buffer; +} const TELEGRAM_SECRET_TOKEN_HEADER = "x-telegram-bot-api-secret-token"; const TELEGRAM_WEBHOOK_VERIFICATION_ERROR = "secretToken is required in webhook mode. Set TELEGRAM_WEBHOOK_SECRET_TOKEN or provide secretToken. To accept unverified webhooks, set allowUnverifiedWebhooks: true or TELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS=true."; @@ -2307,9 +2353,15 @@ export class TelegramAdapter const botToken = this.staticBotToken ?? (await this.resolveBotToken()); const fileUrl = `${this.apiBaseUrl}/file/bot${botToken}/${file.file_path}`; + // Downloads stay on the Web Fetch API so the adapter keeps working in + // runtimes without Node networking (e.g. Cloudflare Workers). The host + // is operator-configured and the path comes from Telegram's getFile, so + // the guard here is the size cap and timeout. let response: Response; try { - response = await fetch(fileUrl); + response = await fetch(fileUrl, { + signal: AbortSignal.timeout(TELEGRAM_FILE_TIMEOUT_MS), + }); } catch (error) { throw new NetworkError( "telegram", @@ -2325,7 +2377,18 @@ export class TelegramAdapter ); } - return response.arrayBuffer(); + try { + return await readTelegramFile(response, fileId); + } catch (error) { + if (error instanceof NetworkError) { + throw error; + } + throw new NetworkError( + "telegram", + `Failed to download Telegram file ${fileId}`, + error instanceof Error ? error : undefined + ); + } } protected async sendDocument( diff --git a/packages/adapter-whatsapp/README.md b/packages/adapter-whatsapp/README.md index e6ef2996..cdd76488 100644 --- a/packages/adapter-whatsapp/README.md +++ b/packages/adapter-whatsapp/README.md @@ -73,6 +73,10 @@ From your Meta app dashboard, copy: For production, generate a permanent **System User Token** instead of the temporary access token. +## Inbound attachments + +Incoming media attachments expose a lazy `fetchData()`. Media is downloaded only from Meta's `fbcdn.net` and `fbsbx.com` hosts or the configured Graph origin. Downloads refuse private and internal addresses, are limited to 25 MB, and time out after 30 seconds, and the access token never follows a redirect off those hosts. Pass a custom transport to `downloadMedia()` to route downloads through a proxy. + ## Configuration All options are auto-detected from environment variables when not provided. You can call `createWhatsAppAdapter()` with no arguments if the env vars are set. diff --git a/packages/adapter-whatsapp/src/index.test.ts b/packages/adapter-whatsapp/src/index.test.ts index 6b057203..a6f938df 100644 --- a/packages/adapter-whatsapp/src/index.test.ts +++ b/packages/adapter-whatsapp/src/index.test.ts @@ -1,4 +1,6 @@ import { createHmac } from "node:crypto"; +import type { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; import { NetworkError } from "@chat-adapter/shared"; import { createMockChatInstance, @@ -30,6 +32,18 @@ import type { WhatsAppWebhookPayload, } from "./types"; +function mediaResponse( + body: string, + status = 200, + headers: IncomingMessage["headers"] = {} +): IncomingMessage { + return Object.assign(Readable.from([Buffer.from(body)]), { + headers, + statusCode: status, + statusMessage: "OK", + }) as IncomingMessage; +} + const NOT_SUPPORTED_PATTERN = /not support/i; const ACCESS_TOKEN_PATTERN = /accessToken/i; const APP_SECRET_PATTERN = /appSecret/i; @@ -533,25 +547,64 @@ describe("downloadMedia", () => { "https://scontent.xx.fbcdn.net/whatsapp/media", ])("downloads media from trusted Meta URL %s", async (url) => { const adapter = createTestAdapter(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ url }), { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ url }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + const transport = vi.fn(async () => mediaResponse("media")); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + try { + const data = await adapter.downloadMedia("media-123", transport); + + expect(data.toString()).toBe("media"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(transport).toHaveBeenCalledWith( + new URL(url), + expect.any(AbortSignal), + expect.objectContaining({ authorization: "Bearer test-token" }) + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it.each([ + "https://sub.graph.example/media/file", + "https://graph.example:8443/media/file", + ])("refuses to send the token to off-policy redirect %s", async (location) => { + const adapter = new WhatsAppAdapter({ + accessToken: "test-token", + apiUrl: "https://graph.example", + appSecret: "test-secret", + phoneNumberId: "123456789", + verifyToken: "test-verify-token", + userName: "test-bot", + logger: createMockLogger(), + }); + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ url: "https://graph.example/media/file" }), + { status: 200, headers: { "content-type": "application/json" }, - }) + } ) - .mockResolvedValueOnce(new Response("media", { status: 200 })); + ); + const transport = vi.fn(async () => mediaResponse("", 302, { location })); const originalFetch = globalThis.fetch; globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; try { - const data = await adapter.downloadMedia("media-123"); - - expect(data.toString()).toBe("media"); - expect(fetchMock.mock.calls[1][1]).toEqual({ - headers: { Authorization: "Bearer test-token" }, - }); + await expect( + adapter.downloadMedia("media-123", transport) + ).rejects.toThrow( + "Refusing to send the access token to an untrusted media URL" + ); + expect(transport).toHaveBeenCalledTimes(1); } finally { globalThis.fetch = originalFetch; } @@ -567,27 +620,27 @@ describe("downloadMedia", () => { userName: "test-bot", logger: createMockLogger(), }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ url: "https://graph.example/media/file" }), - { - status: 200, - headers: { "content-type": "application/json" }, - } - ) + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ url: "https://graph.example/media/file" }), + { + status: 200, + headers: { "content-type": "application/json" }, + } ) - .mockResolvedValueOnce(new Response("media", { status: 200 })); + ); + const transport = vi.fn(async () => mediaResponse("media")); const originalFetch = globalThis.fetch; globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; try { - await adapter.downloadMedia("media-123"); + await adapter.downloadMedia("media-123", transport); - expect(fetchMock.mock.calls[1][1]).toEqual({ - headers: { Authorization: "Bearer test-token" }, - }); + expect(transport).toHaveBeenCalledWith( + new URL("https://graph.example/media/file"), + expect.any(AbortSignal), + expect.objectContaining({ authorization: "Bearer test-token" }) + ); } finally { globalThis.fetch = originalFetch; } diff --git a/packages/adapter-whatsapp/src/index.ts b/packages/adapter-whatsapp/src/index.ts index 3a856466..b57bf9d2 100644 --- a/packages/adapter-whatsapp/src/index.ts +++ b/packages/adapter-whatsapp/src/index.ts @@ -1,7 +1,9 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { AdapterError, + type AttachmentTransport, cardToFallbackText, + downloadAttachment, extractCard, extractFiles, extractPostableAttachments, @@ -1150,7 +1152,10 @@ export class WhatsAppAdapter * * @see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#download-media */ - async downloadMedia(mediaId: string): Promise { + async downloadMedia( + mediaId: string, + transport?: AttachmentTransport + ): Promise { // Step 1: Get the media URL const metaResponse = await fetch(`${this.graphApiUrl}/${mediaId}`, { headers: { Authorization: `Bearer ${this.accessToken}` }, @@ -1178,21 +1183,35 @@ export class WhatsAppAdapter ); } - // Step 2: Download the actual file - const dataResponse = await fetch(mediaInfo.url, { - headers: { Authorization: `Bearer ${this.accessToken}` }, - }); - - if (!dataResponse.ok) { - this.logger.error("Failed to download media", { - status: dataResponse.status, - mediaId, + // Step 2: Download the actual file. Every hop is checked against the + // exact-origin and Meta media host policy before the access token is + // attached, so a redirect cannot carry it to an off-policy host. + try { + return await downloadAttachment(mediaInfo.url, { + adapter: "whatsapp", + headers: (target) => { + if (!isWhatsAppMediaUrl(target.href, this.graphApiUrl)) { + throw new NetworkError( + "whatsapp", + "Refusing to send the access token to an untrusted media URL" + ); + } + return { authorization: `Bearer ${this.accessToken}` }; + }, + hosts: [...WHATSAPP_MEDIA_HOSTS, new URL(this.graphApiUrl).hostname], + transport, }); - throw new Error(`Failed to download media: ${dataResponse.status}`); + } catch (error) { + this.logger.error("Failed to download media", { mediaId }); + if (error instanceof NetworkError) { + throw error; + } + throw new NetworkError( + "whatsapp", + `Failed to download media ${mediaId}`, + error instanceof Error ? error : undefined + ); } - - const arrayBuffer = await dataResponse.arrayBuffer(); - return Buffer.from(arrayBuffer); } /**