Files
twenty/packages/twenty-server/test/integration/metadata/suites/application/failing-upload-application-file-path-traversal.integration-spec.ts
T
Paul Rastoin b8b115f4e3 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.
2026-05-25 11:52:54 +00:00

180 lines
5.2 KiB
TypeScript

import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
import {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const UNKNOWN_APP_ID = uuidv4();
type TestContext = {
applicationUniversalIdentifier: string;
fileFolder: string;
filePath: string;
};
const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
{
title: 'when filePath contains relative path traversal (../)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath:
'../../../other-workspace/other-app/BuiltFrontComponent/stolen.mjs',
},
},
{
title: 'when filePath contains upward traversal (../../)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: '../../etc/passwd',
},
},
{
title: 'when filePath is an absolute path',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: '/etc/passwd',
},
},
{
title: 'when filePath contains backslash path traversal',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: '..\\..\\..\\etc\\passwd',
},
},
{
title: 'when filePath is empty',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: '',
},
},
{
title:
'when applicationUniversalIdentifier does not match any installed application',
context: {
applicationUniversalIdentifier: UNKNOWN_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: 'src/components/legit.mjs',
},
},
{
title: 'when applicationUniversalIdentifier is empty',
context: {
applicationUniversalIdentifier: '',
fileFolder: 'BuiltFrontComponent',
filePath: 'src/components/legit.mjs',
},
},
{
title: 'when fileFolder is not an allowed application file folder',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'CorePicture',
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', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Upload Path Traversal App',
description: 'App for testing path traversal on file upload',
sourcePath: 'test-upload-path-traversal',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it.each(eachTestingContextFilter(FAILING_TEST_CASES))(
'$title',
async ({ context }) => {
jest.useRealTimers();
const { errors } = await uploadApplicationFile({
applicationUniversalIdentifier: context.applicationUniversalIdentifier,
fileFolder: context.fileFolder,
filePath: context.filePath,
fileBuffer: Buffer.from('content'),
filename: 'test-file.mjs',
contentType: 'application/javascript',
expectToFail: true,
});
jest.useFakeTimers();
expectOneNotInternalServerErrorSnapshot({ errors });
},
60000,
);
});