Fix missing filename sanitization on V2 JSON character import + harden getPngName as safety nee (#5538) * fix: sanitize character filenames on V2 JSON import and harden getPngName - Add missing sanitize() call in importFromJson V2 spec branch to match all other import paths - Sanitize data.name before readFromV2() so the name field sync happens automatically - Add sanitize() as defense-in-depth inside getPngName() to catch future oversights - Refactor getPngName() to use getUniqueName() utility for consistent name generation * fix: sanitize data.name before readFromV2 in importFromPng and importFromCharX Same bug as importFromJson: readFromV2() overwrites the top-level name with the unsanitized data.name, undoing any prior sanitize() call. Fix by sanitizing data.name before readFromV2 so the sync preserves it. * fix: sanitize top-level name field in JSON and CharX import paths * fix: incorrect path rejection in isPathUnderParent * fix: increase maxTries in getPngName --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -772,10 +772,13 @@ async function importFromCharX(uploadPath, { request }, preservedFileName) { | |||
| 772 | const { card, avatar, auxiliaryAssets, extractedBuffers } = await parser.parse(); | 772 | const { card, avatar, auxiliaryAssets, extractedBuffers } = await parser.parse(); |
| 773 | 773 | ||
| 774 | // Apply standard character transformations | 774 | // Apply standard character transformations |
| 775 | if (card.data?.name) { | ||
| 776 | card.data.name = sanitize(card.data.name); | ||
| 777 | } | ||
| 778 | card.name = sanitize(card.data?.name || card.name); | ||
| 775 | let processedCard = readFromV2(card); | 779 | let processedCard = readFromV2(card); |
| 776 | unsetPrivateFields(processedCard); | 780 | unsetPrivateFields(processedCard); |
| 777 | processedCard.create_date = new Date().toISOString(); | 781 | processedCard.create_date = new Date().toISOString(); |
| 778 | processedCard.name = sanitize(processedCard.name); | ||
| 779 | 782 | ||
| 780 | const fileName = preservedFileName || getPngName(processedCard.name, request.user.directories); | 783 | const fileName = preservedFileName || getPngName(processedCard.name, request.user.directories); |
| 781 | // Use the actual character name for asset folders, not the unique filename | 784 | // Use the actual character name for asset folders, not the unique filename |
| @@ -887,9 +890,13 @@ async function importFromJson(uploadPath, { request }, preservedFileName) { | |||
| 887 | console.info(`Importing from ${jsonData.spec} json`); | 890 | console.info(`Importing from ${jsonData.spec} json`); |
| 888 | importRisuSprites(request.user.directories, jsonData); | 891 | importRisuSprites(request.user.directories, jsonData); |
| 889 | unsetPrivateFields(jsonData); | 892 | unsetPrivateFields(jsonData); |
| 893 | if (jsonData.data?.name) { | ||
| 894 | jsonData.data.name = sanitize(jsonData.data.name); | ||
| 895 | } | ||
| 896 | jsonData.name = sanitize(jsonData.data?.name || jsonData.name); | ||
| 890 | jsonData = readFromV2(jsonData); | 897 | jsonData = readFromV2(jsonData); |
| 891 | jsonData.create_date = new Date().toISOString(); | 898 | jsonData.create_date = new Date().toISOString(); |
| 892 | const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories); | 899 | const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories); |
| 893 | const char = JSON.stringify(jsonData); | 900 | const char = JSON.stringify(jsonData); |
| 894 | const result = await writeCharacterData(DEFAULT_AVATAR_PATH, char, pngName, request); | 901 | const result = await writeCharacterData(DEFAULT_AVATAR_PATH, char, pngName, request); |
| 895 | return result ? pngName : ''; | 902 | return result ? pngName : ''; |
| @@ -964,6 +971,9 @@ async function importFromPng(uploadPath, { request }, preservedFileName) { | |||
| 964 | 971 | ||
| 965 | let jsonData = JSON.parse(imgData); | 972 | let jsonData = JSON.parse(imgData); |
| 966 | 973 | ||
| 974 | if (jsonData.data?.name) { | ||
| 975 | jsonData.data.name = sanitize(jsonData.data.name); | ||
| 976 | } | ||
| 967 | jsonData.name = sanitize(jsonData.data?.name || jsonData.name); | 977 | jsonData.name = sanitize(jsonData.data?.name || jsonData.name); |
| 968 | const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories); | 978 | const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories); |
| 969 | 979 | ||
| @@ -1529,13 +1539,9 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp | |||
| 1529 | * @returns {string} - The name for the uploaded PNG file | 1539 | * @returns {string} - The name for the uploaded PNG file |
| 1530 | */ | 1540 | */ |
| 1531 | function getPngName(file, directories) { | 1541 | function getPngName(file, directories) { |
| 1532 | let i = 1; | 1542 | file = sanitize(file); |
| 1533 | const baseName = file; | 1543 | return getUniqueName(file, (name) => fs.existsSync(path.join(directories.characters, `${name}.png`)), |
| 1534 | while (fs.existsSync(path.join(directories.characters, `${file}.png`))) { | 1544 | { nameBuilder: (base, i) => i === 0 ? base : `${base}${i}`, startIndex: 0, maxTries: 10000 }) ?? file; |
| 1535 | file = baseName + i; | ||
| 1536 | i++; | ||
| 1537 | } | ||
| 1538 | return file; | ||
| 1539 | } | 1545 | } |
| 1540 | 1546 | ||
| 1541 | /** | 1547 | /** |
| @@ -1387,7 +1387,7 @@ export function isPathUnderParent(parentPath, childPath) { | |||
| 1387 | 1387 | ||
| 1388 | const relativePath = path.relative(normalizedParent, normalizedChild); | 1388 | const relativePath = path.relative(normalizedParent, normalizedChild); |
| 1389 | 1389 | ||
| 1390 | return !relativePath.startsWith('..') && !path.isAbsolute(relativePath); | 1390 | return relativePath !== '..' && !relativePath.startsWith('..' + path.sep) && !path.isAbsolute(relativePath); |
| 1391 | } | 1391 | } |
| 1392 | 1392 | ||
| 1393 | /** | 1393 | /** |