diff --git a/.changeset/gentle-clouds-quote.md b/.changeset/gentle-clouds-quote.md new file mode 100644 index 0000000000000..787a3be01ad8b --- /dev/null +++ b/.changeset/gentle-clouds-quote.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes Google Cloud Storage uploads failing to load in Chromium-based browsers when filenames contain commas, spaces, or other characters that require a quoted `Content-Disposition` value. + +This fix applies to new uploads. Existing affected objects must be re-uploaded or have their `Content-Disposition` metadata repaired manually. diff --git a/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/getContentDisposition.spec.ts b/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/getContentDisposition.spec.ts new file mode 100644 index 0000000000000..c1ec176f85b4d --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/getContentDisposition.spec.ts @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { getContentDisposition } from './getContentDisposition'; + +describe('getContentDisposition', () => { + it('quotes filenames containing commas and spaces', () => { + expect(getContentDisposition('inline', 'thumb-Clipboard - August 21, 2026 11:47 AM.png')).to.equal( + 'inline; filename="thumb-Clipboard - August 21, 2026 11:47 AM.png"', + ); + }); + + it('escapes quotes and backslashes in the quoted filename', () => { + expect(getContentDisposition('attachment', 'report "final"\\copy.pdf')).to.equal( + 'attachment; filename="report \\"final\\"\\\\copy.pdf"', + ); + }); + + it('adds an RFC 5987 filename for Unicode characters', () => { + expect(getContentDisposition('inline', "résumé's (final)*.pdf")).to.equal( + "inline; filename=\"r_sum_'s (final)*.pdf\"; filename*=UTF-8''r%C3%A9sum%C3%A9%27s%20%28final%29%2A.pdf", + ); + }); + + it('replaces C0 and C1 control characters before constructing the header value', () => { + expect(getContentDisposition('inline', 'report\r\n\u0085X-Test: value.txt')).to.equal('inline; filename="report___X-Test: value.txt"'); + }); + + it('normalizes unpaired UTF-16 surrogates before encoding the filename', () => { + expect(getContentDisposition('inline', 'bad\uD800name.txt')).to.equal( + 'inline; filename="bad_name.txt"; filename*=UTF-8\'\'bad%EF%BF%BDname.txt', + ); + }); + + it('preserves simple ASCII filenames', () => { + expect(getContentDisposition('inline', 'photo.png')).to.equal('inline; filename="photo.png"'); + }); +}); diff --git a/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/getContentDisposition.ts b/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/getContentDisposition.ts new file mode 100644 index 0000000000000..7eef790415a10 --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/getContentDisposition.ts @@ -0,0 +1,21 @@ +const replaceControlCharacters = (value: string): string => + Array.from(value, (character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f) ? '_' : character; + }).join(''); + +const encodeRFC5987Value = (value: string): string => + encodeURIComponent(value).replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`); + +export const getContentDisposition = (disposition: 'inline' | 'attachment', fileName: string): string => { + const safeFileName = replaceControlCharacters(fileName.toWellFormed()); + const asciiFileName = safeFileName.replace(/[^\x20-\x7E]/g, '_'); + const quotedFileName = asciiFileName.replace(/["\\]/g, (character) => `\\${character}`); + const fallback = `${disposition}; filename="${quotedFileName}"`; + + if (asciiFileName === safeFileName) { + return fallback; + } + + return `${fallback}; filename*=UTF-8''${encodeRFC5987Value(safeFileName)}`; +}; diff --git a/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/server.ts b/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/server.ts index 945790fca6b3e..e29397973c112 100644 --- a/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/server.ts +++ b/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/server.ts @@ -4,6 +4,7 @@ import type { IUpload } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { check } from 'meteor/check'; +import { getContentDisposition } from './getContentDisposition'; import { UploadFS } from '../../../../../ufs'; import type { StoreOptions } from '../../../../../ufs/ufs-store'; import { SystemLogger } from '../../../../logger/system'; @@ -149,7 +150,7 @@ class GoogleStorageStore extends UploadFS.Store { gzip: false, metadata: { contentType: file.type, - contentDisposition: `inline; filename=${file.name}`, + contentDisposition: getContentDisposition('inline', file.name || ''), // metadata: { // custom: 'metadata' // }