diff --git a/packages/twenty-apps/examples/postcard/src/__tests__/global-setup.ts b/packages/twenty-apps/examples/postcard/src/__tests__/global-setup.ts index e0f8c23b05..98bd5539eb 100644 --- a/packages/twenty-apps/examples/postcard/src/__tests__/global-setup.ts +++ b/packages/twenty-apps/examples/postcard/src/__tests__/global-setup.ts @@ -80,8 +80,8 @@ export async function teardown() { const uninstallResult = await appUninstall({ appPath: APP_PATH }); if (!uninstallResult.success) { - console.warn( - `App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`, + throw new Error( + `App uninstall failed during teardown: ${JSON.stringify(uninstallResult.error, null, 2)}`, ); } } diff --git a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts index 1ca37a0a82..6495b0ae34 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts @@ -20,6 +20,7 @@ import { DevelopmentApplicationDTO } from 'src/engine/core-modules/application/a import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/application-development/dtos/generate-application-token.input'; import { UploadApplicationFileInput } from 'src/engine/core-modules/application/application-development/dtos/upload-application-file.input'; import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto'; +import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util'; import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter'; import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service'; import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util'; @@ -219,6 +220,18 @@ export class ApplicationDevelopmentResolver { ); } + const pathValidationResult = validateFilePath({ + resourcePath: filePath, + fileFolder, + }); + + if (!pathValidationResult.isValid) { + throw new ApplicationException( + pathValidationResult.error, + ApplicationExceptionCode.INVALID_INPUT, + ); + } + const application = await this.applicationService.findByUniversalIdentifier( { universalIdentifier: applicationUniversalIdentifier, diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts index a955b8bf47..39fcbd04f4 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts @@ -2,6 +2,10 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { FileFolder } from 'twenty-shared/types'; +import { + type EachTestingContext, + eachTestingContextFilter, +} from 'twenty-shared/testing'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory'; @@ -190,141 +194,203 @@ describe('FileStorageService', () => { resourcePath: 'src/components/my-component.mjs', }; + const validFolderIdentifier = { + workspaceId: 'workspace-123', + applicationUniversalIdentifier: 'app-456', + fileFolder: FileFolder.BuiltFrontComponent, + }; + const expectedValidPath = 'workspace-123/app-456/built-front-component/src/components/my-component.mjs'; + type RejectedFilePathContext = { + resourcePath: string; + }; + + const REJECTED_FILE_PATHS: EachTestingContext[] = [ + { + title: 'path traversal with ../', + context: { + resourcePath: + '../../../victim-ws/victim-app/built-front-component/secret.mjs', + }, + }, + { + title: 'absolute path', + context: { resourcePath: '/etc/passwd' }, + }, + { + title: 'single-level traversal escaping fileFolder', + context: { resourcePath: '../source/handler.ts' }, + }, + { + title: 'excess .. segments', + context: { resourcePath: 'foo/../../../../../../etc/passwd' }, + }, + { + title: 'exact 3-level traversal to another tenant', + context: { + resourcePath: + '../../../target-ws/target-app/built-front-component/file.js', + }, + }, + { + title: 'empty resource path', + context: { resourcePath: '' }, + }, + ]; + + type AcceptedFilePathContext = { + resourcePath: string; + expectedStoragePath: string; + }; + + const ACCEPTED_FILE_PATHS: EachTestingContext[] = [ + { + title: 'valid relative path', + context: { + resourcePath: 'src/components/my-component.mjs', + expectedStoragePath: expectedValidPath, + }, + }, + { + title: 'deeply nested valid path', + context: { + resourcePath: 'a/b/c/d/e/f/deep-file.mjs', + expectedStoragePath: + 'workspace-123/app-456/built-front-component/a/b/c/d/e/f/deep-file.mjs', + }, + }, + { + title: 'path with dots that are not traversal', + context: { + resourcePath: 'v1.0.0/file.name.mjs', + expectedStoragePath: + 'workspace-123/app-456/built-front-component/v1.0.0/file.name.mjs', + }, + }, + { + title: 'path with hidden directory (dot-prefixed segment)', + context: { + resourcePath: '.hidden/file.name.mjs', + expectedStoragePath: + 'workspace-123/app-456/built-front-component/.hidden/file.name.mjs', + }, + }, + ]; + describe('readFile', () => { - it('should allow valid relative paths', async () => { - await service.readFile(validResourceIdentifier); + it.each(eachTestingContextFilter(REJECTED_FILE_PATHS))( + 'should reject $title', + ({ context }) => { + expect(() => + service.readFile({ + ...validResourceIdentifier, + resourcePath: context.resourcePath, + }), + ).toThrow( + expect.objectContaining({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }), + ); - expect(mockDriver.readFile).toHaveBeenCalledWith({ - filePath: expectedValidPath, - }); - }); + expect(mockDriver.readFile).not.toHaveBeenCalled(); + }, + ); - it('should reject path traversal with ../', () => { - expect(() => - service.readFile({ + it.each(eachTestingContextFilter(ACCEPTED_FILE_PATHS))( + 'should accept $title', + async ({ context }) => { + await service.readFile({ ...validResourceIdentifier, - resourcePath: - '../../../victim-ws/victim-app/built-front-component/secret.mjs', - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); + resourcePath: context.resourcePath, + }); - expect(mockDriver.readFile).not.toHaveBeenCalled(); - }); - - it('should reject absolute paths', () => { - expect(() => - service.readFile({ - ...validResourceIdentifier, - resourcePath: '/etc/passwd', - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - - expect(mockDriver.readFile).not.toHaveBeenCalled(); - }); - - it('should reject single-level traversal escaping fileFolder', () => { - expect(() => - service.readFile({ - ...validResourceIdentifier, - resourcePath: '../source/handler.ts', - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - - expect(mockDriver.readFile).not.toHaveBeenCalled(); - }); + expect(mockDriver.readFile).toHaveBeenCalledWith({ + filePath: context.expectedStoragePath, + }); + }, + ); }); describe('checkFileExists', () => { - it('should allow valid relative paths', async () => { - await service.checkFileExists(validResourceIdentifier); + it.each(eachTestingContextFilter(REJECTED_FILE_PATHS))( + 'should reject $title', + ({ context }) => { + expect(() => + service.checkFileExists({ + ...validResourceIdentifier, + resourcePath: context.resourcePath, + }), + ).toThrow( + expect.objectContaining({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }), + ); - expect(mockDriver.checkFileExists).toHaveBeenCalledWith({ - filePath: expectedValidPath, - }); - }); - - it('should reject path traversal with ../', () => { - expect(() => - service.checkFileExists({ - ...validResourceIdentifier, - resourcePath: - '../../../other-ws/other-app/built-front-component/file.js', - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - - expect(mockDriver.checkFileExists).not.toHaveBeenCalled(); - }); + expect(mockDriver.checkFileExists).not.toHaveBeenCalled(); + }, + ); }); describe('getPresignedUrl', () => { - it('should reject path traversal', async () => { - await expect( - service.getPresignedUrl({ - ...validResourceIdentifier, - resourcePath: '../../../other-ws/file.js', - }), - ).rejects.toMatchObject({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }); + it.each(eachTestingContextFilter(REJECTED_FILE_PATHS))( + 'should reject $title', + async ({ context }) => { + await expect( + service.getPresignedUrl({ + ...validResourceIdentifier, + resourcePath: context.resourcePath, + }), + ).rejects.toMatchObject({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }); - expect(mockDriver.getPresignedUrl).not.toHaveBeenCalled(); - }); + expect(mockDriver.getPresignedUrl).not.toHaveBeenCalled(); + }, + ); }); describe('downloadFile', () => { - it('should reject path traversal', () => { - expect(() => - service.downloadFile({ - ...validResourceIdentifier, - resourcePath: '../../../other-ws/file.js', - localPath: '/tmp/download.js', - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); + it.each(eachTestingContextFilter(REJECTED_FILE_PATHS))( + 'should reject $title', + ({ context }) => { + expect(() => + service.downloadFile({ + ...validResourceIdentifier, + resourcePath: context.resourcePath, + localPath: '/tmp/download.js', + }), + ).toThrow( + expect.objectContaining({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }), + ); - expect(mockDriver.downloadFile).not.toHaveBeenCalled(); - }); + expect(mockDriver.downloadFile).not.toHaveBeenCalled(); + }, + ); }); describe('writeFile', () => { - it('should reject path traversal on write', async () => { - await expect( - service.writeFile({ - ...validResourceIdentifier, - resourcePath: - '../../../victim-ws/victim-app/built-front-component/overwrite.mjs', - sourceFile: Buffer.from('malicious'), - mimeType: 'application/javascript', - settings: { isTemporaryFile: false, toDelete: false }, - }), - ).rejects.toMatchObject({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }); + it.each(eachTestingContextFilter(REJECTED_FILE_PATHS))( + 'should reject $title', + async ({ context }) => { + await expect( + service.writeFile({ + ...validResourceIdentifier, + resourcePath: context.resourcePath, + sourceFile: Buffer.from('malicious'), + mimeType: 'application/javascript', + settings: { isTemporaryFile: false, toDelete: false }, + }), + ).rejects.toMatchObject({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }); - expect(mockDriver.writeFile).not.toHaveBeenCalled(); - }); + expect(mockDriver.writeFile).not.toHaveBeenCalled(); + }, + ); it('should allow valid writes', async () => { await service.writeFile({ @@ -342,18 +408,178 @@ describe('FileStorageService', () => { }); }); - describe('delete', () => { - it('should reject path traversal on delete', async () => { - await expect( - service.delete({ - ...validResourceIdentifier, - resourcePath: '../../../other-ws/other-app/folder', - }), - ).rejects.toMatchObject({ - code: FileStorageExceptionCode.ACCESS_DENIED, + describe('deleteFile', () => { + it.each(eachTestingContextFilter(REJECTED_FILE_PATHS))( + 'should reject $title', + async ({ context }) => { + await expect( + service.deleteFile({ + ...validResourceIdentifier, + resourcePath: context.resourcePath, + }), + ).rejects.toMatchObject({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }); + + expect(mockDriver.delete).not.toHaveBeenCalled(); + }, + ); + + it('should call driver.delete with dirname and basename', async () => { + await service.deleteFile(validResourceIdentifier); + + expect(mockDriver.delete).toHaveBeenCalledWith({ + folderPath: + 'workspace-123/app-456/built-front-component/src/components', + filename: 'my-component.mjs', + }); + }); + }); + + describe('deleteFolder', () => { + type RejectedFolderPathContext = { + folderPath: string; + }; + + const REJECTED_FOLDER_PATHS: EachTestingContext[] = + [ + { + title: 'path traversal with ../', + context: { folderPath: '../../../other-ws/other-app/folder' }, + }, + { + title: 'absolute path', + context: { folderPath: '/etc/secret-folder' }, + }, + { + title: 'empty folder path', + context: { folderPath: '' }, + }, + { + title: 'folder path with special characters (spaces)', + context: { folderPath: 'my folder/data' }, + }, + { + title: 'folder path with shell metacharacters', + context: { folderPath: 'folder;rm -rf/' }, + }, + { + title: 'file path with .mjs extension', + context: { folderPath: 'src/logic-functions/handler.mjs' }, + }, + { + title: 'file path with .ts extension', + context: { folderPath: 'src/components/index.ts' }, + }, + { + title: 'file path with .json extension', + context: { folderPath: 'config/settings.json' }, + }, + { + title: 'dotted version string (extname detects .0)', + context: { folderPath: 'v1.0.0' }, + }, + { + title: 'numeric-only extension (.7z)', + context: { folderPath: 'archive.7z' }, + }, + ]; + + type AcceptedFolderPathContext = { + folderPath: string; + expectedStoragePath: string; + }; + + const ACCEPTED_FOLDER_PATHS: EachTestingContext[] = + [ + { + title: 'UUID folder path', + context: { + folderPath: '8b2df3cc-23ad-4e1b-87fd-f880d4cefd58', + expectedStoragePath: + 'workspace-123/app-456/built-front-component/8b2df3cc-23ad-4e1b-87fd-f880d4cefd58/', + }, + }, + { + title: 'simple folder name', + context: { + folderPath: 'my-folder', + expectedStoragePath: + 'workspace-123/app-456/built-front-component/my-folder/', + }, + }, + { + title: 'nested folder path with numeric ranges', + context: { + folderPath: '0000-0999/tmp200/toto', + expectedStoragePath: + 'workspace-123/app-456/built-front-component/0000-0999/tmp200/toto/', + }, + }, + ]; + + it.each(eachTestingContextFilter(REJECTED_FOLDER_PATHS))( + 'should reject $title', + async ({ context }) => { + await expect( + service.deleteFolder({ + ...validFolderIdentifier, + folderPath: context.folderPath, + }), + ).rejects.toMatchObject({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }); + + expect(mockDriver.delete).not.toHaveBeenCalled(); + }, + ); + + it.each(eachTestingContextFilter(ACCEPTED_FOLDER_PATHS))( + 'should accept $title', + async ({ context }) => { + await service.deleteFolder({ + ...validFolderIdentifier, + folderPath: context.folderPath, + }); + + expect(mockDriver.delete).toHaveBeenCalledWith({ + folderPath: context.expectedStoragePath, + }); + }, + ); + }); + + describe('deleteByFileId', () => { + it('should delegate to deleteFile with the correct resource path', async () => { + mockFileRepository.findOneOrFail.mockResolvedValue({ + id: 'file-id', + path: 'built-front-component/src/components/my-component.mjs', + applicationId: 'app-id', + workspaceId: 'workspace-123', }); - expect(mockDriver.delete).not.toHaveBeenCalled(); + mockApplicationRepository.findOneOrFail.mockResolvedValue({ + id: 'app-id', + universalIdentifier: 'app-456', + }); + + await service.deleteByFileId({ + fileId: 'file-id', + workspaceId: 'workspace-123', + fileFolder: FileFolder.BuiltFrontComponent, + }); + + expect(mockDriver.delete).toHaveBeenCalledWith({ + folderPath: + 'workspace-123/app-456/built-front-component/src/components', + filename: 'my-component.mjs', + }); + + expect(mockFileRepository.delete).toHaveBeenCalledWith({ + path: 'built-front-component/src/components/my-component.mjs', + applicationId: 'app-id', + workspaceId: 'workspace-123', + }); }); }); @@ -390,70 +616,5 @@ describe('FileStorageService', () => { }); }); }); - - describe('edge cases', () => { - it('should reject traversal with excess .. segments', () => { - expect(() => - service.readFile({ - ...validResourceIdentifier, - resourcePath: 'foo/../../../../../../etc/passwd', - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - }); - - it('should accept deeply nested valid paths', async () => { - await service.readFile({ - ...validResourceIdentifier, - resourcePath: 'a/b/c/d/e/f/deep-file.mjs', - }); - - expect(mockDriver.readFile).toHaveBeenCalledWith({ - filePath: - 'workspace-123/app-456/built-front-component/a/b/c/d/e/f/deep-file.mjs', - }); - }); - - it('should reject exact 3-level traversal to another tenant', () => { - expect(() => - service.readFile({ - ...validResourceIdentifier, - resourcePath: - '../../../target-ws/target-app/built-front-component/file.js', - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - }); - - it('should accept paths with dots that are not traversal', async () => { - await service.readFile({ - ...validResourceIdentifier, - resourcePath: '.hidden/file.name.ext', - }); - - expect(mockDriver.readFile).toHaveBeenCalled(); - }); - - it('should reject empty resource path', () => { - expect(() => - service.readFile({ - ...validResourceIdentifier, - resourcePath: '', - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - - expect(mockDriver.readFile).not.toHaveBeenCalled(); - }); - }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/constants/allowed-extensions-by-application-file-folder.constant.ts b/packages/twenty-server/src/engine/core-modules/file-storage/constants/allowed-extensions-by-application-file-folder.constant.ts new file mode 100644 index 0000000000..5592c7caaf --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/constants/allowed-extensions-by-application-file-folder.constant.ts @@ -0,0 +1,8 @@ +import { FileFolder } from 'twenty-shared/types'; + +export const ALLOWED_EXTENSIONS_BY_APPLICATION_FILE_FOLDER = { + [FileFolder.BuiltLogicFunction]: { '.mjs': true }, + [FileFolder.BuiltFrontComponent]: { '.mjs': true }, + [FileFolder.Source]: { '.ts': true, '.tsx': true, '.json': true }, + [FileFolder.Dependencies]: { '.json': true, '.lock': true }, +} as const satisfies Partial>>; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts index 77c07786b1..85eb6a47a8 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts @@ -9,10 +9,16 @@ import { Like, Repository, type QueryRunner } from 'typeorm'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory'; -import { assertStoragePathIsWithinWorkspace } from 'src/engine/core-modules/file-storage/utils/assert-storage-path-is-within-workspace.util'; -import { assertResourcePathIsSafe } from 'src/engine/core-modules/file-storage/utils/assert-resource-path-is-safe.util'; +import { + FileStorageException, + FileStorageExceptionCode, +} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; +import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util'; +import { validateFolderPath } from 'src/engine/core-modules/file-storage/utils/validate-folder-path.util'; +import { validateStoragePathIsWithinWorkspaceOrThrow } from 'src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-workspace-or-throw.util'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types'; +import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils'; export type ResourceIdentifier = { workspaceId: string; @@ -31,29 +37,84 @@ export class FileStorageService { private readonly applicationRepository: Repository, ) {} - private buildOnStoragePath({ + private buildStoragePathWithinWorkspaceOrThrow({ workspaceId, applicationUniversalIdentifier, fileFolder, - resourcePath, - }: ResourceIdentifier): string { - assertResourcePathIsSafe(resourcePath); + relativePath, + }: { + workspaceId: string; + applicationUniversalIdentifier: string; + fileFolder: FileFolder; + relativePath: string; + }): { onStoragePath: string; resourcePath: string } { + const resourcePath = join(fileFolder, relativePath).replace(/\/+/g, '/'); const onStoragePath = join( workspaceId, applicationUniversalIdentifier, - fileFolder, resourcePath, ).replace(/\/+/g, '/'); - assertStoragePathIsWithinWorkspace({ + validateStoragePathIsWithinWorkspaceOrThrow({ onStoragePath, workspaceId, applicationUniversalIdentifier, fileFolder, }); - return onStoragePath; + return { onStoragePath, resourcePath }; + } + + private validateAndBuildFileStoragePath(params: ResourceIdentifier): { + onStorageFilePath: string; + filePath: string; + } { + const validationResult = validateFilePath({ + resourcePath: params.resourcePath, + fileFolder: params.fileFolder, + }); + + if (!validationResult.isValid) { + throw new FileStorageException( + validationResult.error, + FileStorageExceptionCode.ACCESS_DENIED, + ); + } + + const { onStoragePath, resourcePath } = + this.buildStoragePathWithinWorkspaceOrThrow({ + ...params, + relativePath: params.resourcePath, + }); + + return { onStorageFilePath: onStoragePath, filePath: resourcePath }; + } + + private validateAndBuildFolderStoragePath( + params: Omit & { folderPath: string }, + ): { onStorageFolderPath: string; folderPath: string } { + const validationResult = validateFolderPath({ + folderPath: params.folderPath, + }); + + if (!validationResult.isValid) { + throw new FileStorageException( + validationResult.error, + FileStorageExceptionCode.ACCESS_DENIED, + ); + } + + const { onStoragePath, resourcePath } = + this.buildStoragePathWithinWorkspaceOrThrow({ + ...params, + relativePath: params.folderPath, + }); + + return { + onStorageFolderPath: `${onStoragePath}/`, + folderPath: `${resourcePath}/`, + }; } async writeFile({ @@ -89,22 +150,23 @@ export class FileStorageService { }, }); - const onStoragePath = this.buildOnStoragePath({ - workspaceId, - applicationUniversalIdentifier, - fileFolder, - resourcePath, - }); + const { onStorageFilePath, filePath } = + this.validateAndBuildFileStoragePath({ + workspaceId, + applicationUniversalIdentifier, + fileFolder, + resourcePath, + }); await driver.writeFile({ - filePath: onStoragePath, + filePath: onStorageFilePath, mimeType, sourceFile, }); await fileRepository.upsert( { - path: `${fileFolder}/${resourcePath}`, + path: filePath, workspaceId, applicationId: application.id, id: fileId, @@ -120,7 +182,7 @@ export class FileStorageService { return await fileRepository.findOneOrFail({ where: { - path: `${fileFolder}/${resourcePath}`, + path: filePath, applicationId: application.id, workspaceId, }, @@ -135,10 +197,10 @@ export class FileStorageService { }, ): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); - const onStoragePath = this.buildOnStoragePath(params); + const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params); return driver.getPresignedUrl({ - filePath: onStoragePath, + filePath: onStorageFilePath, expiresInSeconds: params.expiresInSeconds, responseContentType: params.responseContentType, responseContentDisposition: params.responseContentDisposition, @@ -148,19 +210,19 @@ export class FileStorageService { readFile(params: ResourceIdentifier): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); - const onStoragePath = this.buildOnStoragePath(params); + const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params); - return driver.readFile({ filePath: onStoragePath }); + return driver.readFile({ filePath: onStorageFilePath }); } downloadFile( params: ResourceIdentifier & { localPath: string }, ): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); - const onStoragePath = this.buildOnStoragePath(params); + const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params); return driver.downloadFile({ - onStoragePath, + onStoragePath: onStorageFilePath, localPath: params.localPath, }); } @@ -179,17 +241,27 @@ export class FileStorageService { }, }); + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + await driver.delete({ + folderPath: `${workspaceId}/${applicationUniversalIdentifier}/`, + }); + await this.fileRepository.delete({ applicationId: application.id, workspaceId, }); } - async delete(params: ResourceIdentifier): Promise { + async deleteFile(params: ResourceIdentifier): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); - const onStoragePath = this.buildOnStoragePath(params); + const { onStorageFilePath, filePath } = + this.validateAndBuildFileStoragePath(params); - const deleteResult = driver.delete({ folderPath: onStoragePath }); + await driver.delete({ + folderPath: dirname(onStorageFilePath), + filename: basename(onStorageFilePath), + }); const application = await this.applicationRepository.findOneOrFail({ where: { @@ -198,18 +270,47 @@ export class FileStorageService { }, }); - const basePath = `${join(params.fileFolder, params.resourcePath)}`.replace( - /\/+/g, - '/', - ); - await this.fileRepository.delete({ - path: Like(`${basePath}%`), + path: filePath, applicationId: application.id, workspaceId: params.workspaceId, }); + } - return deleteResult; + async deleteFolder( + params: Omit & { folderPath: string }, + ): Promise { + const { + workspaceId, + applicationUniversalIdentifier, + fileFolder, + folderPath, + } = params; + + const { onStorageFolderPath, folderPath: validatedFolderPath } = + this.validateAndBuildFolderStoragePath({ + workspaceId, + applicationUniversalIdentifier, + fileFolder, + folderPath, + }); + + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + await driver.delete({ folderPath: onStorageFolderPath }); + + const application = await this.applicationRepository.findOneOrFail({ + where: { + universalIdentifier: applicationUniversalIdentifier, + workspaceId, + }, + }); + + await this.fileRepository.delete({ + path: Like(`${validatedFolderPath}%`), + applicationId: application.id, + workspaceId, + }); } async deleteByFileId({ @@ -233,14 +334,12 @@ export class FileStorageService { where: { id: file.applicationId, workspaceId: file.workspaceId }, }); - const driver = this.fileStorageDriverFactory.getCurrentDriver(); - - await driver.delete({ - folderPath: `${file.workspaceId}/${application.universalIdentifier}`, - filename: file.path, + await this.deleteFile({ + workspaceId, + applicationUniversalIdentifier: application.universalIdentifier, + fileFolder, + resourcePath: removeFileFolderFromFileEntityPath(file.path), }); - - await this.fileRepository.delete(fileId); } async checkIfWorkspaceFolderExists(workspaceId: string): Promise { @@ -273,8 +372,10 @@ export class FileStorageService { }): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); - const fromPath = this.buildOnStoragePath(from); - const toPath = this.buildOnStoragePath(to); + const { onStorageFilePath: fromPath } = + this.validateAndBuildFileStoragePath(from); + const { onStorageFilePath: toPath } = + this.validateAndBuildFileStoragePath(to); const isFile = await driver.checkFileExists({ filePath: fromPath }); @@ -293,8 +394,8 @@ export class FileStorageService { checkFileExists(params: ResourceIdentifier): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); - const onStoragePath = this.buildOnStoragePath(params); + const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params); - return driver.checkFileExists({ filePath: onStoragePath }); + return driver.checkFileExists({ filePath: onStorageFilePath }); } } diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/types/resource-path-validation-result.type.ts b/packages/twenty-server/src/engine/core-modules/file-storage/types/resource-path-validation-result.type.ts new file mode 100644 index 0000000000..c024fa58af --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/types/resource-path-validation-result.type.ts @@ -0,0 +1,12 @@ +type ResourcePathValidationSuccess = { + isValid: true; +}; + +type ResourcePathValidationFailure = { + isValid: false; + error: string; +}; + +export type ResourcePathValidationResult = + | ResourcePathValidationSuccess + | ResourcePathValidationFailure; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/assert-resource-path-is-safe.util.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/assert-resource-path-is-safe.util.spec.ts deleted file mode 100644 index ba1b727cf2..0000000000 --- a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/assert-resource-path-is-safe.util.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; - -import { assertResourcePathIsSafe } from 'src/engine/core-modules/file-storage/utils/assert-resource-path-is-safe.util'; - -describe('assertResourcePathIsSafe', () => { - it('should accept valid relative paths', () => { - expect(() => - assertResourcePathIsSafe('src/components/test.mjs'), - ).not.toThrow(); - expect(() => assertResourcePathIsSafe('file.mjs')).not.toThrow(); - expect(() => assertResourcePathIsSafe('a/b/c/d.txt')).not.toThrow(); - }); - - it('should reject paths with .. traversal', () => { - expect(() => assertResourcePathIsSafe('../../../other-ws/file.js')).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - }); - - it('should reject absolute paths', () => { - expect(() => assertResourcePathIsSafe('/etc/passwd')).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - }); - - it('should reject paths with null bytes', () => { - expect(() => assertResourcePathIsSafe('file\0.txt')).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - }); - - it('should reject paths with backslashes', () => { - expect(() => assertResourcePathIsSafe('..\\..\\etc\\passwd')).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - }); - - it('should reject empty strings', () => { - expect(() => assertResourcePathIsSafe('')).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/assert-storage-path-is-within-workspace.util.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/assert-storage-path-is-within-workspace.util.spec.ts deleted file mode 100644 index 416879a6f2..0000000000 --- a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/assert-storage-path-is-within-workspace.util.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { FileFolder } from 'twenty-shared/types'; - -import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; - -import { assertStoragePathIsWithinWorkspace } from 'src/engine/core-modules/file-storage/utils/assert-storage-path-is-within-workspace.util'; - -const primitives = { - workspaceId: 'workspace-id', - applicationUniversalIdentifier: 'app-uid', - fileFolder: FileFolder.BuiltFrontComponent, -}; - -describe('assertStoragePathIsWithinWorkspace', () => { - it('should accept paths within the expected prefix', () => { - expect(() => - assertStoragePathIsWithinWorkspace({ - onStoragePath: - 'workspace-id/app-uid/built-front-component/src/component.mjs', - ...primitives, - }), - ).not.toThrow(); - }); - - it('should accept paths directly under the prefix', () => { - expect(() => - assertStoragePathIsWithinWorkspace({ - onStoragePath: 'workspace-id/app-uid/built-front-component/file.mjs', - ...primitives, - }), - ).not.toThrow(); - }); - - it('should reject paths that escape via .. traversal', () => { - expect(() => - assertStoragePathIsWithinWorkspace({ - onStoragePath: - 'other-workspace/other-app/built-front-component/stolen.mjs', - ...primitives, - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - }); - - it('should reject paths that escape by one level', () => { - expect(() => - assertStoragePathIsWithinWorkspace({ - onStoragePath: 'workspace-id/app-uid/other-folder/file.mjs', - ...primitives, - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - }); - - it('should reject the prefix itself without a trailing file', () => { - expect(() => - assertStoragePathIsWithinWorkspace({ - onStoragePath: 'workspace-id/app-uid/built-front-component', - ...primitives, - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - }); - - it('should reject paths where prefix is a partial match', () => { - expect(() => - assertStoragePathIsWithinWorkspace({ - onStoragePath: - 'workspace-id/app-uid/built-front-componentMalicious/file.mjs', - ...primitives, - }), - ).toThrow( - expect.objectContaining({ - code: FileStorageExceptionCode.ACCESS_DENIED, - }), - ); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/has-allowed-extension.util.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/has-allowed-extension.util.spec.ts new file mode 100644 index 0000000000..e78c4093a0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/has-allowed-extension.util.spec.ts @@ -0,0 +1,90 @@ +import { hasAllowedExtension } from 'src/engine/core-modules/file-storage/utils/has-allowed-extension.util'; + +describe('hasAllowedExtension', () => { + const allowedExtensions = { + '.mjs': true, + '.ts': true, + '.tsx': true, + } as const; + + it('should return true for an allowed extension', () => { + expect( + hasAllowedExtension({ filePath: 'index.mjs', allowedExtensions }), + ).toBe(true); + expect( + hasAllowedExtension({ filePath: 'handler.ts', allowedExtensions }), + ).toBe(true); + expect( + hasAllowedExtension({ filePath: 'component.tsx', allowedExtensions }), + ).toBe(true); + }); + + it('should return true for nested paths with allowed extensions', () => { + expect( + hasAllowedExtension({ + filePath: 'src/handlers/index.mjs', + allowedExtensions, + }), + ).toBe(true); + expect( + hasAllowedExtension({ + filePath: 'src/deep/nested/path/file.ts', + allowedExtensions, + }), + ).toBe(true); + }); + + it('should return false for disallowed extensions', () => { + expect( + hasAllowedExtension({ filePath: 'index.js', allowedExtensions }), + ).toBe(false); + expect( + hasAllowedExtension({ filePath: 'index.html', allowedExtensions }), + ).toBe(false); + expect( + hasAllowedExtension({ filePath: 'script.sh', allowedExtensions }), + ).toBe(false); + }); + + it('should return false for files with no extension', () => { + expect( + hasAllowedExtension({ filePath: 'Makefile', allowedExtensions }), + ).toBe(false); + expect(hasAllowedExtension({ filePath: 'README', allowedExtensions })).toBe( + false, + ); + }); + + it('should be case-insensitive', () => { + expect( + hasAllowedExtension({ filePath: 'index.MJS', allowedExtensions }), + ).toBe(true); + expect( + hasAllowedExtension({ filePath: 'handler.TS', allowedExtensions }), + ).toBe(true); + expect( + hasAllowedExtension({ filePath: 'component.TSX', allowedExtensions }), + ).toBe(true); + }); + + it('should use the last extension for double extensions', () => { + expect( + hasAllowedExtension({ + filePath: 'archive.tar.gz', + allowedExtensions: { '.gz': true }, + }), + ).toBe(true); + expect( + hasAllowedExtension({ + filePath: 'archive.tar.gz', + allowedExtensions: { '.tar': true }, + }), + ).toBe(false); + }); + + it('should return false for empty file path', () => { + expect(hasAllowedExtension({ filePath: '', allowedExtensions })).toBe( + false, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-file-extension.util.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-file-extension.util.spec.ts new file mode 100644 index 0000000000..078399cebd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-file-extension.util.spec.ts @@ -0,0 +1,119 @@ +import { FileFolder } from 'twenty-shared/types'; + +import { validateFileExtension } from 'src/engine/core-modules/file-storage/utils/validate-file-extension.util'; + +describe('validateFileExtension', () => { + it.each([ + { + title: 'BuiltLogicFunction with .mjs', + resourcePath: 'src/handlers/index.mjs', + fileFolder: FileFolder.BuiltLogicFunction, + }, + { + title: 'BuiltFrontComponent with .mjs', + resourcePath: 'src/components/card.mjs', + fileFolder: FileFolder.BuiltFrontComponent, + }, + { + title: 'Source with .ts', + resourcePath: 'src/index.ts', + fileFolder: FileFolder.Source, + }, + { + title: 'Source with .tsx', + resourcePath: 'src/app.tsx', + fileFolder: FileFolder.Source, + }, + { + title: 'Dependencies with .json', + resourcePath: 'package.json', + fileFolder: FileFolder.Dependencies, + }, + { + title: 'Dependencies with .lock', + resourcePath: 'yarn.lock', + fileFolder: FileFolder.Dependencies, + }, + ])( + 'should return isValid: true for $title', + ({ resourcePath, fileFolder }) => { + expect(validateFileExtension({ resourcePath, fileFolder })).toEqual({ + isValid: true, + }); + }, + ); + + it.each([ + { + title: 'BuiltLogicFunction with .js', + resourcePath: 'handler.js', + fileFolder: FileFolder.BuiltLogicFunction, + }, + { + title: 'BuiltFrontComponent with .html', + resourcePath: 'component.html', + fileFolder: FileFolder.BuiltFrontComponent, + }, + { + title: 'BuiltLogicFunction with .pdf', + resourcePath: 'handler.pdf', + fileFolder: FileFolder.BuiltLogicFunction, + }, + { + title: 'Source with .mjs', + resourcePath: 'src/index.mjs', + fileFolder: FileFolder.Source, + }, + { + title: 'Dependencies with .sh', + resourcePath: 'install.sh', + fileFolder: FileFolder.Dependencies, + }, + ])( + 'should return isValid: false for $title', + ({ resourcePath, fileFolder }) => { + const result = validateFileExtension({ resourcePath, fileFolder }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('Invalid file extension'); + } + }, + ); + + it.each([ + { + title: 'CorePicture', + resourcePath: 'photo.png', + fileFolder: FileFolder.CorePicture, + }, + { + title: 'FilesField', + resourcePath: 'document.pdf', + fileFolder: FileFolder.FilesField, + }, + { + title: 'PublicAsset with .svg', + resourcePath: 'assets/logo.svg', + fileFolder: FileFolder.PublicAsset, + }, + { + title: 'PublicAsset with .js', + resourcePath: 'assets/script.js', + fileFolder: FileFolder.PublicAsset, + }, + { + title: 'PublicAsset with .exe', + resourcePath: 'downloads/installer.exe', + fileFolder: FileFolder.PublicAsset, + }, + ])( + 'should return isValid: true for unconfigured file folder $title', + ({ resourcePath, fileFolder }) => { + expect(validateFileExtension({ resourcePath, fileFolder })).toEqual({ + isValid: true, + }); + }, + ); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-file-path.util.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-file-path.util.spec.ts new file mode 100644 index 0000000000..4101ab865a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-file-path.util.spec.ts @@ -0,0 +1,105 @@ +import { FileFolder } from 'twenty-shared/types'; + +import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util'; + +describe('validateFilePath', () => { + it.each([ + { + title: 'valid built logic function path', + resourcePath: 'src/handlers/index.mjs', + fileFolder: FileFolder.BuiltLogicFunction, + }, + { + title: 'valid source path', + resourcePath: 'src/index.ts', + fileFolder: FileFolder.Source, + }, + { + title: 'valid public asset path', + resourcePath: 'assets/logo.svg', + fileFolder: FileFolder.PublicAsset, + }, + { + title: 'valid dependencies path', + resourcePath: 'package.json', + fileFolder: FileFolder.Dependencies, + }, + { + title: 'valid unconfigured folder', + resourcePath: 'photo.png', + fileFolder: FileFolder.CorePicture, + }, + ])( + 'should return isValid: true for $title', + ({ resourcePath, fileFolder }) => { + expect(validateFilePath({ resourcePath, fileFolder })).toEqual({ + isValid: true, + }); + }, + ); + + it('should fail on path traversal (safe relative path check)', () => { + const result = validateFilePath({ + resourcePath: '../../../etc/passwd', + fileFolder: FileFolder.BuiltLogicFunction, + }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('path traversal'); + } + }); + + it('should fail on invalid characters (filename integrity check)', () => { + const result = validateFilePath({ + resourcePath: 'my folder/file.mjs', + fileFolder: FileFolder.BuiltLogicFunction, + }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('invalid characters'); + } + }); + + it('should fail on missing extension (filename integrity check)', () => { + const result = validateFilePath({ + resourcePath: 'Makefile', + fileFolder: FileFolder.BuiltLogicFunction, + }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('must have an extension'); + } + }); + + it('should fail on wrong extension (resource extension check)', () => { + const result = validateFilePath({ + resourcePath: 'handler.js', + fileFolder: FileFolder.BuiltLogicFunction, + }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('Invalid file extension'); + } + }); + + it('should short-circuit on the first failure', () => { + const result = validateFilePath({ + resourcePath: '', + fileFolder: FileFolder.BuiltLogicFunction, + }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('must not be empty'); + } + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-folder-path.util.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-folder-path.util.spec.ts new file mode 100644 index 0000000000..41150931b7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-folder-path.util.spec.ts @@ -0,0 +1,112 @@ +import { validateFolderPath } from 'src/engine/core-modules/file-storage/utils/validate-folder-path.util'; + +describe('validateFolderPath', () => { + it.each([ + { title: 'simple folder name', folderPath: 'my-folder' }, + { + title: 'UUID folder', + folderPath: '8b2df3cc-23ad-4e1b-87fd-f880d4cefd58', + }, + { title: 'nested folder path', folderPath: '0000-0999/tmp200/toto' }, + { title: 'dot-prefixed hidden folder', folderPath: '.hidden' }, + ])('should return isValid: true for $title', ({ folderPath }) => { + expect(validateFolderPath({ folderPath })).toEqual({ isValid: true }); + }); + + it('should fail on path traversal', () => { + const result = validateFolderPath({ + folderPath: '../../../other-ws/other-app/folder', + }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('path traversal'); + } + }); + + it('should fail on absolute path', () => { + const result = validateFolderPath({ folderPath: '/etc/secret-folder' }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('must be relative'); + } + }); + + it('should fail on empty folder path', () => { + const result = validateFolderPath({ folderPath: '' }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('must not be empty'); + } + }); + + it('should fail on invalid characters (spaces)', () => { + const result = validateFolderPath({ folderPath: 'my folder/data' }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('invalid characters'); + } + }); + + it('should fail on shell metacharacters', () => { + const result = validateFolderPath({ folderPath: 'folder;rm -rf' }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('invalid characters'); + } + }); + + it.each([ + { title: 'trailing slash', folderPath: 'my-folder/' }, + { title: 'double slashes', folderPath: 'my-folder//sub' }, + ])('should fail on $title', ({ folderPath }) => { + const result = validateFolderPath({ folderPath }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('empty segments or trailing slashes'); + } + }); + + it.each([ + { title: '.mjs extension', folderPath: 'src/logic-functions/handler.mjs' }, + { title: '.ts extension', folderPath: 'src/components/index.ts' }, + { title: '.json extension', folderPath: 'config/settings.json' }, + { title: '.woff2 extension', folderPath: 'fonts/roboto.woff2' }, + { title: '.pdf extension', folderPath: 'docs/report.pdf' }, + { title: '.csv extension', folderPath: 'data/export.csv' }, + { title: 'numeric-only extension (.7z)', folderPath: 'archive.7z' }, + { title: 'dotted version string (v1.0.0)', folderPath: 'v1.0.0' }, + { title: 'numeric extension (.123)', folderPath: 'release.123' }, + ])('should fail on path with file extension ($title)', ({ folderPath }) => { + const result = validateFolderPath({ folderPath }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('file extension'); + } + }); + + it('should short-circuit on the first failure', () => { + const result = validateFolderPath({ + folderPath: '../../../handler.mjs', + }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain('path traversal'); + } + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-safe-relative-path.util.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-safe-relative-path.util.spec.ts new file mode 100644 index 0000000000..72ce255f48 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-safe-relative-path.util.spec.ts @@ -0,0 +1,52 @@ +import { validateSafeRelativePath } from 'src/engine/core-modules/file-storage/utils/validate-safe-relative-path.util'; + +describe('validateSafeRelativePath', () => { + it.each([ + { title: 'nested relative path', resourcePath: 'src/components/test.mjs' }, + { title: 'simple filename', resourcePath: 'file.mjs' }, + { title: 'deeply nested path', resourcePath: 'a/b/c/d.txt' }, + ])('should return isValid: true for $title', ({ resourcePath }) => { + expect(validateSafeRelativePath({ resourcePath })).toEqual({ + isValid: true, + }); + }); + + it.each([ + { + title: 'empty string', + resourcePath: '', + expectedError: 'must not be empty', + }, + { + title: 'null bytes', + resourcePath: 'file\0.txt', + expectedError: 'contains null bytes', + }, + { + title: 'absolute path', + resourcePath: '/etc/passwd', + expectedError: 'must be relative', + }, + { + title: 'backslashes', + resourcePath: '..\\..\\etc\\passwd', + expectedError: 'must not contain backslashes', + }, + { + title: '.. traversal', + resourcePath: '../../../other-ws/file.js', + expectedError: 'path traversal', + }, + ])( + 'should return isValid: false for $title', + ({ resourcePath, expectedError }) => { + const result = validateSafeRelativePath({ resourcePath }); + + expect(result.isValid).toBe(false); + + if (!result.isValid) { + expect(result.error).toContain(expectedError); + } + }, + ); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-storage-path-is-within-workspace-or-throw.util.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-storage-path-is-within-workspace-or-throw.util.spec.ts new file mode 100644 index 0000000000..191811f7db --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-storage-path-is-within-workspace-or-throw.util.spec.ts @@ -0,0 +1,67 @@ +import { FileFolder } from 'twenty-shared/types'; + +import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; + +import { validateStoragePathIsWithinWorkspaceOrThrow } from 'src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-workspace-or-throw.util'; + +const primitives = { + workspaceId: 'workspace-id', + applicationUniversalIdentifier: 'app-uid', + fileFolder: FileFolder.BuiltFrontComponent, +}; + +describe('validateStoragePathIsWithinWorkspaceOrThrow', () => { + it.each([ + { + title: 'nested path within prefix', + onStoragePath: + 'workspace-id/app-uid/built-front-component/src/component.mjs', + }, + { + title: 'file directly under prefix', + onStoragePath: 'workspace-id/app-uid/built-front-component/file.mjs', + }, + ])('should accept valid path: $title', ({ onStoragePath }) => { + expect(() => + validateStoragePathIsWithinWorkspaceOrThrow({ + onStoragePath, + ...primitives, + }), + ).not.toThrow(); + }); + + it.each([ + { + title: 'different workspace and app', + onStoragePath: + 'other-workspace/other-app/built-front-component/stolen.mjs', + }, + { + title: 'different file folder', + onStoragePath: 'workspace-id/app-uid/other-folder/file.mjs', + }, + { + title: 'prefix without trailing file', + onStoragePath: 'workspace-id/app-uid/built-front-component', + }, + { + title: 'partial prefix match (malicious suffix)', + onStoragePath: + 'workspace-id/app-uid/built-front-componentMalicious/file.mjs', + }, + ])( + 'should reject path that escapes workspace: $title', + ({ onStoragePath }) => { + expect(() => + validateStoragePathIsWithinWorkspaceOrThrow({ + onStoragePath, + ...primitives, + }), + ).toThrow( + expect.objectContaining({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }), + ); + }, + ); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/assert-resource-path-is-safe.util.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/assert-resource-path-is-safe.util.ts deleted file mode 100644 index f891fe12b2..0000000000 --- a/packages/twenty-server/src/engine/core-modules/file-storage/utils/assert-resource-path-is-safe.util.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - FileStorageException, - FileStorageExceptionCode, -} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; -import { isSafeRelativePath } from 'src/engine/core-modules/file-storage/utils/is-safe-relative-path.util'; - -export const assertResourcePathIsSafe = (resourcePath: string): void => { - if (!isSafeRelativePath(resourcePath)) { - throw new FileStorageException( - 'Invalid resource path: contains unsafe characters or path traversal', - FileStorageExceptionCode.ACCESS_DENIED, - ); - } -}; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/has-allowed-extension.util.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/has-allowed-extension.util.ts new file mode 100644 index 0000000000..7bfec351da --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/has-allowed-extension.util.ts @@ -0,0 +1,13 @@ +import { extname } from 'path'; + +export const hasAllowedExtension = ({ + filePath, + allowedExtensions, +}: { + filePath: string; + allowedExtensions: Readonly>; +}): boolean => { + const ext = extname(filePath).toLowerCase(); + + return allowedExtensions[ext] === true; +}; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-extension.util.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-extension.util.ts new file mode 100644 index 0000000000..b3a2977834 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-extension.util.ts @@ -0,0 +1,37 @@ +import { t } from '@lingui/core/macro'; +import { type FileFolder } from 'twenty-shared/types'; + +import { ALLOWED_EXTENSIONS_BY_APPLICATION_FILE_FOLDER } from 'src/engine/core-modules/file-storage/constants/allowed-extensions-by-application-file-folder.constant'; +import { type ResourcePathValidationResult } from 'src/engine/core-modules/file-storage/types/resource-path-validation-result.type'; +import { hasAllowedExtension } from 'src/engine/core-modules/file-storage/utils/has-allowed-extension.util'; + +export const validateFileExtension = ({ + resourcePath, + fileFolder, +}: { + resourcePath: string; + fileFolder: FileFolder; +}): ResourcePathValidationResult => { + const allowedExtensions = + ALLOWED_EXTENSIONS_BY_APPLICATION_FILE_FOLDER[ + fileFolder as keyof typeof ALLOWED_EXTENSIONS_BY_APPLICATION_FILE_FOLDER + ]; + + if (!allowedExtensions) { + return { isValid: true }; + } + + if ( + !hasAllowedExtension({ + filePath: resourcePath, + allowedExtensions, + }) + ) { + return { + isValid: false, + error: t`Invalid file extension. Allowed extensions: ${Object.keys(allowedExtensions).join(', ')}`, + }; + } + + return { isValid: true }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-path.util.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-path.util.ts new file mode 100644 index 0000000000..12b4bc1b43 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-path.util.ts @@ -0,0 +1,39 @@ +import { t } from '@lingui/core/macro'; +import { type FileFolder } from 'twenty-shared/types'; + +import { type ResourcePathValidationResult } from 'src/engine/core-modules/file-storage/types/resource-path-validation-result.type'; +import { validateFileExtension } from 'src/engine/core-modules/file-storage/utils/validate-file-extension.util'; +import { validatePathSegmentsSafety } from 'src/engine/core-modules/file-storage/utils/validate-path-segments-safety.util'; +import { validateSafeRelativePath } from 'src/engine/core-modules/file-storage/utils/validate-safe-relative-path.util'; + +export const validateFilePath = ({ + resourcePath, + fileFolder, +}: { + resourcePath: string; + fileFolder: FileFolder; +}): ResourcePathValidationResult => { + const safePathResult = validateSafeRelativePath({ resourcePath }); + + if (!safePathResult.isValid) { + return safePathResult; + } + + const segmentsSafetyResult = validatePathSegmentsSafety({ resourcePath }); + + if (!segmentsSafetyResult.isValid) { + return segmentsSafetyResult; + } + + const segments = resourcePath.split('/'); + const filename = segments[segments.length - 1]; + + if (!filename.includes('.')) { + return { + isValid: false, + error: t`Filename must have an extension`, + }; + } + + return validateFileExtension({ resourcePath, fileFolder }); +}; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-folder-path.util.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-folder-path.util.ts new file mode 100644 index 0000000000..857f0de472 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-folder-path.util.ts @@ -0,0 +1,40 @@ +import { extname } from 'path'; + +import { t } from '@lingui/core/macro'; + +import { type ResourcePathValidationResult } from 'src/engine/core-modules/file-storage/types/resource-path-validation-result.type'; +import { validatePathSegmentsSafety } from 'src/engine/core-modules/file-storage/utils/validate-path-segments-safety.util'; +import { validateSafeRelativePath } from 'src/engine/core-modules/file-storage/utils/validate-safe-relative-path.util'; + +export const validateFolderPath = ({ + folderPath, +}: { + folderPath: string; +}): ResourcePathValidationResult => { + const safePathResult = validateSafeRelativePath({ + resourcePath: folderPath, + }); + + if (!safePathResult.isValid) { + return safePathResult; + } + + const segmentsSafetyResult = validatePathSegmentsSafety({ + resourcePath: folderPath, + }); + + if (!segmentsSafetyResult.isValid) { + return segmentsSafetyResult; + } + + const extension = extname(folderPath); + + if (extension.length > 0) { + return { + isValid: false, + error: t`Folder path must not contain a file extension — use deleteFile for file paths`, + }; + } + + return { isValid: true }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-path-segments-safety.util.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-path-segments-safety.util.ts new file mode 100644 index 0000000000..20b6ea7598 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-path-segments-safety.util.ts @@ -0,0 +1,47 @@ +import { t } from '@lingui/core/macro'; + +import { type ResourcePathValidationResult } from 'src/engine/core-modules/file-storage/types/resource-path-validation-result.type'; + +const MAX_SEGMENT_LENGTH = 255; +const MAX_PATH_LENGTH = 1024; +const SAFE_SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/; + +export const validatePathSegmentsSafety = ({ + resourcePath, +}: { + resourcePath: string; +}): ResourcePathValidationResult => { + if (resourcePath.length > MAX_PATH_LENGTH) { + return { + isValid: false, + error: t`Resource path exceeds maximum length of ${MAX_PATH_LENGTH} characters`, + }; + } + + if (resourcePath.includes('//') || resourcePath.endsWith('/')) { + return { + isValid: false, + error: t`Resource path must not contain empty segments or trailing slashes`, + }; + } + + const segments = resourcePath.split('/'); + + for (const segment of segments) { + if (segment.length > MAX_SEGMENT_LENGTH) { + return { + isValid: false, + error: t`A path segment exceeds the maximum length of 255 characters`, + }; + } + + if (!SAFE_SEGMENT_PATTERN.test(segment)) { + return { + isValid: false, + error: t`A path segment contains invalid characters. Only alphanumeric, dots, dashes and underscores are allowed`, + }; + } + } + + return { isValid: true }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-safe-relative-path.util.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-safe-relative-path.util.ts new file mode 100644 index 0000000000..51561f1c2b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-safe-relative-path.util.ts @@ -0,0 +1,44 @@ +import { isAbsolute, normalize, sep } from 'path'; + +import { t } from '@lingui/core/macro'; + +import { type ResourcePathValidationResult } from 'src/engine/core-modules/file-storage/types/resource-path-validation-result.type'; + +export const validateSafeRelativePath = ({ + resourcePath, +}: { + resourcePath: string; +}): ResourcePathValidationResult => { + if (resourcePath.length === 0) { + return { isValid: false, error: t`Resource path must not be empty` }; + } + + if (resourcePath.includes('\0')) { + return { isValid: false, error: t`Resource path contains null bytes` }; + } + + if (isAbsolute(resourcePath)) { + return { + isValid: false, + error: t`Resource path must be relative, not absolute`, + }; + } + + if (resourcePath.includes('\\')) { + return { + isValid: false, + error: t`Resource path must not contain backslashes`, + }; + } + + const normalized = normalize(resourcePath); + + if (normalized.split(sep).includes('..')) { + return { + isValid: false, + error: t`Resource path must not contain path traversal (..)`, + }; + } + + return { isValid: true }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/assert-storage-path-is-within-workspace.util.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-workspace-or-throw.util.ts similarity index 93% rename from packages/twenty-server/src/engine/core-modules/file-storage/utils/assert-storage-path-is-within-workspace.util.ts rename to packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-workspace-or-throw.util.ts index 55161b996c..66c062d87b 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/utils/assert-storage-path-is-within-workspace.util.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-workspace-or-throw.util.ts @@ -7,7 +7,7 @@ import { FileStorageExceptionCode, } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; -export const assertStoragePathIsWithinWorkspace = ({ +export const validateStoragePathIsWithinWorkspaceOrThrow = ({ onStoragePath, workspaceId, applicationUniversalIdentifier, diff --git a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts index 9ea12f9a20..69dab2e231 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts @@ -189,7 +189,7 @@ export class FileCorePictureService { const customApplicationUniversalIdentifier = await this.findCustomApplicationUniversalIdentifier(workspaceId); - await this.fileStorageService.delete({ + await this.fileStorageService.deleteFile({ workspaceId, applicationUniversalIdentifier: customApplicationUniversalIdentifier, fileFolder: FileFolder.CorePicture, diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service.ts index ec8eb90fd7..31b03d6a82 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service.ts @@ -149,7 +149,7 @@ export class LogicFunctionResourceService { workspaceId, applicationUniversalIdentifier, }: GetSourceCodeParams): Promise { - await this.fileStorageService.delete({ + await this.fileStorageService.deleteFile({ workspaceId, applicationUniversalIdentifier, fileFolder: FileFolder.Source, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service.ts index 062992dd51..6712275364 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service.ts @@ -3,9 +3,10 @@ import { Injectable } from '@nestjs/common'; import { msg, t } from '@lingui/core/macro'; import { isNonEmptyString } from '@sniptt/guards'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { isSafeRelativePath } from 'src/engine/core-modules/file-storage/utils/is-safe-relative-path.util'; +import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; import { FrontComponentExceptionCode } from 'src/engine/metadata-modules/front-component/front-component.exception'; import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; @@ -37,26 +38,34 @@ export class FlatFrontComponentValidatorService { }); } - if ( - isDefined(flatFrontComponent.builtComponentPath) && - !isSafeRelativePath(flatFrontComponent.builtComponentPath) - ) { - validationResult.errors.push({ - code: FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT, - message: t`Built component path contains unsafe characters`, - userFriendlyMessage: msg`Built component path contains unsafe characters`, + if (isDefined(flatFrontComponent.builtComponentPath)) { + const builtPathResult = validateFilePath({ + resourcePath: flatFrontComponent.builtComponentPath, + fileFolder: FileFolder.BuiltFrontComponent, }); + + if (!builtPathResult.isValid) { + validationResult.errors.push({ + code: FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT, + message: builtPathResult.error, + userFriendlyMessage: msg`Built component path is invalid`, + }); + } } - if ( - isDefined(flatFrontComponent.sourceComponentPath) && - !isSafeRelativePath(flatFrontComponent.sourceComponentPath) - ) { - validationResult.errors.push({ - code: FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT, - message: t`Source component path contains unsafe characters`, - userFriendlyMessage: msg`Source component path contains unsafe characters`, + if (isDefined(flatFrontComponent.sourceComponentPath)) { + const sourcePathResult = validateFilePath({ + resourcePath: flatFrontComponent.sourceComponentPath, + fileFolder: FileFolder.Source, }); + + if (!sourcePathResult.isValid) { + validationResult.errors.push({ + code: FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT, + message: sourcePathResult.error, + userFriendlyMessage: msg`Source component path is invalid`, + }); + } } return validationResult; @@ -129,26 +138,34 @@ export class FlatFrontComponentValidatorService { return validationResult; } - if ( - isDefined(flatEntityUpdate.builtComponentPath) && - !isSafeRelativePath(flatEntityUpdate.builtComponentPath) - ) { - validationResult.errors.push({ - code: FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT, - message: t`Built component path contains unsafe characters`, - userFriendlyMessage: msg`Built component path contains unsafe characters`, + if (isDefined(flatEntityUpdate.builtComponentPath)) { + const builtPathResult = validateFilePath({ + resourcePath: flatEntityUpdate.builtComponentPath, + fileFolder: FileFolder.BuiltFrontComponent, }); + + if (!builtPathResult.isValid) { + validationResult.errors.push({ + code: FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT, + message: builtPathResult.error, + userFriendlyMessage: msg`Built component path is invalid`, + }); + } } - if ( - isDefined(flatEntityUpdate.sourceComponentPath) && - !isSafeRelativePath(flatEntityUpdate.sourceComponentPath) - ) { - validationResult.errors.push({ - code: FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT, - message: t`Source component path contains unsafe characters`, - userFriendlyMessage: msg`Source component path contains unsafe characters`, + if (isDefined(flatEntityUpdate.sourceComponentPath)) { + const sourcePathResult = validateFilePath({ + resourcePath: flatEntityUpdate.sourceComponentPath, + fileFolder: FileFolder.Source, }); + + if (!sourcePathResult.isValid) { + validationResult.errors.push({ + code: FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT, + message: sourcePathResult.error, + userFriendlyMessage: msg`Source component path is invalid`, + }); + } } return validationResult; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-logic-function-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-logic-function-validator.service.ts index da5d99bad6..e91cf0a955 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-logic-function-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-logic-function-validator.service.ts @@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common'; import { msg, t } from '@lingui/core/macro'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { isSafeRelativePath } from 'src/engine/core-modules/file-storage/utils/is-safe-relative-path.util'; +import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; import { LogicFunctionExceptionCode } from 'src/engine/metadata-modules/logic-function/logic-function.exception'; import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; @@ -48,26 +49,34 @@ export class FlatLogicFunctionValidatorService { return validationResult; } - if ( - isDefined(flatEntityUpdate.builtHandlerPath) && - !isSafeRelativePath(flatEntityUpdate.builtHandlerPath) - ) { - validationResult.errors.push({ - code: LogicFunctionExceptionCode.INVALID_LOGIC_FUNCTION_INPUT, - message: t`Built handler path contains unsafe characters`, - userFriendlyMessage: msg`Built handler path contains unsafe characters`, + if (isDefined(flatEntityUpdate.builtHandlerPath)) { + const builtPathResult = validateFilePath({ + resourcePath: flatEntityUpdate.builtHandlerPath, + fileFolder: FileFolder.BuiltLogicFunction, }); + + if (!builtPathResult.isValid) { + validationResult.errors.push({ + code: LogicFunctionExceptionCode.INVALID_LOGIC_FUNCTION_INPUT, + message: builtPathResult.error, + userFriendlyMessage: msg`Built handler path is invalid`, + }); + } } - if ( - isDefined(flatEntityUpdate.sourceHandlerPath) && - !isSafeRelativePath(flatEntityUpdate.sourceHandlerPath) - ) { - validationResult.errors.push({ - code: LogicFunctionExceptionCode.INVALID_LOGIC_FUNCTION_INPUT, - message: t`Source handler path contains unsafe characters`, - userFriendlyMessage: msg`Source handler path contains unsafe characters`, + if (isDefined(flatEntityUpdate.sourceHandlerPath)) { + const sourcePathResult = validateFilePath({ + resourcePath: flatEntityUpdate.sourceHandlerPath, + fileFolder: FileFolder.Source, }); + + if (!sourcePathResult.isValid) { + validationResult.errors.push({ + code: LogicFunctionExceptionCode.INVALID_LOGIC_FUNCTION_INPUT, + message: sourcePathResult.error, + userFriendlyMessage: msg`Source handler path is invalid`, + }); + } } return validationResult; @@ -136,26 +145,34 @@ export class FlatLogicFunctionValidatorService { }); } - if ( - isDefined(flatLogicFunctionToValidate.builtHandlerPath) && - !isSafeRelativePath(flatLogicFunctionToValidate.builtHandlerPath) - ) { - validationResult.errors.push({ - code: LogicFunctionExceptionCode.INVALID_LOGIC_FUNCTION_INPUT, - message: t`Built handler path contains unsafe characters`, - userFriendlyMessage: msg`Built handler path contains unsafe characters`, + if (isDefined(flatLogicFunctionToValidate.builtHandlerPath)) { + const builtPathResult = validateFilePath({ + resourcePath: flatLogicFunctionToValidate.builtHandlerPath, + fileFolder: FileFolder.BuiltLogicFunction, }); + + if (!builtPathResult.isValid) { + validationResult.errors.push({ + code: LogicFunctionExceptionCode.INVALID_LOGIC_FUNCTION_INPUT, + message: builtPathResult.error, + userFriendlyMessage: msg`Built handler path is invalid`, + }); + } } - if ( - isDefined(flatLogicFunctionToValidate.sourceHandlerPath) && - !isSafeRelativePath(flatLogicFunctionToValidate.sourceHandlerPath) - ) { - validationResult.errors.push({ - code: LogicFunctionExceptionCode.INVALID_LOGIC_FUNCTION_INPUT, - message: t`Source handler path contains unsafe characters`, - userFriendlyMessage: msg`Source handler path contains unsafe characters`, + if (isDefined(flatLogicFunctionToValidate.sourceHandlerPath)) { + const sourcePathResult = validateFilePath({ + resourcePath: flatLogicFunctionToValidate.sourceHandlerPath, + fileFolder: FileFolder.Source, }); + + if (!sourcePathResult.isValid) { + validationResult.errors.push({ + code: LogicFunctionExceptionCode.INVALID_LOGIC_FUNCTION_INPUT, + message: sourcePathResult.error, + userFriendlyMessage: msg`Source handler path is invalid`, + }); + } } return validationResult; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts index b5215d256d..b5365f475c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts @@ -60,16 +60,14 @@ export class DeleteLogicFunctionActionHandlerService extends WorkspaceMigrationR const applicationUniversalIdentifier = flatApplication.universalIdentifier; - await this.fileStorageService.delete({ + await this.fileStorageService.deleteFolder({ workspaceId, applicationUniversalIdentifier, fileFolder: FileFolder.Source, - resourcePath: getLogicFunctionSubfolderForFromSource( - flatLogicFunction.id, - ), + folderPath: getLogicFunctionSubfolderForFromSource(flatLogicFunction.id), }); - await this.fileStorageService.delete({ + await this.fileStorageService.deleteFile({ workspaceId, applicationUniversalIdentifier, fileFolder: FileFolder.BuiltLogicFunction, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts index 57ca6b8a6d..25ced4363a 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts @@ -86,7 +86,7 @@ export class UpdateLogicFunctionActionHandlerService extends WorkspaceMigrationR ); if (builtPathChanged) { - await this.fileStorageService.delete({ + await this.fileStorageService.deleteFile({ workspaceId, applicationUniversalIdentifier, fileFolder: FileFolder.BuiltLogicFunction, diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-upload-application-file-path-traversal.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-upload-application-file-path-traversal.integration-spec.ts.snap index 8914852ea4..cd8aa954c5 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-upload-application-file-path-traversal.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-upload-application-file-path-traversal.integration-spec.ts.snap @@ -68,6 +68,78 @@ exports[`Upload application file should fail when filePath contains upward trave } `; +exports[`Upload application file should fail when filePath has an invalid extension for BuiltFrontComponent (.js instead of .mjs) 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_INPUT", + "userFriendlyMessage": "Invalid input provided.", + }, + "message": "Invalid file extension. Allowed extensions: .mjs", + "name": "UserInputError", +} +`; + +exports[`Upload application file should fail when filePath has an invalid extension for BuiltLogicFunction (.html) 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_INPUT", + "userFriendlyMessage": "Invalid input provided.", + }, + "message": "Invalid file extension. Allowed extensions: .mjs", + "name": "UserInputError", +} +`; + +exports[`Upload application file should fail when filePath has an invalid extension for Dependencies (.sh instead of .json/.lock) 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_INPUT", + "userFriendlyMessage": "Invalid input provided.", + }, + "message": "Invalid file extension. Allowed extensions: .json, .lock", + "name": "UserInputError", +} +`; + +exports[`Upload application file should fail when filePath has an invalid extension for Source (.js instead of .ts) 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_INPUT", + "userFriendlyMessage": "Invalid input provided.", + }, + "message": "Invalid file extension. Allowed extensions: .ts, .tsx, .json", + "name": "UserInputError", +} +`; + +exports[`Upload application file should fail when filePath is a folder path without extension (bare UUID) 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_INPUT", + "userFriendlyMessage": "Invalid input provided.", + }, + "message": "Filename must have an extension", + "name": "UserInputError", +} +`; + +exports[`Upload application file should fail when filePath is a nested folder path without extension 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_INPUT", + "userFriendlyMessage": "Invalid input provided.", + }, + "message": "Filename must have an extension", + "name": "UserInputError", +} +`; + exports[`Upload application file should fail when filePath is an absolute path 1`] = ` { "extensions": { diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-upload-application-file-path-traversal.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-upload-application-file-path-traversal.integration-spec.ts index d42533347f..3accc36662 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/failing-upload-application-file-path-traversal.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-upload-application-file-path-traversal.integration-spec.ts @@ -84,6 +84,59 @@ const FAILING_TEST_CASES: EachTestingContext[] = [ filePath: 'src/components/legit.mjs', }, }, + { + title: + 'when filePath is a folder path without extension (bare UUID)', + context: { + applicationUniversalIdentifier: TEST_APP_ID, + fileFolder: 'BuiltFrontComponent', + filePath: '8b2df3cc-23ad-4e1b-87fd-f880d4cefd58', + }, + }, + { + title: + 'when filePath is a nested folder path without extension', + context: { + applicationUniversalIdentifier: TEST_APP_ID, + fileFolder: 'Source', + filePath: 'src/logic-functions/my-handler', + }, + }, + { + title: + 'when filePath has an invalid extension for BuiltFrontComponent (.js instead of .mjs)', + context: { + applicationUniversalIdentifier: TEST_APP_ID, + fileFolder: 'BuiltFrontComponent', + filePath: 'src/components/component.js', + }, + }, + { + title: + 'when filePath has an invalid extension for BuiltLogicFunction (.html)', + context: { + applicationUniversalIdentifier: TEST_APP_ID, + fileFolder: 'BuiltLogicFunction', + filePath: 'src/handlers/handler.html', + }, + }, + { + title: 'when filePath has an invalid extension for Source (.js instead of .ts)', + context: { + applicationUniversalIdentifier: TEST_APP_ID, + fileFolder: 'Source', + filePath: 'src/index.js', + }, + }, + { + title: + 'when filePath has an invalid extension for Dependencies (.sh instead of .json/.lock)', + context: { + applicationUniversalIdentifier: TEST_APP_ID, + fileFolder: 'Dependencies', + filePath: 'install.sh', + }, + }, ]; describe('Upload application file should fail', () => { diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap index f737c6b5c8..503386c31d 100644 --- a/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap @@ -10,8 +10,8 @@ exports[`Front component creation should fail when builtComponentPath contains p "errors": [ { "code": "INVALID_FRONT_COMPONENT_INPUT", - "message": "Built component path contains unsafe characters", - "userFriendlyMessage": "Built component path contains unsafe characters", + "message": "Resource path must not contain path traversal (..)", + "userFriendlyMessage": "Built component path is invalid", }, ], "flatEntityMinimalInformation": { @@ -29,7 +29,115 @@ exports[`Front component creation should fail when builtComponentPath contains p "frontComponent": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Built component path contains unsafe characters", + "userFriendlyMessage": "Built component path is invalid", + }, + "message": "Multiple validation errors occurred while creating front component", + "name": "GraphQLError", +} +`; + +exports[`Front component creation should fail when builtComponentPath has a completely unrelated extension (.pdf) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Invalid file extension. Allowed extensions: .mjs", + "userFriendlyMessage": "Built component path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "name": "PdfExtTest", + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built component path is invalid", + }, + "message": "Multiple validation errors occurred while creating front component", + "name": "GraphQLError", +} +`; + +exports[`Front component creation should fail when builtComponentPath has an invalid extension (.js instead of .mjs) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Invalid file extension. Allowed extensions: .mjs", + "userFriendlyMessage": "Built component path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "name": "InvalidExtTest", + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built component path is invalid", + }, + "message": "Multiple validation errors occurred while creating front component", + "name": "GraphQLError", +} +`; + +exports[`Front component creation should fail when builtComponentPath is a folder path without extension (bare UUID) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Filename must have an extension", + "userFriendlyMessage": "Built component path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "name": "FolderPathTest", + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built component path is invalid", }, "message": "Multiple validation errors occurred while creating front component", "name": "GraphQLError", @@ -46,8 +154,8 @@ exports[`Front component creation should fail when builtComponentPath is an abso "errors": [ { "code": "INVALID_FRONT_COMPONENT_INPUT", - "message": "Built component path contains unsafe characters", - "userFriendlyMessage": "Built component path contains unsafe characters", + "message": "Resource path must be relative, not absolute", + "userFriendlyMessage": "Built component path is invalid", }, ], "flatEntityMinimalInformation": { @@ -65,7 +173,7 @@ exports[`Front component creation should fail when builtComponentPath is an abso "frontComponent": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Built component path contains unsafe characters", + "userFriendlyMessage": "Built component path is invalid", }, "message": "Multiple validation errors occurred while creating front component", "name": "GraphQLError", @@ -154,8 +262,8 @@ exports[`Front component creation should fail when sourceComponentPath contains "errors": [ { "code": "INVALID_FRONT_COMPONENT_INPUT", - "message": "Source component path contains unsafe characters", - "userFriendlyMessage": "Source component path contains unsafe characters", + "message": "Resource path must not contain path traversal (..)", + "userFriendlyMessage": "Source component path is invalid", }, ], "flatEntityMinimalInformation": { @@ -173,7 +281,79 @@ exports[`Front component creation should fail when sourceComponentPath contains "frontComponent": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Source component path contains unsafe characters", + "userFriendlyMessage": "Source component path is invalid", + }, + "message": "Multiple validation errors occurred while creating front component", + "name": "GraphQLError", +} +`; + +exports[`Front component creation should fail when sourceComponentPath has an invalid extension (.mjs instead of .ts/.tsx) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Invalid file extension. Allowed extensions: .ts, .tsx, .json", + "userFriendlyMessage": "Source component path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "name": "InvalidSourceExtTest", + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Source component path is invalid", + }, + "message": "Multiple validation errors occurred while creating front component", + "name": "GraphQLError", +} +`; + +exports[`Front component creation should fail when sourceComponentPath is a folder path without extension 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Filename must have an extension", + "userFriendlyMessage": "Source component path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "name": "FolderSourcePathTest", + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Source component path is invalid", }, "message": "Multiple validation errors occurred while creating front component", "name": "GraphQLError", diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-update-path-traversal.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-update-path-traversal.integration-spec.ts.snap index b4c9d8e545..18b0b80a52 100644 --- a/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-update-path-traversal.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-update-path-traversal.integration-spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Front component update via manifest sync should fail for path traversal when builtComponentPath is updated with absolute path via manifest sync 1`] = ` +exports[`Front component update via manifest sync should fail for path traversal when builtComponentPath is a folder path without extension (bare UUID) 1`] = ` { "extensions": { "code": "METADATA_VALIDATION_FAILED", @@ -10,8 +10,8 @@ exports[`Front component update via manifest sync should fail for path traversal "errors": [ { "code": "INVALID_FRONT_COMPONENT_INPUT", - "message": "Built component path contains unsafe characters", - "userFriendlyMessage": "Built component path contains unsafe characters", + "message": "Filename must have an extension", + "userFriendlyMessage": "Built component path is invalid", }, ], "flatEntityMinimalInformation": { @@ -28,7 +28,77 @@ exports[`Front component update via manifest sync should fail for path traversal "frontComponent": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Built component path contains unsafe characters", + "userFriendlyMessage": "Built component path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Front component update via manifest sync should fail for path traversal when builtComponentPath is updated with a completely unrelated extension (.pdf) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Invalid file extension. Allowed extensions: .mjs", + "userFriendlyMessage": "Built component path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built component path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Front component update via manifest sync should fail for path traversal when builtComponentPath is updated with absolute path via manifest sync 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Resource path must be relative, not absolute", + "userFriendlyMessage": "Built component path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built component path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", @@ -45,8 +115,8 @@ exports[`Front component update via manifest sync should fail for path traversal "errors": [ { "code": "INVALID_FRONT_COMPONENT_INPUT", - "message": "Built component path contains unsafe characters", - "userFriendlyMessage": "Built component path contains unsafe characters", + "message": "Resource path must not contain backslashes", + "userFriendlyMessage": "Built component path is invalid", }, ], "flatEntityMinimalInformation": { @@ -63,7 +133,42 @@ exports[`Front component update via manifest sync should fail for path traversal "frontComponent": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Built component path contains unsafe characters", + "userFriendlyMessage": "Built component path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Front component update via manifest sync should fail for path traversal when builtComponentPath is updated with invalid extension (.js instead of .mjs) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Invalid file extension. Allowed extensions: .mjs", + "userFriendlyMessage": "Built component path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built component path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", @@ -80,8 +185,8 @@ exports[`Front component update via manifest sync should fail for path traversal "errors": [ { "code": "INVALID_FRONT_COMPONENT_INPUT", - "message": "Built component path contains unsafe characters", - "userFriendlyMessage": "Built component path contains unsafe characters", + "message": "Resource path must not contain path traversal (..)", + "userFriendlyMessage": "Built component path is invalid", }, ], "flatEntityMinimalInformation": { @@ -98,7 +203,77 @@ exports[`Front component update via manifest sync should fail for path traversal "frontComponent": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Built component path contains unsafe characters", + "userFriendlyMessage": "Built component path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Front component update via manifest sync should fail for path traversal when sourceComponentPath is a folder path without extension 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Filename must have an extension", + "userFriendlyMessage": "Source component path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Source component path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Front component update via manifest sync should fail for path traversal when sourceComponentPath is updated with invalid extension (.mjs instead of .ts/.tsx) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "frontComponent": [ + { + "errors": [ + { + "code": "INVALID_FRONT_COMPONENT_INPUT", + "message": "Invalid file extension. Allowed extensions: .ts, .tsx, .json", + "userFriendlyMessage": "Source component path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "frontComponent", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 frontComponent", + "summary": { + "frontComponent": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Source component path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", @@ -115,8 +290,8 @@ exports[`Front component update via manifest sync should fail for path traversal "errors": [ { "code": "INVALID_FRONT_COMPONENT_INPUT", - "message": "Source component path contains unsafe characters", - "userFriendlyMessage": "Source component path contains unsafe characters", + "message": "Resource path must not contain path traversal (..)", + "userFriendlyMessage": "Source component path is invalid", }, ], "flatEntityMinimalInformation": { @@ -133,7 +308,7 @@ exports[`Front component update via manifest sync should fail for path traversal "frontComponent": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Source component path contains unsafe characters", + "userFriendlyMessage": "Source component path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts index 7c4e401e32..3e33c9e57b 100644 --- a/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts @@ -74,6 +74,71 @@ const FAILING_TEST_CASES: EachTestingContext[] = [ }, }, }, + { + title: + 'when builtComponentPath is a folder path without extension (bare UUID)', + context: { + input: { + name: 'FolderPathTest', + componentName: 'FolderPathTest', + sourceComponentPath: 'src/front-components/index.tsx', + builtComponentPath: '8b2df3cc-23ad-4e1b-87fd-f880d4cefd58', + builtComponentChecksum: 'abc123', + }, + }, + }, + { + title: + 'when sourceComponentPath is a folder path without extension', + context: { + input: { + name: 'FolderSourcePathTest', + componentName: 'FolderSourcePathTest', + sourceComponentPath: 'src/front-components/my-component', + builtComponentPath: 'src/front-components/index.mjs', + builtComponentChecksum: 'abc123', + }, + }, + }, + { + title: + 'when builtComponentPath has an invalid extension (.js instead of .mjs)', + context: { + input: { + name: 'InvalidExtTest', + componentName: 'InvalidExtTest', + sourceComponentPath: 'src/front-components/index.tsx', + builtComponentPath: 'src/front-components/index.js', + builtComponentChecksum: 'abc123', + }, + }, + }, + { + title: + 'when sourceComponentPath has an invalid extension (.mjs instead of .ts/.tsx)', + context: { + input: { + name: 'InvalidSourceExtTest', + componentName: 'InvalidSourceExtTest', + sourceComponentPath: 'src/front-components/index.mjs', + builtComponentPath: 'src/front-components/index.mjs', + builtComponentChecksum: 'abc123', + }, + }, + }, + { + title: + 'when builtComponentPath has a completely unrelated extension (.pdf)', + context: { + input: { + name: 'PdfExtTest', + componentName: 'PdfExtTest', + sourceComponentPath: 'src/front-components/index.tsx', + builtComponentPath: 'src/front-components/index.pdf', + builtComponentChecksum: 'abc123', + }, + }, + }, ]; describe('Front component creation should fail', () => { diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-update-path-traversal.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-update-path-traversal.integration-spec.ts index 6f332129eb..556882210f 100644 --- a/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-update-path-traversal.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-update-path-traversal.integration-spec.ts @@ -89,6 +89,51 @@ const FAILING_UPDATE_TEST_CASES: EachTestingContext[] = [ }), }, }, + { + title: + 'when builtComponentPath is a folder path without extension (bare UUID)', + context: { + manifest: buildManifest({ + builtComponentPath: '8b2df3cc-23ad-4e1b-87fd-f880d4cefd58', + }), + }, + }, + { + title: + 'when sourceComponentPath is a folder path without extension', + context: { + manifest: buildManifest({ + sourceComponentPath: 'src/front-components/my-component', + }), + }, + }, + { + title: + 'when builtComponentPath is updated with invalid extension (.js instead of .mjs)', + context: { + manifest: buildManifest({ + builtComponentPath: 'src/front-components/test.js', + }), + }, + }, + { + title: + 'when sourceComponentPath is updated with invalid extension (.mjs instead of .ts/.tsx)', + context: { + manifest: buildManifest({ + sourceComponentPath: 'src/front-components/test.mjs', + }), + }, + }, + { + title: + 'when builtComponentPath is updated with a completely unrelated extension (.pdf)', + context: { + manifest: buildManifest({ + builtComponentPath: 'src/front-components/test.pdf', + }), + }, + }, ]; describe('Front component update via manifest sync should fail for path traversal', () => { diff --git a/packages/twenty-server/test/integration/metadata/suites/logic-function/__snapshots__/failing-logic-function-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/logic-function/__snapshots__/failing-logic-function-creation.integration-spec.ts.snap index 760929e8ee..3204369966 100644 --- a/packages/twenty-server/test/integration/metadata/suites/logic-function/__snapshots__/failing-logic-function-creation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/logic-function/__snapshots__/failing-logic-function-creation.integration-spec.ts.snap @@ -10,8 +10,8 @@ exports[`Logic function creation via manifest sync should fail for path traversa "errors": [ { "code": "INVALID_LOGIC_FUNCTION_INPUT", - "message": "Built handler path contains unsafe characters", - "userFriendlyMessage": "Built handler path contains unsafe characters", + "message": "Resource path must not contain backslashes", + "userFriendlyMessage": "Built handler path is invalid", }, ], "flatEntityMinimalInformation": { @@ -28,7 +28,7 @@ exports[`Logic function creation via manifest sync should fail for path traversa "logicFunction": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Built handler path contains unsafe characters", + "userFriendlyMessage": "Built handler path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", @@ -45,8 +45,8 @@ exports[`Logic function creation via manifest sync should fail for path traversa "errors": [ { "code": "INVALID_LOGIC_FUNCTION_INPUT", - "message": "Built handler path contains unsafe characters", - "userFriendlyMessage": "Built handler path contains unsafe characters", + "message": "Resource path must not contain path traversal (..)", + "userFriendlyMessage": "Built handler path is invalid", }, ], "flatEntityMinimalInformation": { @@ -63,7 +63,112 @@ exports[`Logic function creation via manifest sync should fail for path traversa "logicFunction": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Built handler path contains unsafe characters", + "userFriendlyMessage": "Built handler path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Logic function creation via manifest sync should fail for path traversal when builtHandlerPath has a completely unrelated extension (.pdf) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "logicFunction": [ + { + "errors": [ + { + "code": "INVALID_LOGIC_FUNCTION_INPUT", + "message": "Invalid file extension. Allowed extensions: .mjs", + "userFriendlyMessage": "Built handler path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "logicFunction", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 logicFunction", + "summary": { + "logicFunction": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built handler path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Logic function creation via manifest sync should fail for path traversal when builtHandlerPath has an invalid extension (.js instead of .mjs) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "logicFunction": [ + { + "errors": [ + { + "code": "INVALID_LOGIC_FUNCTION_INPUT", + "message": "Invalid file extension. Allowed extensions: .mjs", + "userFriendlyMessage": "Built handler path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "logicFunction", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 logicFunction", + "summary": { + "logicFunction": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built handler path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Logic function creation via manifest sync should fail for path traversal when builtHandlerPath is a folder path without extension (bare UUID) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "logicFunction": [ + { + "errors": [ + { + "code": "INVALID_LOGIC_FUNCTION_INPUT", + "message": "Filename must have an extension", + "userFriendlyMessage": "Built handler path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "logicFunction", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 logicFunction", + "summary": { + "logicFunction": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built handler path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", @@ -80,8 +185,8 @@ exports[`Logic function creation via manifest sync should fail for path traversa "errors": [ { "code": "INVALID_LOGIC_FUNCTION_INPUT", - "message": "Built handler path contains unsafe characters", - "userFriendlyMessage": "Built handler path contains unsafe characters", + "message": "Resource path must be relative, not absolute", + "userFriendlyMessage": "Built handler path is invalid", }, ], "flatEntityMinimalInformation": { @@ -98,7 +203,7 @@ exports[`Logic function creation via manifest sync should fail for path traversa "logicFunction": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Built handler path contains unsafe characters", + "userFriendlyMessage": "Built handler path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", @@ -115,8 +220,8 @@ exports[`Logic function creation via manifest sync should fail for path traversa "errors": [ { "code": "INVALID_LOGIC_FUNCTION_INPUT", - "message": "Source handler path contains unsafe characters", - "userFriendlyMessage": "Source handler path contains unsafe characters", + "message": "Resource path must not contain path traversal (..)", + "userFriendlyMessage": "Source handler path is invalid", }, ], "flatEntityMinimalInformation": { @@ -133,7 +238,77 @@ exports[`Logic function creation via manifest sync should fail for path traversa "logicFunction": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Source handler path contains unsafe characters", + "userFriendlyMessage": "Source handler path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Logic function creation via manifest sync should fail for path traversal when sourceHandlerPath has an invalid extension (.mjs instead of .ts/.tsx) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "logicFunction": [ + { + "errors": [ + { + "code": "INVALID_LOGIC_FUNCTION_INPUT", + "message": "Invalid file extension. Allowed extensions: .ts, .tsx, .json", + "userFriendlyMessage": "Source handler path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "logicFunction", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 logicFunction", + "summary": { + "logicFunction": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Source handler path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Logic function creation via manifest sync should fail for path traversal when sourceHandlerPath is a folder path without extension 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "logicFunction": [ + { + "errors": [ + { + "code": "INVALID_LOGIC_FUNCTION_INPUT", + "message": "Filename must have an extension", + "userFriendlyMessage": "Source handler path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "logicFunction", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 logicFunction", + "summary": { + "logicFunction": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Source handler path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", diff --git a/packages/twenty-server/test/integration/metadata/suites/logic-function/__snapshots__/failing-logic-function-update-path-traversal.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/logic-function/__snapshots__/failing-logic-function-update-path-traversal.integration-spec.ts.snap index 64ab0a14d9..dcafa87eae 100644 --- a/packages/twenty-server/test/integration/metadata/suites/logic-function/__snapshots__/failing-logic-function-update-path-traversal.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/logic-function/__snapshots__/failing-logic-function-update-path-traversal.integration-spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Logic function update via manifest sync should fail for path traversal when builtHandlerPath is updated with absolute path via manifest sync 1`] = ` +exports[`Logic function update via manifest sync should fail for path traversal when builtHandlerPath is a folder path without extension (bare UUID) 1`] = ` { "extensions": { "code": "METADATA_VALIDATION_FAILED", @@ -10,8 +10,8 @@ exports[`Logic function update via manifest sync should fail for path traversal "errors": [ { "code": "INVALID_LOGIC_FUNCTION_INPUT", - "message": "Built handler path contains unsafe characters", - "userFriendlyMessage": "Built handler path contains unsafe characters", + "message": "Filename must have an extension", + "userFriendlyMessage": "Built handler path is invalid", }, ], "flatEntityMinimalInformation": { @@ -28,7 +28,77 @@ exports[`Logic function update via manifest sync should fail for path traversal "logicFunction": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Built handler path contains unsafe characters", + "userFriendlyMessage": "Built handler path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Logic function update via manifest sync should fail for path traversal when builtHandlerPath is updated with a completely unrelated extension (.pdf) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "logicFunction": [ + { + "errors": [ + { + "code": "INVALID_LOGIC_FUNCTION_INPUT", + "message": "Invalid file extension. Allowed extensions: .mjs", + "userFriendlyMessage": "Built handler path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "logicFunction", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 logicFunction", + "summary": { + "logicFunction": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built handler path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Logic function update via manifest sync should fail for path traversal when builtHandlerPath is updated with absolute path via manifest sync 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "logicFunction": [ + { + "errors": [ + { + "code": "INVALID_LOGIC_FUNCTION_INPUT", + "message": "Resource path must be relative, not absolute", + "userFriendlyMessage": "Built handler path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "logicFunction", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 logicFunction", + "summary": { + "logicFunction": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built handler path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", @@ -45,8 +115,8 @@ exports[`Logic function update via manifest sync should fail for path traversal "errors": [ { "code": "INVALID_LOGIC_FUNCTION_INPUT", - "message": "Built handler path contains unsafe characters", - "userFriendlyMessage": "Built handler path contains unsafe characters", + "message": "Resource path must not contain backslashes", + "userFriendlyMessage": "Built handler path is invalid", }, ], "flatEntityMinimalInformation": { @@ -63,7 +133,42 @@ exports[`Logic function update via manifest sync should fail for path traversal "logicFunction": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Built handler path contains unsafe characters", + "userFriendlyMessage": "Built handler path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Logic function update via manifest sync should fail for path traversal when builtHandlerPath is updated with invalid extension (.js instead of .mjs) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "logicFunction": [ + { + "errors": [ + { + "code": "INVALID_LOGIC_FUNCTION_INPUT", + "message": "Invalid file extension. Allowed extensions: .mjs", + "userFriendlyMessage": "Built handler path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "logicFunction", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 logicFunction", + "summary": { + "logicFunction": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Built handler path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", @@ -80,8 +185,8 @@ exports[`Logic function update via manifest sync should fail for path traversal "errors": [ { "code": "INVALID_LOGIC_FUNCTION_INPUT", - "message": "Built handler path contains unsafe characters", - "userFriendlyMessage": "Built handler path contains unsafe characters", + "message": "Resource path must not contain path traversal (..)", + "userFriendlyMessage": "Built handler path is invalid", }, ], "flatEntityMinimalInformation": { @@ -98,7 +203,77 @@ exports[`Logic function update via manifest sync should fail for path traversal "logicFunction": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Built handler path contains unsafe characters", + "userFriendlyMessage": "Built handler path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Logic function update via manifest sync should fail for path traversal when sourceHandlerPath is a folder path without extension 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "logicFunction": [ + { + "errors": [ + { + "code": "INVALID_LOGIC_FUNCTION_INPUT", + "message": "Filename must have an extension", + "userFriendlyMessage": "Source handler path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "logicFunction", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 logicFunction", + "summary": { + "logicFunction": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Source handler path is invalid", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Logic function update via manifest sync should fail for path traversal when sourceHandlerPath is updated with invalid extension (.mjs instead of .ts/.tsx) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "logicFunction": [ + { + "errors": [ + { + "code": "INVALID_LOGIC_FUNCTION_INPUT", + "message": "Invalid file extension. Allowed extensions: .ts, .tsx, .json", + "userFriendlyMessage": "Source handler path is invalid", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "logicFunction", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 logicFunction", + "summary": { + "logicFunction": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Source handler path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", @@ -115,8 +290,8 @@ exports[`Logic function update via manifest sync should fail for path traversal "errors": [ { "code": "INVALID_LOGIC_FUNCTION_INPUT", - "message": "Source handler path contains unsafe characters", - "userFriendlyMessage": "Source handler path contains unsafe characters", + "message": "Resource path must not contain path traversal (..)", + "userFriendlyMessage": "Source handler path is invalid", }, ], "flatEntityMinimalInformation": { @@ -133,7 +308,7 @@ exports[`Logic function update via manifest sync should fail for path traversal "logicFunction": 1, "totalErrors": 1, }, - "userFriendlyMessage": "Source handler path contains unsafe characters", + "userFriendlyMessage": "Source handler path is invalid", }, "message": "Validation errors occurred while syncing application manifest metadata", "name": "GraphQLError", diff --git a/packages/twenty-server/test/integration/metadata/suites/logic-function/failing-logic-function-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/logic-function/failing-logic-function-creation.integration-spec.ts index 2ae7d080cd..8938259c3e 100644 --- a/packages/twenty-server/test/integration/metadata/suites/logic-function/failing-logic-function-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/logic-function/failing-logic-function-creation.integration-spec.ts @@ -81,6 +81,48 @@ const FAILING_CREATION_TEST_CASES: EachTestingContext[] = [ }), }, }, + { + title: 'when builtHandlerPath is a folder path without extension (bare UUID)', + context: { + manifest: buildManifest({ + builtHandlerPath: '8b2df3cc-23ad-4e1b-87fd-f880d4cefd58', + }), + }, + }, + { + title: 'when sourceHandlerPath is a folder path without extension', + context: { + manifest: buildManifest({ + sourceHandlerPath: 'src/logic-functions/my-handler', + }), + }, + }, + { + title: + 'when builtHandlerPath has an invalid extension (.js instead of .mjs)', + context: { + manifest: buildManifest({ + builtHandlerPath: 'src/logic-functions/handler.js', + }), + }, + }, + { + title: + 'when sourceHandlerPath has an invalid extension (.mjs instead of .ts/.tsx)', + context: { + manifest: buildManifest({ + sourceHandlerPath: 'src/logic-functions/handler.mjs', + }), + }, + }, + { + title: 'when builtHandlerPath has a completely unrelated extension (.pdf)', + context: { + manifest: buildManifest({ + builtHandlerPath: 'src/logic-functions/handler.pdf', + }), + }, + }, ]; describe('Logic function creation via manifest sync should fail for path traversal', () => { diff --git a/packages/twenty-server/test/integration/metadata/suites/logic-function/failing-logic-function-update-path-traversal.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/logic-function/failing-logic-function-update-path-traversal.integration-spec.ts index 9e991fd06f..65c5d714a5 100644 --- a/packages/twenty-server/test/integration/metadata/suites/logic-function/failing-logic-function-update-path-traversal.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/logic-function/failing-logic-function-update-path-traversal.integration-spec.ts @@ -88,6 +88,51 @@ const FAILING_UPDATE_TEST_CASES: EachTestingContext[] = [ }), }, }, + { + title: + 'when builtHandlerPath is a folder path without extension (bare UUID)', + context: { + manifest: buildManifest({ + builtHandlerPath: '8b2df3cc-23ad-4e1b-87fd-f880d4cefd58', + }), + }, + }, + { + title: + 'when sourceHandlerPath is a folder path without extension', + context: { + manifest: buildManifest({ + sourceHandlerPath: 'src/logic-functions/my-handler', + }), + }, + }, + { + title: + 'when builtHandlerPath is updated with invalid extension (.js instead of .mjs)', + context: { + manifest: buildManifest({ + builtHandlerPath: 'src/logic-functions/handler.js', + }), + }, + }, + { + title: + 'when sourceHandlerPath is updated with invalid extension (.mjs instead of .ts/.tsx)', + context: { + manifest: buildManifest({ + sourceHandlerPath: 'src/logic-functions/handler.mjs', + }), + }, + }, + { + title: + 'when builtHandlerPath is updated with a completely unrelated extension (.pdf)', + context: { + manifest: buildManifest({ + builtHandlerPath: 'src/logic-functions/handler.pdf', + }), + }, + }, ]; describe('Logic function update via manifest sync should fail for path traversal', () => {