Fix ENAMETOOLONG on very long attachment filenames (#6412)

Attachment filenames were sanitized for path traversal but never length-limited,
so a very long name (made worse by multibyte UTF-8 chars like German umlauts,
2 bytes each) produced an on-disk component "<id>-<version>-<name>" exceeding the
filesystem's 255-byte per-path-component limit. Migrating such files from GridFS
to filesystem storage then failed with ENAMETOOLONG (errno -36).

sanitizeFilename now truncates the name to 200 UTF-8 bytes, measured in bytes
(not characters) and never splitting a multibyte codepoint, while preserving the
file extension. 200 bytes leaves headroom for the "<id>-<version>-" prefix so the
full component stays under 255 bytes. Applied centrally in sanitizeFilename so
every path-construction and lookup site stays consistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lauri OjansivuandClaude Opus 4.8 committed 2026-06-21 21:08:26 +03:00
1 parent 7b66884ba3
commit 5e26cf0048
1 file changed
+49
+49
View File
@@ -25,6 +25,50 @@ export {
CLOUD_STORAGE_NAMES,
} from './fileStoreConstants';
/**
* Maximum number of bytes for the sanitized filename.
* Most filesystems limit a single path component to 255 bytes. The stored file
* is named "<id>-<version>-<name>", so we reserve headroom for that prefix and
* cap the name itself well below 255. Length is measured in UTF-8 *bytes* (not
* characters) because multibyte names (e.g. German umlauts) take several bytes
* each and would otherwise overflow the limit and fail with ENAMETOOLONG.
* See https://github.com/wekan/wekan/issues/6412
*/
const MAX_FILENAME_BYTES = 200;
/**
* Truncate a filename to at most maxBytes UTF-8 bytes, preserving the extension
* and never splitting a multibyte character.
* @param {string} filename
* @param {number} maxBytes
* @return {string} the (possibly) shortened filename
*/
function truncateFilenameToBytes(filename, maxBytes) {
if (Buffer.byteLength(filename, 'utf8') <= maxBytes) {
return filename;
}
const ext = path.extname(filename);
const extBytes = Buffer.byteLength(ext, 'utf8');
// If the extension alone does not fit, ignore it and clip the whole name.
const keepExt = extBytes < maxBytes;
const base = keepExt ? filename.slice(0, filename.length - ext.length) : filename;
const budget = keepExt ? maxBytes - extBytes : maxBytes;
let out = '';
let bytes = 0;
for (const ch of base) { // iterates by Unicode code point
const chBytes = Buffer.byteLength(ch, 'utf8');
if (bytes + chBytes > budget) {
break;
}
out += ch;
bytes += chBytes;
}
const truncated = keepExt ? out + ext : out;
return truncated || 'unnamed';
}
/**
* Sanitize filename to prevent path traversal attacks
* @param {string} filename - User-provided filename
@@ -57,6 +101,11 @@ function sanitizeFilename(filename) {
return 'unnamed';
}
// Limit length so the on-disk filename stays within the filesystem's
// per-component byte limit, preventing ENAMETOOLONG on very long names.
// See https://github.com/wekan/wekan/issues/6412
safe = truncateFilenameToBytes(safe, MAX_FILENAME_BYTES);
return safe;
}