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:
@@ -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,
|
||||
|
||||
+81
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+71
-8
@@ -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],
|
||||
|
||||
+6
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user