mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
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:
1 parent
7b66884ba3
commit
5e26cf0048
1 file changed
+49
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user