Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/tough-panthers-argue.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions apps/docs/content/adapters/official/discord.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions apps/docs/content/adapters/official/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions apps/docs/content/adapters/official/telegram.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/docs/content/adapters/official/whatsapp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions packages/adapter-discord/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 26 additions & 5 deletions packages/adapter-discord/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Buffer> {
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", () => {
Expand Down
16 changes: 5 additions & 11 deletions packages/adapter-discord/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { AsyncLocalStorage } from "node:async_hooks";
import {
downloadAttachment,
extractCard,
extractFiles,
NetworkError,
Expand Down Expand Up @@ -2071,25 +2072,18 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
}

protected async downloadAttachment(url: string): Promise<Buffer> {
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());
}

/**
Expand Down
57 changes: 56 additions & 1 deletion packages/adapter-shared/src/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
) => Promise<IncomingMessage>
>()
.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("<html>sign in</html>", 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, {
Expand Down Expand Up @@ -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" })
);
});

Expand Down
54 changes: 37 additions & 17 deletions packages/adapter-shared/src/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
) => Promise<IncomingMessage>;

export interface DownloadAttachmentOptions {
/** Adapter name used to tag thrown errors, e.g. "teams". */
adapter: string;
/** Extra request headers merged over the defaults. */
headers?: Record<string, string>;
/**
* 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<string, string>
| ((url: URL) => Record<string, string> | undefined);
/**
* Optional host allowlist. When set, every fetched URL (including
* redirect targets) must be one of these hosts or a subdomain of one;
Expand All @@ -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;
/**
Expand Down Expand Up @@ -179,22 +193,15 @@ export function validateAttachmentUrl(
return url;
}

function createTransport(
adapter: string,
headers?: Record<string, string>
): 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,
},
Expand Down Expand Up @@ -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;
Expand All @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions packages/adapter-slack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading