Skip to content
Open
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
223 changes: 165 additions & 58 deletions apps/meteor/app/lib/server/functions/setUserAvatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,165 @@ import { RocketChatFile } from '../../../file/server';
import { FileUpload } from '../../../file-upload/server';
import { settings } from '../../../settings/server';

const DEFAULT_AVATAR_DOWNLOAD_TIMEOUT_MS = 20_000;
const DEFAULT_MAX_FILE_SIZE = 104_857_600;

const getMediaType = (contentTypeHeader: string | null): string => {
const mediaType = contentTypeHeader?.split(';', 1)[0].trim().toLowerCase();

return mediaType || '';
};

const isImageContentType = (contentTypeHeader: string | null): boolean => /^image\/[^;\s]+$/.test(getMediaType(contentTypeHeader));

const isRequestTimeoutError = (error: unknown): boolean => {
if (!error || typeof error !== 'object') {
return false;
}

const { name, code, type } = error as { name?: string; code?: string; type?: string };

return name === 'AbortError' || code === 'ETIMEDOUT' || type === 'request-timeout' || type === 'body-timeout';
};

const isResponseTooLargeError = (error: unknown): boolean => {
if (!error || typeof error !== 'object') {
return false;
}

return (error as { type?: string }).type === 'max-size';
};

const redactUrl = (url: string): string => {
try {
const parsed = new URL(url);
parsed.username = '';
parsed.password = '';
parsed.searchParams.forEach((_value, key) => {
if (/token|key|secret|password|auth|session|sid/i.test(key)) {
parsed.searchParams.set(key, '[redacted]');
}
});

return parsed.toString();
} catch {
return url;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 14, 2026

Choose a reason for hiding this comment

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

P2: redactUrl returns raw input on URL-parse failure, so malformed user-supplied avatar URLs are logged unsanitized in the new logging paths.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/app/lib/server/functions/setUserAvatar.ts, line 55:

<comment>`redactUrl` returns raw input on URL-parse failure, so malformed user-supplied avatar URLs are logged unsanitized in the new logging paths.</comment>

<file context>
@@ -39,6 +39,23 @@ const isRequestTimeoutError = (error: unknown): boolean => {
+
+		return parsed.toString();
+	} catch {
+		return url;
+	}
+};
</file context>
Fix with Cubic

}
};

const getMaxFileSize = (): number => {
const maxFileSizeSetting = Number(settings.get('FileUpload_MaxFileSize'));

return Number.isFinite(maxFileSizeSetting) && maxFileSizeSetting > 0 ? maxFileSizeSetting : DEFAULT_MAX_FILE_SIZE;
};

const throwInvalidAvatarUrl = (dataURI: string): never => {
throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${encodeURI(dataURI)}`, {
function: 'setUserAvatar',
url: dataURI,
});
};

const throwAvatarDownloadTimeout = (dataURI: string): never => {
throw new Meteor.Error('error-avatar-download-timeout', 'Avatar download timed out', {
function: 'setUserAvatar',
url: dataURI,
timeoutMs: DEFAULT_AVATAR_DOWNLOAD_TIMEOUT_MS,
});
};

const throwAvatarUrlHandling = (dataURI: string, username: string): never => {
throw new Meteor.Error(
'error-avatar-url-handling',
`Error while handling avatar setting from a URL (${encodeURI(dataURI)}) for ${username}`,
{ function: 'RocketChat.setUserAvatar', url: dataURI, username },
);
};

const getAvatarFromUrl = async (
user: Pick<IUser, 'username'> & { username: string },
dataURI: string,
): Promise<{ buffer: Buffer; type: string }> => {
const maxFileSize = getMaxFileSize();
let response!: Response;

try {
response = await fetch(dataURI, {
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
size: maxFileSize,
timeout: DEFAULT_AVATAR_DOWNLOAD_TIMEOUT_MS,
});
} catch (error) {
if (isRequestTimeoutError(error)) {
throwAvatarDownloadTimeout(dataURI);
}

SystemLogger.info({
msg: 'Not a valid response from the avatar url',
url: redactUrl(dataURI),
err: error,
});
throwInvalidAvatarUrl(dataURI);
}

if (response.status !== 200) {
if (response.status !== 404) {
SystemLogger.info({
msg: 'Error while handling the setting of the avatar from a url',
url: redactUrl(dataURI),
username: user.username,
status: response.status,
});
throwAvatarUrlHandling(dataURI, user.username);
}

SystemLogger.info({
msg: 'Not a valid response from the avatar url',
status: response.status,
url: redactUrl(dataURI),
});
throwInvalidAvatarUrl(dataURI);
}

const type = response.headers.get('content-type') || '';
if (!isImageContentType(type)) {
SystemLogger.info({
msg: 'Not a valid content-type from the provided avatar url',
contentType: type,
url: redactUrl(dataURI),
});
throwInvalidAvatarUrl(dataURI);
}

try {
return {
buffer: Buffer.from(await response.arrayBuffer()),
type,
};
} catch (error) {
if (isResponseTooLargeError(error)) {
throw new Meteor.Error('error-file-too-large', 'Avatar file exceeds allowed size limit', {
function: 'setUserAvatar',
url: dataURI,
sizeLimit: maxFileSize,
});
}

if (isRequestTimeoutError(error)) {
throwAvatarDownloadTimeout(dataURI);
}

SystemLogger.info({
msg: 'Error while downloading avatar from provided url',
url: redactUrl(dataURI),
username: user.username,
err: error,
});
return throwAvatarUrlHandling(dataURI, user.username);
}
};

export const setAvatarFromServiceWithValidation = async (
userId: string,
dataURI: string,
Expand Down Expand Up @@ -100,67 +259,13 @@ export async function setUserAvatar(

const { buffer, type } = await (async (): Promise<{ buffer: Buffer; type: string }> => {
if (service === 'url' && typeof dataURI === 'string') {
let response: Response;

try {
response = await fetch(dataURI, {
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
});
} catch (e) {
SystemLogger.info({
msg: 'Not a valid response from the avatar url',
url: encodeURI(dataURI),
err: e,
});
throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${encodeURI(dataURI)}`, {
function: 'setUserAvatar',
url: dataURI,
});
}

if (response.status !== 200) {
if (response.status !== 404) {
SystemLogger.info({
msg: 'Error while handling the setting of the avatar from a url',
url: encodeURI(dataURI),
username: user.username,
status: response.status,
});
throw new Meteor.Error(
'error-avatar-url-handling',
`Error while handling avatar setting from a URL (${encodeURI(dataURI)}) for ${user.username}`,
{ function: 'RocketChat.setUserAvatar', url: dataURI, username: user.username },
);
}

SystemLogger.info({
msg: 'Not a valid response from the avatar url',
status: response.status,
url: dataURI,
});
throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, {
if (!user.username) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
function: 'setUserAvatar',
url: dataURI,
});
}

if (!/image\/.+/.test(response.headers.get('content-type') || '')) {
SystemLogger.info({
msg: 'Not a valid content-type from the provided avatar url',
contentType: response.headers.get('content-type'),
url: dataURI,
});
throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, {
function: 'setUserAvatar',
url: dataURI,
});
}

return {
buffer: Buffer.from(await response.arrayBuffer()),
type: response.headers.get('content-type') || '',
};
return getAvatarFromUrl({ username: user.username }, dataURI);
}

if (service === 'rest') {
Expand All @@ -185,7 +290,9 @@ export async function setUserAvatar(
})();

const fileStore = FileUpload.getStore('Avatars');
user.username && (await fileStore.deleteByName(user.username, { session }));
if (user.username) {
await fileStore.deleteByName(user.username, { session });
}

const file = {
userId: user._id,
Expand Down
Loading