Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
67 changes: 37 additions & 30 deletions apps/meteor/app/importer/server/methods/downloadPublicImportFile.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,60 @@
import fs from 'fs';
import http from 'http';
import https from 'https';

import { Import } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Meteor } from 'meteor/meteor';

import { Importers } from '..';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { settings } from '../../../settings/server';
import { ProgressStep } from '../../lib/ImporterProgressStep';
import { RocketChatImportFileInstance } from '../startup/store';

function downloadHttpFile(fileUrl: string, writeStream: fs.WriteStream): void {
const protocol = fileUrl.startsWith('https') ? https : http;
protocol.get(fileUrl, (response) => {
response.pipe(writeStream);
const getPublicImportUrl = (fileUrl: string): URL => {
try {
const url = new URL(fileUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Invalid protocol');
}

return url;
} catch {
throw new Meteor.Error('error-invalid-url', 'Import files must be downloaded from a valid HTTP or HTTPS URL.', 'downloadPublicImportFile');
}
};

async function downloadHttpFile(fileUrl: string, writeStream: ReturnType<typeof RocketChatImportFileInstance.createWriteStream>): Promise<void> {
const response = await fetch(fileUrl, {
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
});
}

function copyLocalFile(filePath: fs.PathLike, writeStream: fs.WriteStream): void {
const readStream = fs.createReadStream(filePath);
readStream.pipe(writeStream);
if (response.status !== 200) {
throw new Meteor.Error('error-import-file-download-failed', 'Failed to download import file.', 'downloadPublicImportFile');
}

const fileBuffer = Buffer.from(await response.arrayBuffer());
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stream import downloads instead of buffering whole file

This now reads the full remote import file into memory (response.arrayBuffer() then Buffer.from(...)) before writing it to storage, which is a regression from the previous streaming behavior and can exhaust Node heap on large imports. In production, a sufficiently large CSV/JSON export can cause high memory spikes or process crashes during downloadPublicImportFile, interrupting imports for all users. Keep the SSRF validation, but write from the response stream to writeStream to preserve bounded memory usage.

Useful? React with 👍 / 👎.

await new Promise<void>((resolve, reject) => {
writeStream.once('error', reject);
writeStream.end(fileBuffer, resolve);
});
Comment on lines +36 to +40
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

downloadHttpFile loads the full response into memory (response.arrayBuffer() -> Buffer.from(...)) before writing. Import archives can be large, so this risks high memory usage/OOM and makes the server susceptible to large-download DoS. Prefer streaming the response body into the write stream (with backpressure) and enforce a max size (e.g., Content-Length / configured limit) and/or an explicit download timeout.

Copilot uses AI. Check for mistakes.
}

export const executeDownloadPublicImportFile = async (userId: IUser['_id'], fileUrl: string, importerKey: string): Promise<void> => {
const importer = Importers.get(importerKey);
const isUrl = fileUrl.startsWith('http');
const publicImportUrl = getPublicImportUrl(fileUrl);
if (!importer) {
throw new Meteor.Error(
'error-importer-not-defined',
`The importer (${importerKey}) has no import class defined.`,
'downloadImportFile',
);
}
// Check if it's a valid url or path before creating a new import record
if (!isUrl && !fs.existsSync(fileUrl)) {
throw new Meteor.Error('error-import-file-missing', fileUrl, 'downloadPublicImportFile');
}

const operation = await Import.newOperation(userId, importer.name, importer.key);
const instance = new importer.importer(importer, operation); // eslint-disable-line new-cap

const oldFileName = fileUrl.substring(fileUrl.lastIndexOf('/') + 1).split('?')[0];
const oldFileName = publicImportUrl.pathname.substring(publicImportUrl.pathname.lastIndexOf('/') + 1) || 'import-file';
const date = new Date();
const dateStr = `${date.getUTCFullYear()}${date.getUTCMonth()}${date.getUTCDate()}${date.getUTCHours()}${date.getUTCMinutes()}${date.getUTCSeconds()}`;
const newFileName = `${dateStr}_${userId}_${oldFileName}`;
Expand All @@ -57,21 +69,16 @@ export const executeDownloadPublicImportFile = async (userId: IUser['_id'], file
void instance.updateProgress(ProgressStep.ERROR);
});

writeStream.on('end', () => {
writeStream.on('finish', () => {
void instance.updateProgress(ProgressStep.FILE_LOADED);
});

if (isUrl) {
downloadHttpFile(fileUrl, writeStream);
} else {
// If the url is actually a folder path on the current machine, skip moving it to the file store
if (fs.statSync(fileUrl).isDirectory()) {
await instance.updateRecord({ file: fileUrl });
await instance.updateProgress(ProgressStep.FILE_LOADED);
return;
}

copyLocalFile(fileUrl, writeStream);
try {
await downloadHttpFile(publicImportUrl.toString(), writeStream);
} catch (error) {
writeStream.destroy();
await instance.updateProgress(ProgressStep.ERROR);
throw error;
}
};

Expand Down
8 changes: 4 additions & 4 deletions apps/meteor/ee/server/apps/communication/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,9 @@ export class AppsRestApi {

if (this.bodyParams.url) {
try {
// SECURITY: user needs specific privileges to send this. Bypassing the SSRF check is okay for now.
const response = await fetch(this.bodyParams.url, {
ignoreSsrfValidation: true,
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
});

if (response.status !== 200 || response.headers.get('content-type') !== 'application/zip') {
Expand Down Expand Up @@ -825,9 +825,9 @@ export class AppsRestApi {
let isPrivateAppUpload = false;

if (this.bodyParams.url) {
// SECURITY: user needs specific privileges to send this. Bypassing the SSRF check is okay for now.
const response = await fetch(this.bodyParams.url, {
ignoreSsrfValidation: true,
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
});

if (response.status !== 200 || response.headers.get('content-type') !== 'application/zip') {
Expand Down
Loading