FileStorageService Dedicated file and folder code flow + integrity check (#20831)

# Introduction

Next handling mimetype integrity check and checksum integrity check for
s3 storage type

Always expecting a trailing end slash when deleting a folder etc

## Application
Uninstalling an application now deletes all its related files

## File storage service
Making a distincton between folder path and file path

## Validation Pipeline

Every file operation in `FileStorageService.buildOnStoragePath` runs
through `validateResourcePath`, which chains three validators in order:

**1. `validateSafeRelativePath`** -- rejects path traversal attacks

| Input | Result | Error |
|---|---|---|
| `../../../etc/passwd` | Rejected | `Resource path must not contain
path traversal (..)` |
| `/etc/passwd` | Rejected | `Resource path must be relative, not
absolute` |
| `file\0.txt` | Rejected | `Resource path contains null bytes` |
| `..\\..\\etc\\passwd` | Rejected | `Resource path must not contain
backslashes` |
| _(empty)_ | Rejected | `Resource path must not be empty` |

**2. `validateFilenameIntegrity`** -- enforces safe characters, length
limits, extension required

| Input | Result | Error |
|---|---|---|
| `my folder/file.mjs` | Rejected | `A path segment contains invalid
characters...` |
| `Makefile` | Rejected | `Filename must have an extension` |
| `aaa...(256 chars).mjs` | Rejected | `A path segment exceeds the
maximum length of 255 characters` |
| `a/b/.../file.mjs` (1025+ chars) | Rejected | `Resource path exceeds
maximum length of 1024 characters` |
| `src/handlers/index.mjs` | Accepted | -- |
| `my-app/my_file.tsx` | Accepted | -- |
| `v1.0/module.config.mjs` | Accepted | -- |

Allowed characters per segment: `a-z`, `A-Z`, `0-9`, `.`, `-`, `_`

**3. `validateResourceExtension`** -- checks extension against the
`FileFolder` allowlist

| Input | FileFolder | Result | Error |
|---|---|---|---|
| `handler.js` | `BuiltLogicFunction` | Rejected | `Invalid file
extension. Allowed extensions: .mjs` |
| `card.tsx` | `BuiltFrontComponent` | Rejected | `Invalid file
extension. Allowed extensions: .mjs` |
| `script.js` | `PublicAsset` | Rejected | `Invalid file extension.
Allowed extensions: .png, .jpg, ...` |
| `index.mjs` | `BuiltLogicFunction` | Accepted | -- |
| `app.tsx` | `Source` | Accepted | -- |
| `photo.png` | `CorePicture` | Accepted | -- (unconfigured folder,
passes through) |

## Consumers

- **`FileStorageService`** -- calls `validateResourcePath`, throws
`FileStorageException` on failure (last-resort defense)
- **Resolver (`uploadApplicationFile`)** -- calls
`validateResourcePath`, throws `ApplicationException` on failure
(user-facing)
- **Flat validators** -- call `validateResourcePath`, push the error to
`validationResult.errors` (non-throwing, collects all errors)

All error messages are translated via Lingui `t` and returned in a
discriminated union `{ isValid: true } | { isValid: false, error: string
}`, letting each consumer decide how to handle failures.
This commit is contained in:
Paul Rastoin
2026-05-25 13:52:54 +02:00
committed by GitHub
parent 85d649e831
commit b8b115f4e3
38 changed files with 2474 additions and 508 deletions
@@ -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)}`,
);
}
}
@@ -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,
@@ -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<RejectedFilePathContext>[] = [
{
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<AcceptedFilePathContext>[] = [
{
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<RejectedFolderPathContext>[] =
[
{
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<AcceptedFolderPathContext>[] =
[
{
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();
});
});
});
});
@@ -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<Record<FileFolder, Record<string, true>>>;
@@ -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<ApplicationEntity>,
) {}
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<ResourceIdentifier, 'resourcePath'> & { 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<string | null> {
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<Readable> {
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<void> {
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<void> {
async deleteFile(params: ResourceIdentifier): Promise<void> {
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<ResourceIdentifier, 'resourcePath'> & { folderPath: string },
): Promise<void> {
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<boolean> {
@@ -273,8 +372,10 @@ export class FileStorageService {
}): Promise<void> {
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<boolean> {
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 });
}
}
@@ -0,0 +1,12 @@
type ResourcePathValidationSuccess = {
isValid: true;
};
type ResourcePathValidationFailure = {
isValid: false;
error: string;
};
export type ResourcePathValidationResult =
| ResourcePathValidationSuccess
| ResourcePathValidationFailure;
@@ -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,
}),
);
});
});
@@ -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,
}),
);
});
});
@@ -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,
);
});
});
@@ -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,
});
},
);
});
@@ -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');
}
});
});
@@ -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');
}
});
});
@@ -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);
}
},
);
});
@@ -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,
}),
);
},
);
});
@@ -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,
);
}
};
@@ -0,0 +1,13 @@
import { extname } from 'path';
export const hasAllowedExtension = ({
filePath,
allowedExtensions,
}: {
filePath: string;
allowedExtensions: Readonly<Record<string, true>>;
}): boolean => {
const ext = extname(filePath).toLowerCase();
return allowedExtensions[ext] === true;
};
@@ -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 };
};
@@ -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 });
};
@@ -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 };
};
@@ -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 };
};
@@ -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 };
};
@@ -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,
@@ -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,
@@ -149,7 +149,7 @@ export class LogicFunctionResourceService {
workspaceId,
applicationUniversalIdentifier,
}: GetSourceCodeParams): Promise<void> {
await this.fileStorageService.delete({
await this.fileStorageService.deleteFile({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.Source,
@@ -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;
@@ -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;
@@ -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,
@@ -86,7 +86,7 @@ export class UpdateLogicFunctionActionHandlerService extends WorkspaceMigrationR
);
if (builtPathChanged) {
await this.fileStorageService.delete({
await this.fileStorageService.deleteFile({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.BuiltLogicFunction,
@@ -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": {
@@ -84,6 +84,59 @@ const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
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', () => {
@@ -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<String>,
},
"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<String>,
},
"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<String>,
},
"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<String>,
},
"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<String>,
},
"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",
@@ -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<String>,
},
"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<String>,
},
"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<String>,
},
"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<String>,
},
"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<String>,
},
"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",
@@ -74,6 +74,71 @@ const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
},
},
},
{
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', () => {
@@ -89,6 +89,51 @@ const FAILING_UPDATE_TEST_CASES: EachTestingContext<TestContext>[] = [
}),
},
},
{
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', () => {
@@ -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<String>,
},
"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<String>,
},
"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<String>,
},
"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<String>,
},
"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<String>,
},
"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",
@@ -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<String>,
},
"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<String>,
},
"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<String>,
},
"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<String>,
},
"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<String>,
},
"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",
@@ -81,6 +81,48 @@ const FAILING_CREATION_TEST_CASES: EachTestingContext<TestContext>[] = [
}),
},
},
{
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', () => {
@@ -88,6 +88,51 @@ const FAILING_UPDATE_TEST_CASES: EachTestingContext<TestContext>[] = [
}),
},
},
{
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', () => {