b8b115f4e3
# 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.
171 lines
4.9 KiB
TypeScript
171 lines
4.9 KiB
TypeScript
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
|
|
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.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 { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
|
|
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
|
|
import {
|
|
type LogicFunctionManifest,
|
|
type Manifest,
|
|
} from 'twenty-shared/application';
|
|
import {
|
|
type EachTestingContext,
|
|
eachTestingContextFilter,
|
|
} from 'twenty-shared/testing';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
const TEST_APP_ID = uuidv4();
|
|
const TEST_ROLE_ID = uuidv4();
|
|
|
|
const VALID_BUILT_PATH = 'src/logic-functions/handler.mjs';
|
|
|
|
const buildLogicFunction = (
|
|
overrides: Partial<LogicFunctionManifest> = {},
|
|
): LogicFunctionManifest => ({
|
|
universalIdentifier: uuidv4(),
|
|
name: 'TestLogicFunction',
|
|
description: 'A test logic function',
|
|
sourceHandlerPath: 'src/logic-functions/handler.ts',
|
|
builtHandlerPath: VALID_BUILT_PATH,
|
|
builtHandlerChecksum: 'valid-checksum',
|
|
handlerName: 'handler',
|
|
...overrides,
|
|
});
|
|
|
|
const buildManifest = (
|
|
logicFunctionOverrides: Partial<LogicFunctionManifest> = {},
|
|
): Manifest =>
|
|
buildBaseManifest({
|
|
appId: TEST_APP_ID,
|
|
roleId: TEST_ROLE_ID,
|
|
overrides: {
|
|
logicFunctions: [buildLogicFunction(logicFunctionOverrides)],
|
|
},
|
|
});
|
|
|
|
type TestContext = {
|
|
manifest: Manifest;
|
|
};
|
|
|
|
const FAILING_CREATION_TEST_CASES: EachTestingContext<TestContext>[] = [
|
|
{
|
|
title: 'when builtHandlerPath contains path traversal',
|
|
context: {
|
|
manifest: buildManifest({
|
|
builtHandlerPath:
|
|
'../../../other-workspace/other-app/built-logic-function/stolen.mjs',
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
title: 'when sourceHandlerPath contains path traversal',
|
|
context: {
|
|
manifest: buildManifest({
|
|
sourceHandlerPath: '../../etc/passwd',
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
title: 'when builtHandlerPath is an absolute path',
|
|
context: {
|
|
manifest: buildManifest({
|
|
builtHandlerPath: '/etc/passwd',
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
title: 'when builtHandlerPath contains backslash path traversal',
|
|
context: {
|
|
manifest: buildManifest({
|
|
builtHandlerPath: '..\\..\\..\\etc\\passwd',
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
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', () => {
|
|
beforeAll(async () => {
|
|
await setupApplicationForSync({
|
|
applicationUniversalIdentifier: TEST_APP_ID,
|
|
name: 'Test Path Traversal Logic App',
|
|
description: 'App for testing path traversal validation on creation',
|
|
sourcePath: 'test-path-traversal-logic',
|
|
});
|
|
|
|
jest.useRealTimers();
|
|
|
|
await uploadApplicationFile({
|
|
applicationUniversalIdentifier: TEST_APP_ID,
|
|
fileFolder: 'BuiltLogicFunction',
|
|
filePath: VALID_BUILT_PATH,
|
|
fileBuffer: Buffer.from('dummy built handler content'),
|
|
filename: 'handler.mjs',
|
|
contentType: 'application/javascript',
|
|
expectToFail: false,
|
|
});
|
|
|
|
jest.useFakeTimers();
|
|
}, 60000);
|
|
|
|
afterAll(async () => {
|
|
await cleanupApplicationAndAppRegistration({
|
|
applicationUniversalIdentifier: TEST_APP_ID,
|
|
});
|
|
});
|
|
|
|
it.each(eachTestingContextFilter(FAILING_CREATION_TEST_CASES))(
|
|
'$title',
|
|
async ({ context }) => {
|
|
const { errors } = await syncApplication({
|
|
manifest: context.manifest,
|
|
expectToFail: true,
|
|
});
|
|
|
|
expectOneNotInternalServerErrorSnapshot({ errors });
|
|
},
|
|
60000,
|
|
);
|
|
});
|