Harden server-side input validation and auth defaults (#18018)

## Summary

- **File storage (LocalDriver):** Add realpath resolution and symlink
rejection to `writeFile`, `downloadFile`, and `downloadFolder` — brings
them in line with the existing `readFile` protections. Includes unit
tests.
- **JWT:** Pin signing/verification to HS256 explicitly.
- **Auth:** Revoke active refresh tokens when a user changes their
password.
- **Logic functions:** Validate `handlerName` as a safe JS identifier at
both DTO and runtime level, preventing injection into the generated
runner script.
- **User entity:** Remove `passwordHash` from the GraphQL schema
(`@Field` decorator removed, column stays).
- **Query params:** Use `crypto.randomBytes` instead of `Math.random`
for SQL parameter name generation.
- **Exception filter:** Mirror the request `Origin` header instead of
sending `Access-Control-Allow-Origin: *`.

## Test plan

- [x] `local.driver.spec.ts` — writeFile rejects symlinks, downloadFile
rejects paths outside storage
- [ ] Verify JWT auth flow still works (login, token refresh)
- [ ] Verify password change invalidates existing sessions
- [ ] Verify logic function creation with valid/invalid handler names
- [ ] Verify file upload/download in dev environment


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-18 21:41:01 +01:00
committed by GitHub
parent 330737aa2e
commit 67074a7581
11 changed files with 227 additions and 50 deletions
@@ -11,7 +11,7 @@ import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { PermissionFlagType } from 'twenty-shared/constants';
import { AppPath } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { IsNull, Repository } from 'typeorm';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
@@ -618,6 +618,18 @@ export class AuthService {
passwordHash: newPasswordHash,
});
// Invalidate all existing refresh tokens for this user across all workspaces
await this.appTokenRepository.update(
{
userId,
type: AppTokenType.RefreshToken,
revokedAt: IsNull(),
},
{
revokedAt: new Date(),
},
);
const emailTemplate = PasswordUpdateNotifyEmail({
userName: `${user.firstName} ${user.lastName}`,
email: user.email,
@@ -0,0 +1,81 @@
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { LocalDriver } from 'src/engine/core-modules/file-storage/drivers/local.driver';
describe('LocalDriver security hardening', () => {
const cleanupPaths: string[] = [];
const createTempDirectory = async (prefix: string) => {
const dir = await mkdtemp(path.join(tmpdir(), prefix));
cleanupPaths.push(dir);
return dir;
};
afterAll(async () => {
await Promise.all(
cleanupPaths.map(async (directoryPath) => {
await rm(directoryPath, { recursive: true, force: true });
}),
);
});
it('should reject writeFile when target is a symlink', async () => {
const storagePath = await createTempDirectory('local-driver-storage-');
const outsidePath = await createTempDirectory('local-driver-outside-');
const outsideFilePath = path.join(outsidePath, 'outside.txt');
const symlinkFolderPath = path.join(storagePath, 'workspace', 'app');
const symlinkFilePath = path.join(symlinkFolderPath, 'target.txt');
await mkdir(symlinkFolderPath, { recursive: true });
await writeFile(outsideFilePath, 'outside');
await symlink(outsideFilePath, symlinkFilePath);
const driver = new LocalDriver({ storagePath });
await expect(
driver.writeFile({
filePath: 'workspace/app/target.txt',
sourceFile: Buffer.from('new-content'),
mimeType: undefined,
}),
).rejects.toMatchObject({
code: FileStorageExceptionCode.ACCESS_DENIED,
});
await expect(readFile(outsideFilePath, 'utf8')).resolves.toBe('outside');
});
it('should reject downloadFile when path resolves outside storage', async () => {
const storagePath = await createTempDirectory('local-driver-storage-');
const outsidePath = await createTempDirectory('local-driver-outside-');
const outsideFilePath = path.join(outsidePath, 'outside.txt');
const symlinkFolderPath = path.join(storagePath, 'workspace', 'app');
const symlinkFilePath = path.join(symlinkFolderPath, 'target.txt');
const downloadDestinationPath = path.join(
storagePath,
'download',
'file.txt',
);
await mkdir(symlinkFolderPath, { recursive: true });
await writeFile(outsideFilePath, 'outside');
await symlink(outsideFilePath, symlinkFilePath);
const driver = new LocalDriver({ storagePath });
await expect(
driver.downloadFile({
onStoragePath: 'workspace/app/target.txt',
localPath: downloadDestinationPath,
}),
).rejects.toMatchObject({
code: FileStorageExceptionCode.ACCESS_DENIED,
});
});
});
@@ -74,17 +74,49 @@ export class LocalDriver implements StorageDriver {
await this.createFolder(folderPath);
await fs.writeFile(filePath, params.sourceFile);
const realFolderPath = realpathSync(folderPath);
const realFilePath = path.join(realFolderPath, path.basename(filePath));
this.assertRealPathIsWithinStorage(realFilePath);
try {
const stats = await fs.lstat(realFilePath);
if (stats.isSymbolicLink()) {
throw new FileStorageException(
'Access denied',
FileStorageExceptionCode.ACCESS_DENIED,
);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
await fs.writeFile(realFilePath, params.sourceFile);
}
async downloadFile(params: {
onStoragePath: string;
localPath: string;
}): Promise<void> {
const filePath = path.resolve(
const resolvedPath = path.resolve(
this.options.storagePath,
params.onStoragePath,
);
let filePath: string;
try {
filePath = realpathSync(resolvedPath);
} catch {
throw new FileStorageException(
'File not found',
FileStorageExceptionCode.FILE_NOT_FOUND,
);
}
this.assertRealPathIsWithinStorage(filePath);
await this.createFolder(dirname(params.localPath));
@@ -97,26 +129,57 @@ export class LocalDriver implements StorageDriver {
onStoragePath: string;
localPath: string;
}): Promise<void> {
const rootFolderPath = path.resolve(
const resolvedPath = path.resolve(
this.options.storagePath,
params.onStoragePath,
);
let rootFolderPath: string;
try {
rootFolderPath = realpathSync(resolvedPath);
} catch {
throw new FileStorageException(
'File not found',
FileStorageExceptionCode.FILE_NOT_FOUND,
);
}
this.assertRealPathIsWithinStorage(rootFolderPath);
await this.createFolder(params.localPath);
const resources = await fs.readdir(rootFolderPath);
await this.downloadFolderFromRealPath({
rootFolderPath,
localPath: params.localPath,
});
}
private async downloadFolderFromRealPath(params: {
rootFolderPath: string;
localPath: string;
}): Promise<void> {
await this.createFolder(params.localPath);
const resources = await fs.readdir(params.rootFolderPath);
for (const resource of resources) {
const resourcePath = path.join(rootFolderPath, resource);
const stats = await fs.stat(resourcePath);
const resourcePath = path.join(params.rootFolderPath, resource);
const stats = await fs.lstat(resourcePath);
if (stats.isSymbolicLink()) {
throw new FileStorageException(
'Access denied',
FileStorageExceptionCode.ACCESS_DENIED,
);
}
if (stats.isFile()) {
const content = await fs.readFile(resourcePath);
await fs.writeFile(path.join(params.localPath, resource), content);
} else {
await this.downloadFolder({
onStoragePath: path.join(params.onStoragePath, resource),
await this.downloadFolderFromRealPath({
rootFolderPath: resourcePath,
localPath: path.join(params.localPath, resource),
});
}
@@ -10,8 +10,12 @@ const InternalJwtModule = NestJwtModule.registerAsync({
return {
secret: twentyConfigService.get('APP_SECRET'),
signOptions: {
algorithm: 'HS256',
expiresIn: twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN'),
},
verifyOptions: {
algorithms: ['HS256'],
},
};
},
inject: [TwentyConfigService],
@@ -215,6 +215,12 @@ export class LocalDriver implements LogicFunctionDriver {
builtFileAbsPath: string;
handlerName: string;
}) {
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(handlerName)) {
throw new Error(
`Invalid handlerName "${handlerName}": must be a valid JavaScript identifier`,
);
}
const runnerPath = join(dir, '__runner.cjs');
const code = `
// Auto-generated. Do not edit.
@@ -70,7 +70,6 @@ export class UserEntity {
@Column({ default: false })
disabled: boolean;
@Field({ nullable: true })
@Column({ nullable: true })
passwordHash: string;