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
137 changes: 137 additions & 0 deletions packages/channels/dingtalk/src/media.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import { downloadMedia } from './media.js';

describe('downloadMedia (DingTalk)', () => {
let fetchSpy: MockInstance<typeof fetch>;

beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});

afterEach(() => {
fetchSpy.mockRestore();
});

// First fetch is the DingTalk download-code API that returns a downloadUrl;
// the second fetch is the actual file download that the size cap guards.
function mockApiResponse(
downloadUrl = 'https://dl.dingtalk.example/file',
): Response {
return {
ok: true,
json: vi.fn().mockResolvedValue({ downloadUrl }),
} as unknown as Response;
}

it('downloads a file within the size limit', async () => {
const mockData = new Uint8Array([1, 2, 3, 4]);
const fileResp = {
ok: true,
headers: {
get: (key: string) => {
if (key === 'content-length') return '4';
if (key === 'content-type') return 'image/png';
return null;
},
},
body: {
getReader: () => ({
read: vi
.fn()
.mockResolvedValueOnce({ done: false, value: mockData })
.mockResolvedValueOnce({ done: true, value: undefined }),
cancel: vi.fn(),
}),
},
};

fetchSpy
.mockResolvedValueOnce(mockApiResponse())
.mockResolvedValueOnce(fileResp as unknown as Response);

const result = await downloadMedia('code', 'robot', 'token');

expect(result).not.toBeNull();
expect(result?.buffer).toEqual(Buffer.from(mockData));
expect(result?.mimeType).toBe('image/png');
});

it('rejects a Content-Length exceeding 50MB and releases the connection', async () => {
const largeSize = 60 * 1024 * 1024; // 60 MB
const bodyCancel = vi.fn();
const fileResp = {
ok: true,
headers: {
get: (key: string) =>
key === 'content-length' ? largeSize.toString() : null,
},
body: {
cancel: bodyCancel,
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
cancel: vi.fn(),
}),
},
};

fetchSpy
.mockResolvedValueOnce(mockApiResponse())
.mockResolvedValueOnce(fileResp as unknown as Response);

const result = await downloadMedia('code', 'robot', 'token');

expect(result).toBeNull();
expect(bodyCancel).toHaveBeenCalled();
});

it('rejects a stream exceeding 50MB with no Content-Length header', async () => {
const chunkSize = 10 * 1024 * 1024; // 10 MB per chunk
const mockData = new Uint8Array(chunkSize);
const cancelMock = vi.fn();
const fileResp = {
ok: true,
headers: {
get: () => null, // no content-length → must be caught while streaming
},
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: false, value: mockData }), // never ends
cancel: cancelMock,
}),
},
};

fetchSpy
.mockResolvedValueOnce(mockApiResponse())
.mockResolvedValueOnce(fileResp as unknown as Response);

const result = await downloadMedia('code', 'robot', 'token');

expect(result).toBeNull();
expect(cancelMock).toHaveBeenCalled();
});

it('returns null when the file response body is null', async () => {
const fileResp = {
ok: true,
headers: { get: () => null },
body: null,
};

fetchSpy
.mockResolvedValueOnce(mockApiResponse())
.mockResolvedValueOnce(fileResp as unknown as Response);

const result = await downloadMedia('code', 'robot', 'token');

expect(result).toBeNull();
});
});
46 changes: 43 additions & 3 deletions packages/channels/dingtalk/src/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,58 @@ export async function downloadMedia(
return null;
}

// Step 2: Download the actual file
const fileResp = await fetch(downloadUrl);
// Step 2: Download the actual file. Bound the request so a stalled CDN
// connection can't hang the streaming loop below indefinitely (matches
// the Feishu adapter).
const fileResp = await fetch(downloadUrl, {
signal: AbortSignal.timeout(30_000),
});
if (!fileResp.ok) {
process.stderr.write(
`[DingTalk] downloadMedia file fetch failed: HTTP ${fileResp.status}\n`,
);
return null;
}

