diff --git a/apps/server/package.json b/apps/server/package.json index 97af8db7..c36e9002 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -110,7 +110,7 @@ "react": "^18.3.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "sanitize-filename-ts": "1.0.2", + "sanitize-filename": "1.6.3", "socket.io": "^4.8.3", "stripe": "^17.7.0", "tlds": "^1.261.0", @@ -165,6 +165,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "transformIgnorePatterns": [ + "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked)(@|/))" + ], "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/apps/server/src/common/helpers/utils.spec.ts b/apps/server/src/common/helpers/utils.spec.ts new file mode 100644 index 00000000..f444e007 Binary files /dev/null and b/apps/server/src/common/helpers/utils.spec.ts differ diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index c37e9a47..f65067e1 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import * as bcrypt from 'bcrypt'; -import { sanitize } from 'sanitize-filename-ts'; +import sanitize = require('sanitize-filename'); import { FastifyRequest } from 'fastify'; import { Readable, Transform } from 'stream'; @@ -72,11 +72,33 @@ export function extractDateFromUuid7(uuid7: string) { return new Date(timestamp); } -export function sanitizeFileName(fileName: string): string { - const sanitizedFilename = sanitize(fileName) - .replace(/ /g, '_') - .replace(/#/g, '_'); - return sanitizedFilename.slice(0, 255); +export type SanitizeFileNameOptions = { + /** Keep spaces and `#` instead of replacing them with `_`. Useful for + * download filenames where readability matters. Defaults to false. */ + preserveSpaces?: boolean; +}; + +export function sanitizeFileName( + fileName: string, + options: SanitizeFileNameOptions = {}, +): string { + // Decode percent-encoded sequences so that bypasses like "..%2F" reach + // sanitize() as literal "../" and get stripped. sanitize-filename only + // strips literal characters and won't catch encoded path separators + // on its own. + const decoded = fileName.replace(/%[0-9a-fA-F]{2}/g, (m) => { + try { + return decodeURIComponent(m); + } catch { + return m; + } + }); + + const sanitized = sanitize(decoded); + if (options.preserveSpaces) { + return sanitized; + } + return sanitized.replace(/ /g, '_').replace(/#/g, '_'); } export function removeAccent(str: string): string { diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index 784e527e..73605819 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -53,7 +53,6 @@ import { EnvironmentService } from '../../integrations/environment/environment.s import { TokenService } from '../auth/services/token.service'; import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload'; import * as path from 'path'; -import { sanitize } from 'sanitize-filename-ts'; import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto'; import { PageAccessService } from '../page/page-access/page-access.service'; import { AuditEvent, AuditResource } from '../../common/events/audit-events'; @@ -357,13 +356,19 @@ export class AttachmentController { throw new BadRequestException('Invalid image attachment type'); } - if (!fileName || sanitize(fileName) !== fileName) { + if (!fileName) { throw new BadRequestException('Invalid file name'); } - const filenameWithoutExt = path.basename(fileName, path.extname(fileName)); - if (!isValidUUID(filenameWithoutExt)) { - throw new BadRequestException('Invalid file id'); + const ext = path.extname(fileName); + const filenameWithoutExt = path.basename(fileName, ext); + + if ( + !ext || + !isValidUUID(filenameWithoutExt) || + `${filenameWithoutExt}${ext}` !== fileName + ) { + throw new BadRequestException('Invalid file name'); } const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`; diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts index 1ce3f8c8..140622da 100644 --- a/apps/server/src/integrations/export/export.controller.ts +++ b/apps/server/src/integrations/export/export.controller.ts @@ -23,9 +23,12 @@ import { SpaceCaslSubject, } from '../../core/casl/interfaces/space-ability.type'; import { FastifyReply } from 'fastify'; -import { sanitize } from 'sanitize-filename-ts'; import { getExportExtension } from './utils'; -import { getMimeType, getPageTitle } from '../../common/helpers'; +import { + getMimeType, + getPageTitle, + sanitizeFileName, +} from '../../common/helpers'; import * as path from 'path'; import { AuditEvent, AuditResource } from '../../common/events/audit-events'; import { @@ -85,7 +88,9 @@ export class ExportController { if (result.type === 'file') { const ext = getExportExtension(dto.format); - const fileName = sanitize(page.title || 'untitled') + ext; + const fileName = + sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) + + ext; const contentType = getMimeType(path.extname(fileName)); res.headers({ @@ -96,7 +101,9 @@ export class ExportController { res.send(result.content); } else { - const fileName = sanitize(page.title || 'untitled') + '.zip'; + const fileName = + sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) + + '.zip'; res.headers({ 'Content-Type': 'application/zip', @@ -144,7 +151,9 @@ export class ExportController { 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="' + - encodeURIComponent(sanitize(exportFile.fileName)) + + encodeURIComponent( + sanitizeFileName(exportFile.fileName, { preserveSpaces: true }), + ) + '"', }); diff --git a/apps/server/src/integrations/import/services/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts index 231a6c89..66c57585 100644 --- a/apps/server/src/integrations/import/services/import.service.ts +++ b/apps/server/src/integrations/import/services/import.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { MultipartFile } from '@fastify/multipart'; -import { sanitize } from 'sanitize-filename-ts'; import * as path from 'path'; import { htmlToJson, @@ -53,8 +52,8 @@ export class ImportService { const file = await filePromise; const fileBuffer = await file.toBuffer(); const fileExtension = path.extname(file.filename).toLowerCase(); - const fileName = sanitize( - path.basename(file.filename, fileExtension).slice(0, 255), + const fileName = sanitizeFileName( + path.basename(file.filename, fileExtension), ); const fileContent = fileBuffer.toString(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56dd6d79..fe7a2042 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -697,9 +697,9 @@ importers: rxjs: specifier: ^7.8.2 version: 7.8.2 - sanitize-filename-ts: - specifier: 1.0.2 - version: 1.0.2 + sanitize-filename: + specifier: 1.6.3 + version: 1.6.3 socket.io: specifier: ^4.8.3 version: 4.8.3 @@ -9570,8 +9570,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sanitize-filename-ts@1.0.2: - resolution: {integrity: sha512-bON2VOJoappmaBHlnxvBNk5R7HkUAsirf5m1M5Kz15uZykDGbHfGPCQNcEQKR8HrQhgh9CmQ6Xe9y71yM9ywkw==} + sanitize-filename@1.6.3: + resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} sass@1.51.0: resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==} @@ -20900,7 +20900,7 @@ snapshots: safer-buffer@2.1.2: {} - sanitize-filename-ts@1.0.2: + sanitize-filename@1.6.3: dependencies: truncate-utf8-bytes: 1.0.2