const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
const contentLength = fileResp.headers.get('content-length');
if (contentLength && parseInt(contentLength, 10) > MAX_DOWNLOAD_BYTES) {
Comment on lines +78 to +80

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The size-capped streaming download logic (~30 lines) is duplicated line-for-line between this file and packages/channels/feishu/src/media.ts:61-95. Both also independently declare identical MediaFile interfaces.

Concrete cost: a bug fix to the size enforcement (e.g., the missing body cancel above) must be independently applied to both adapters. A fix applied to one and missed in the other creates divergent security behavior for the same class of attack.

Consider extracting a shared helper — e.g., downloadWithSizeCap(url, headers?, label) — into packages/channels/base/src/ and having both adapters delegate to it.

— qwen3.7-max via Qwen Code /review

// Release the connection instead of letting the unread body pin it
// until GC.
await fileResp.body?.cancel();
process.stderr.write(
`[DingTalk] downloadMedia rejected: size ${contentLength} exceeds ${MAX_DOWNLOAD_BYTES} byte limit\n`,
);
return null;
}
Comment on lines +80 to +88

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When rejecting based on Content-Length, the response body is not consumed or cancelled before returning null. In Node.js/undici, an unconsumed Response.body holds the underlying TCP connection until garbage collection. A burst of oversized media messages would each hold a connection to the DingTalk CDN that isn't released promptly.

The streaming rejection path (line 96) correctly calls reader.cancel(), and the codebase follows this pattern elsewhere — see DingtalkAdapter.ts lines 617 and 659.

Concrete cost: repeated Content-Length rejections leak HTTP connections until GC finalizes the Response objects.

Suggested change
if (contentLength && parseInt(contentLength, 10) > MAX_DOWNLOAD_BYTES) {
process.stderr.write(
`[DingTalk] downloadMedia rejected: size ${contentLength} exceeds ${MAX_DOWNLOAD_BYTES} byte limit\n`,
);
return null;
}
if (contentLength && parseInt(contentLength, 10) > MAX_DOWNLOAD_BYTES) {
await fileResp.body?.cancel();
process.stderr.write(
`[DingTalk] downloadMedia rejected: size ${contentLength} exceeds ${MAX_DOWNLOAD_BYTES} byte limit\n`,
);
return null;
}

— qwen3.7-max via Qwen Code /review


const mimeType =
fileResp.headers.get('content-type') || 'application/octet-stream';
const buffer = Buffer.from(await fileResp.arrayBuffer());

// Stream-read with size enforcement (handles chunked transfer without Content-Length)
const reader = fileResp.body?.getReader();
if (!reader) {
return null;
}
Comment on lines +94 to +97

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The download fetch() call lacks an AbortSignal.timeout(), which means the new streaming loop below can block indefinitely if the server stalls mid-stream without closing the connection. The Feishu reference implementation guards against this with signal: AbortSignal.timeout(30_000) on its download fetch (packages/channels/feishu/src/media.ts:50). In the long-running daemon, repeated stalls on a degraded DingTalk CDN would accumulate open connections, eventually exhausting the socket pool.

Failure scenario: DingTalk CDN accepts the connection but delivers bytes extremely slowly or stalls mid-stream → reader.read() waits forever → each stalled invocation holds a file descriptor and connection pool slot.

Consider adding signal: AbortSignal.timeout(30_000) to the fetch(downloadUrl) call above, matching the Feishu adapter.

— qwen3.7-max via Qwen Code /review

const chunks: Buffer[] = [];
let totalSize = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.byteLength;
if (totalSize > MAX_DOWNLOAD_BYTES) {
// Await the cancel so a stream error during teardown surfaces here
// instead of becoming an unhandled rejection (matches the body.cancel
// above).
await reader.cancel();
process.stderr.write(
`[DingTalk] downloadMedia rejected: actual size exceeds ${MAX_DOWNLOAD_BYTES} byte limit\n`,
);
return null;
}
chunks.push(Buffer.from(value));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Buffer.from(value) allocates a new Buffer and copies the Uint8Array chunk data. Buffer.concat then allocates another buffer and copies all chunks a second time. For a file near the 50 MB cap, this produces ~50 MB of unnecessary allocation.

Buffer.concat accepts readonly Uint8Array[] in Node.js, so the raw value can be pushed directly:

Suggested change
chunks.push(Buffer.from(value));
chunks.push(value);

And update the type:

const chunks: Uint8Array[] = [];

— qwen3.7-max via Qwen Code /review

}
const buffer = Buffer.concat(chunks);

return { buffer, mimeType };
} catch (err) {
Expand Down
Loading