From 7400c35a48e60c784c36e4946ef6a7d91d73321e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Feb 2023 14:24:02 +0100 Subject: [PATCH] :rocket: Release 0.216.1 (#5531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :rocket: Release 0.216.1 * fix(core): Do not allow arbitrary path traversal in the credential-translation endpoint (#5522) * fix(core): Do not allow arbitrary path traversal in BinaryDataManager (#5523) * fix(core): User update endpoint should only allow updating email, firstName, and lastName (#5526) * fix(core): Do not explicitly bypass auth on urls containing `.svg` (#5525) * :books: Update CHANGELOG.md --------- Co-authored-by: janober Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ Co-authored-by: Jan Oberhauser --- CHANGELOG.md | 12 +++ package.json | 2 +- packages/cli/package.json | 3 +- packages/cli/src/GenericHelpers.ts | 3 +- packages/cli/src/Server.ts | 79 ++++++------------- packages/cli/src/TranslationHelpers.ts | 16 ---- packages/cli/src/controllers/index.ts | 1 + packages/cli/src/controllers/me.controller.ts | 35 ++++---- .../src/controllers/translation.controller.ts | 58 ++++++++++++++ packages/cli/src/databases/entities/User.ts | 5 +- packages/cli/src/middlewares/auth.ts | 14 ++-- packages/cli/src/requests.ts | 23 ++++-- packages/cli/test/setup-mocks.ts | 2 + .../unit/controllers/me.controller.test.ts | 48 +++++++++-- .../translation.controller.test.ts | 40 ++++++++++ packages/core/package.json | 2 +- .../core/src/BinaryDataManager/FileSystem.ts | 27 ++++--- packages/core/src/errors.ts | 5 ++ packages/core/src/index.ts | 1 + packages/editor-ui/package.json | 2 +- packages/node-dev/package.json | 2 +- packages/nodes-base/package.json | 2 +- packages/workflow/package.json | 2 +- pnpm-lock.yaml | 26 +++--- 24 files changed, 272 insertions(+), 138 deletions(-) create mode 100644 packages/cli/src/controllers/translation.controller.ts create mode 100644 packages/cli/test/unit/controllers/translation.controller.test.ts create mode 100644 packages/core/src/errors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea8b39bffaa4..1ba0cebe5452f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [0.216.1](https://github.com/n8n-io/n8n/compare/n8n@0.216.0...n8n@0.216.1) (2023-02-21) + + +### Bug Fixes + +* **core:** Do not allow arbitrary path traversal in BinaryDataManager ([#5523](https://github.com/n8n-io/n8n/issues/5523)) ([40b9784](https://github.com/n8n-io/n8n/commit/40b97846483fe7c58229c156acb66f43a5a79dc3)) +* **core:** Do not allow arbitrary path traversal in the credential-translation endpoint ([#5522](https://github.com/n8n-io/n8n/issues/5522)) ([fb07d77](https://github.com/n8n-io/n8n/commit/fb07d77106bb4933758c63bbfb87f591bf4a27dd)) +* **core:** Do not explicitly bypass auth on urls containing `.svg` ([#5525](https://github.com/n8n-io/n8n/issues/5525)) ([27adea7](https://github.com/n8n-io/n8n/commit/27adea70459329fc0dddabee69e10c9d1453835f)) +* **core:** User update endpoint should only allow updating email, firstName, and lastName ([#5526](https://github.com/n8n-io/n8n/issues/5526)) ([5599221](https://github.com/n8n-io/n8n/commit/5599221007cb09cb81f0623874fafc6cd481384c)) + + + # [0.216.0](https://github.com/n8n-io/n8n/compare/n8n@0.215.2...n8n@0.216.0) (2023-02-16) diff --git a/package.json b/package.json index 5a55493af9256..73d50b1db79ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.216.0", + "version": "0.216.1", "private": true, "homepage": "https://n8n.io", "engines": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 4e17130ca5cc9..30a1e3905eb13 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.216.0", + "version": "0.216.1", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -127,6 +127,7 @@ "callsites": "^3.1.0", "change-case": "^4.1.1", "class-validator": "^0.14.0", + "class-transformer": "^0.5.1", "client-oauth2": "^4.2.5", "compression": "^1.7.4", "connect-history-api-fallback": "^1.6.0", diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts index e3940a620b7c3..613e450e3b36e 100644 --- a/packages/cli/src/GenericHelpers.ts +++ b/packages/cli/src/GenericHelpers.ts @@ -22,6 +22,7 @@ import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { TagEntity } from '@db/entities/TagEntity'; import type { User } from '@db/entities/User'; +import type { UserUpdatePayload } from '@/requests'; /** * Returns the base URL n8n is reachable from @@ -99,7 +100,7 @@ export async function generateUniqueName( } export async function validateEntity( - entity: WorkflowEntity | CredentialsEntity | TagEntity | User, + entity: WorkflowEntity | CredentialsEntity | TagEntity | User | UserUpdatePayload, ): Promise { const errors = await validate(entity); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index c65300131668d..166b75af2b41e 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -33,6 +33,7 @@ import { LoadNodeParameterOptions, LoadNodeListSearch, UserSettings, + FileNotFoundError, } from 'n8n-core'; import type { @@ -55,7 +56,6 @@ import history from 'connect-history-api-fallback'; import config from '@/config'; import * as Queue from '@/Queue'; import { InternalHooksManager } from '@/InternalHooksManager'; -import { getCredentialTranslationPath } from '@/TranslationHelpers'; import { getSharedWorkflowIds } from '@/WorkflowHelpers'; import { nodesController } from '@/api/nodes.api'; @@ -86,6 +86,7 @@ import { MeController, OwnerController, PasswordResetController, + TranslationController, UsersController, } from '@/controllers'; @@ -347,6 +348,7 @@ class Server extends AbstractServer { new OwnerController({ config, internalHooks, repositories, logger }), new MeController({ externalHooks, internalHooks, repositories, logger }), new PasswordResetController({ config, externalHooks, internalHooks, repositories, logger }), + new TranslationController(config, this.credentialTypes), new UsersController({ config, mailer, @@ -585,48 +587,6 @@ class Server extends AbstractServer { ), ); - this.app.get( - `/${this.restEndpoint}/credential-translation`, - ResponseHelper.send( - async ( - req: express.Request & { query: { credentialType: string } }, - res: express.Response, - ): Promise => { - const translationPath = getCredentialTranslationPath({ - locale: this.frontendSettings.defaultLocale, - credentialType: req.query.credentialType, - }); - - try { - return require(translationPath); - } catch (error) { - return null; - } - }, - ), - ); - - // Returns node information based on node names and versions - const headersPath = pathJoin(NODES_BASE_DIR, 'dist', 'nodes', 'headers'); - this.app.get( - `/${this.restEndpoint}/node-translation-headers`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - try { - await fsAccess(`${headersPath}.js`); - } catch (_) { - return; // no headers available - } - - try { - return require(headersPath); - } catch (error) { - res.status(500).send('Failed to load headers file'); - } - }, - ), - ); - // ---------------------------------------- // Node-Types // ---------------------------------------- @@ -1160,21 +1120,26 @@ class Server extends AbstractServer { // TODO UM: check if this needs permission check for UM const identifier = req.params.path; const binaryDataManager = BinaryDataManager.getInstance(); - const binaryPath = binaryDataManager.getBinaryPath(identifier); - let { mode, fileName, mimeType } = req.query; - if (!fileName || !mimeType) { - try { - const metadata = await binaryDataManager.getBinaryMetadata(identifier); - fileName = metadata.fileName; - mimeType = metadata.mimeType; - res.setHeader('Content-Length', metadata.fileSize); - } catch {} - } - if (mimeType) res.setHeader('Content-Type', mimeType); - if (mode === 'download') { - res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + try { + const binaryPath = binaryDataManager.getBinaryPath(identifier); + let { mode, fileName, mimeType } = req.query; + if (!fileName || !mimeType) { + try { + const metadata = await binaryDataManager.getBinaryMetadata(identifier); + fileName = metadata.fileName; + mimeType = metadata.mimeType; + res.setHeader('Content-Length', metadata.fileSize); + } catch {} + } + if (mimeType) res.setHeader('Content-Type', mimeType); + if (mode === 'download') { + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + } + res.sendFile(binaryPath); + } catch (error) { + if (error instanceof FileNotFoundError) res.writeHead(404).end(); + else throw error; } - res.sendFile(binaryPath); }, ); diff --git a/packages/cli/src/TranslationHelpers.ts b/packages/cli/src/TranslationHelpers.ts index cc4319b04f283..dd829534a6d78 100644 --- a/packages/cli/src/TranslationHelpers.ts +++ b/packages/cli/src/TranslationHelpers.ts @@ -1,7 +1,6 @@ import { join, dirname } from 'path'; import { readdir } from 'fs/promises'; import type { Dirent } from 'fs'; -import { NODES_BASE_DIR } from '@/constants'; const ALLOWED_VERSIONED_DIRNAME_LENGTH = [2, 3]; // e.g. v1, v10 @@ -47,18 +46,3 @@ export async function getNodeTranslationPath({ ? join(nodeDir, `v${maxVersion}`, 'translations', locale, `${nodeType}.json`) : join(nodeDir, 'translations', locale, `${nodeType}.json`); } - -/** - * Get the full path to a credential translation file in `/dist`. - */ -export function getCredentialTranslationPath({ - locale, - credentialType, -}: { - locale: string; - credentialType: string; -}): string { - const credsPath = join(NODES_BASE_DIR, 'dist', 'credentials'); - - return join(credsPath, 'translations', locale, `${credentialType}.json`); -} diff --git a/packages/cli/src/controllers/index.ts b/packages/cli/src/controllers/index.ts index 2ee7c7fd064ec..37ce548a540dc 100644 --- a/packages/cli/src/controllers/index.ts +++ b/packages/cli/src/controllers/index.ts @@ -2,4 +2,5 @@ export { AuthController } from './auth.controller'; export { MeController } from './me.controller'; export { OwnerController } from './owner.controller'; export { PasswordResetController } from './passwordReset.controller'; +export { TranslationController } from './translation.controller'; export { UsersController } from './users.controller'; diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index 603140715b028..be2f5f32c2646 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -1,4 +1,5 @@ import validator from 'validator'; +import { plainToInstance } from 'class-transformer'; import { Delete, Get, Patch, Post, RestController } from '@/decorators'; import { compareHash, @@ -7,13 +8,13 @@ import { validatePassword, } from '@/UserManagement/UserManagementHelper'; import { BadRequestError } from '@/ResponseHelper'; -import { User } from '@db/entities/User'; +import type { User } from '@db/entities/User'; import { validateEntity } from '@/GenericHelpers'; import { issueCookie } from '@/auth/jwt'; import { Response } from 'express'; import type { Repository } from 'typeorm'; import type { ILogger } from 'n8n-workflow'; -import { AuthenticatedRequest, MeRequest } from '@/requests'; +import { AuthenticatedRequest, MeRequest, UserUpdatePayload } from '@/requests'; import type { PublicUser, IDatabaseCollections, @@ -61,38 +62,40 @@ export class MeController { * Update the logged-in user's settings, except password. */ @Patch('/') - async updateCurrentUser(req: MeRequest.Settings, res: Response): Promise { - const { email } = req.body; + async updateCurrentUser(req: MeRequest.UserUpdate, res: Response): Promise { + const { id: userId, email: currentEmail } = req.user; + const payload = plainToInstance(UserUpdatePayload, req.body); + + const { email } = payload; if (!email) { this.logger.debug('Request to update user email failed because of missing email in payload', { - userId: req.user.id, - payload: req.body, + userId, + payload, }); throw new BadRequestError('Email is mandatory'); } if (!validator.isEmail(email)) { this.logger.debug('Request to update user email failed because of invalid email in payload', { - userId: req.user.id, + userId, invalidEmail: email, }); throw new BadRequestError('Invalid email address'); } - const { email: currentEmail } = req.user; - const newUser = new User(); - - Object.assign(newUser, req.user, req.body); + await validateEntity(payload); - await validateEntity(newUser); - - const user = await this.userRepository.save(newUser); + await this.userRepository.update(userId, payload); + const user = await this.userRepository.findOneOrFail({ + where: { id: userId }, + relations: { globalRole: true }, + }); - this.logger.info('User updated successfully', { userId: user.id }); + this.logger.info('User updated successfully', { userId }); await issueCookie(res, user); - const updatedKeys = Object.keys(req.body); + const updatedKeys = Object.keys(payload); void this.internalHooks.onUserUpdate({ user, fields_changed: updatedKeys, diff --git a/packages/cli/src/controllers/translation.controller.ts b/packages/cli/src/controllers/translation.controller.ts new file mode 100644 index 0000000000000..8d24f9e11365e --- /dev/null +++ b/packages/cli/src/controllers/translation.controller.ts @@ -0,0 +1,58 @@ +import type { Request } from 'express'; +import { ICredentialTypes } from 'n8n-workflow'; +import { join } from 'path'; +import { access } from 'fs/promises'; +import { Get, RestController } from '@/decorators'; +import { BadRequestError, InternalServerError } from '@/ResponseHelper'; +import { Config } from '@/config'; +import { NODES_BASE_DIR } from '@/constants'; + +export const CREDENTIAL_TRANSLATIONS_DIR = 'n8n-nodes-base/dist/credentials/translations'; +export const NODE_HEADERS_PATH = join(NODES_BASE_DIR, 'dist/nodes/headers'); + +export declare namespace TranslationRequest { + export type Credential = Request<{}, {}, {}, { credentialType: string }>; +} + +@RestController('/') +export class TranslationController { + constructor(private config: Config, private credentialTypes: ICredentialTypes) {} + + @Get('/credential-translation') + async getCredentialTranslation(req: TranslationRequest.Credential) { + const { credentialType } = req.query; + + if (!this.credentialTypes.recognizes(credentialType)) + throw new BadRequestError(`Invalid Credential type: "${credentialType}"`); + + const defaultLocale = this.config.getEnv('defaultLocale'); + const translationPath = join( + CREDENTIAL_TRANSLATIONS_DIR, + defaultLocale, + `${credentialType}.json`, + ); + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return require(translationPath); + } catch (error) { + return null; + } + } + + @Get('/node-translation-headers') + async getNodeTranslationHeaders() { + try { + await access(`${NODE_HEADERS_PATH}.js`); + } catch (_) { + return; // no headers available + } + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return require(NODE_HEADERS_PATH); + } catch (error) { + throw new InternalServerError('Failed to load headers file'); + } + } +} diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 6cba438f7942a..d62bf8482fc26 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -111,6 +111,9 @@ export class User extends AbstractEntity implements IUser { @AfterLoad() @AfterUpdate() computeIsPending(): void { - this.isPending = this.password === null; + this.isPending = + this.globalRole?.name === 'owner' && this.globalRole.scope === 'global' + ? false + : this.password === null; } } diff --git a/packages/cli/src/middlewares/auth.ts b/packages/cli/src/middlewares/auth.ts index a0c13d4f2176a..070b86dd09e13 100644 --- a/packages/cli/src/middlewares/auth.ts +++ b/packages/cli/src/middlewares/auth.ts @@ -3,11 +3,12 @@ import jwt from 'jsonwebtoken'; import cookieParser from 'cookie-parser'; import passport from 'passport'; import { Strategy } from 'passport-jwt'; +import { sync as globSync } from 'fast-glob'; import { LoggerProxy as Logger } from 'n8n-workflow'; import type { JwtPayload } from '@/Interfaces'; import type { AuthenticatedRequest } from '@/requests'; import config from '@/config'; -import { AUTH_COOKIE_NAME } from '@/constants'; +import { AUTH_COOKIE_NAME, EDITOR_UI_DIST_DIR } from '@/constants'; import { issueCookie, resolveJwtContent } from '@/auth/jwt'; import { isAuthenticatedRequest, @@ -61,6 +62,10 @@ const refreshExpiringCookie: RequestHandler = async (req: AuthenticatedRequest, const passportMiddleware = passport.authenticate('jwt', { session: false }) as RequestHandler; +const staticAssets = globSync(['**/*.html', '**/*.svg', '**/*.png', '**/*.ico'], { + cwd: EDITOR_UI_DIST_DIR, +}); + /** * This sets up the auth middlewares in the correct order */ @@ -79,12 +84,7 @@ export const setupAuthMiddlewares = ( // TODO: refactor me!!! // skip authentication for preflight requests req.method === 'OPTIONS' || - req.url === '/index.html' || - req.url === '/favicon.ico' || - req.url.startsWith('/css/') || - req.url.startsWith('/js/') || - req.url.startsWith('/fonts/') || - req.url.includes('.svg') || + staticAssets.includes(req.url.slice(1)) || req.url.startsWith(`/${restEndpoint}/settings`) || req.url.startsWith(`/${restEndpoint}/login`) || req.url.startsWith(`/${restEndpoint}/logout`) || diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index e0636d7611ac0..9171d2f9c32f7 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -10,11 +10,28 @@ import type { IWorkflowSettings, } from 'n8n-workflow'; +import { IsEmail, IsString, Length } from 'class-validator'; +import { NoXss } from '@db/utils/customValidators'; import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import type * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer'; +export class UserUpdatePayload implements Pick { + @IsEmail() + email: string; + + @NoXss() + @IsString({ message: 'First name must be of type string.' }) + @Length(1, 32, { message: 'First name must be $constraint1 to $constraint2 characters long.' }) + firstName: string; + + @NoXss() + @IsString({ message: 'Last name must be of type string.' }) + @Length(1, 32, { message: 'Last name must be $constraint1 to $constraint2 characters long.' }) + lastName: string; +} + export type AuthlessRequest< RouteParams = {}, ResponseBody = {}, @@ -144,11 +161,7 @@ export declare namespace ExecutionRequest { // ---------------------------------- export declare namespace MeRequest { - export type Settings = AuthenticatedRequest< - {}, - {}, - Pick - >; + export type UserUpdate = AuthenticatedRequest<{}, {}, UserUpdatePayload>; export type Password = AuthenticatedRequest< {}, {}, diff --git a/packages/cli/test/setup-mocks.ts b/packages/cli/test/setup-mocks.ts index 1a9bb4e9c0a15..c6db2d147cdc8 100644 --- a/packages/cli/test/setup-mocks.ts +++ b/packages/cli/test/setup-mocks.ts @@ -1,3 +1,5 @@ +import 'reflect-metadata'; + jest.mock('@sentry/node'); jest.mock('@n8n_io/license-sdk'); jest.mock('@/telemetry'); diff --git a/packages/cli/test/unit/controllers/me.controller.test.ts b/packages/cli/test/unit/controllers/me.controller.test.ts index 1606915398c57..de57d031a9c25 100644 --- a/packages/cli/test/unit/controllers/me.controller.test.ts +++ b/packages/cli/test/unit/controllers/me.controller.test.ts @@ -28,40 +28,74 @@ describe('MeController', () => { describe('updateCurrentUser', () => { it('should throw BadRequestError if email is missing in the payload', async () => { - const req = mock({}); + const req = mock({}); expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError( new BadRequestError('Email is mandatory'), ); }); it('should throw BadRequestError if email is invalid', async () => { - const req = mock({ body: { email: 'invalid-email' } }); + const req = mock({ body: { email: 'invalid-email' } }); expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError( new BadRequestError('Invalid email address'), ); }); it('should update the user in the DB, and issue a new cookie', async () => { - const req = mock({ - user: mock({ id: '123', password: 'password', authIdentities: [] }), - body: { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }, + const user = mock({ + id: '123', + password: 'password', + authIdentities: [], + globalRoleId: '1', }); + const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; + const req = mock({ user, body: reqBody }); const res = mock(); - userRepository.save.calledWith(anyObject()).mockResolvedValue(req.user); + userRepository.findOneOrFail.mockResolvedValue(user); jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); await controller.updateCurrentUser(req, res); + expect(userRepository.update).toHaveBeenCalled(); + const cookieOptions = captor(); expect(res.cookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME, 'signed-token', cookieOptions); expect(cookieOptions.value.httpOnly).toBe(true); expect(cookieOptions.value.sameSite).toBe('lax'); expect(externalHooks.run).toHaveBeenCalledWith('user.profile.update', [ - req.user.email, + user.email, anyObject(), ]); }); + + it('should not allow updating any other fields on a user besides email and name', async () => { + const user = mock({ + id: '123', + password: 'password', + authIdentities: [], + globalRoleId: '1', + }); + const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; + const req = mock({ user, body: reqBody }); + const res = mock(); + userRepository.findOneOrFail.mockResolvedValue(user); + jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); + + // Add invalid data to the request payload + Object.assign(reqBody, { id: '0', globalRoleId: '42' }); + + await controller.updateCurrentUser(req, res); + + expect(userRepository.update).toHaveBeenCalled(); + + const updatedUser = userRepository.update.mock.calls[0][1]; + expect(updatedUser.email).toBe(reqBody.email); + expect(updatedUser.firstName).toBe(reqBody.firstName); + expect(updatedUser.lastName).toBe(reqBody.lastName); + expect(updatedUser.id).not.toBe('0'); + expect(updatedUser.globalRoleId).not.toBe('42'); + }); }); describe('updatePassword', () => { diff --git a/packages/cli/test/unit/controllers/translation.controller.test.ts b/packages/cli/test/unit/controllers/translation.controller.test.ts new file mode 100644 index 0000000000000..a24d462f81818 --- /dev/null +++ b/packages/cli/test/unit/controllers/translation.controller.test.ts @@ -0,0 +1,40 @@ +import { mock } from 'jest-mock-extended'; +import type { ICredentialTypes } from 'n8n-workflow'; +import type { Config } from '@/config'; +import { + TranslationController, + TranslationRequest, + CREDENTIAL_TRANSLATIONS_DIR, +} from '@/controllers/translation.controller'; +import { BadRequestError } from '@/ResponseHelper'; + +describe('TranslationController', () => { + const config = mock(); + const credentialTypes = mock(); + const controller = new TranslationController(config, credentialTypes); + + describe('getCredentialTranslation', () => { + it('should throw 400 on invalid credential types', async () => { + const credentialType = 'not-a-valid-credential-type'; + const req = mock({ query: { credentialType } }); + credentialTypes.recognizes.calledWith(credentialType).mockReturnValue(false); + + expect(controller.getCredentialTranslation(req)).rejects.toThrowError( + new BadRequestError(`Invalid Credential type: "${credentialType}"`), + ); + }); + + it('should return translation json on valid credential types', async () => { + const credentialType = 'credential-type'; + const req = mock({ query: { credentialType } }); + config.getEnv.calledWith('defaultLocale').mockReturnValue('de'); + credentialTypes.recognizes.calledWith(credentialType).mockReturnValue(true); + const response = { translation: 'string' }; + jest.mock(`${CREDENTIAL_TRANSLATIONS_DIR}/de/credential-type.json`, () => response, { + virtual: true, + }); + + expect(await controller.getCredentialTranslation(req)).toEqual(response); + }); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 96585c2491c71..60ec9df01be8a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.155.0", + "version": "0.155.1", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/core/src/BinaryDataManager/FileSystem.ts b/packages/core/src/BinaryDataManager/FileSystem.ts index b66093b3d2f01..df033569752f1 100644 --- a/packages/core/src/BinaryDataManager/FileSystem.ts +++ b/packages/core/src/BinaryDataManager/FileSystem.ts @@ -7,6 +7,7 @@ import type { BinaryMetadata } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; import type { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces'; +import { FileNotFoundError } from '../errors'; const PREFIX_METAFILE = 'binarymeta'; const PREFIX_PERSISTED_METAFILE = 'persistedmeta'; @@ -85,17 +86,17 @@ export class BinaryDataFileSystem implements IBinaryDataManager { } getBinaryPath(identifier: string): string { - return path.join(this.storagePath, identifier); + return this.resolveStoragePath(identifier); } getMetadataPath(identifier: string): string { - return path.join(this.storagePath, `${identifier}.metadata`); + return this.resolveStoragePath(`${identifier}.metadata`); } async markDataForDeletionByExecutionId(executionId: string): Promise { const tt = new Date(new Date().getTime() + this.binaryDataTTL * 60000); return fs.writeFile( - path.join(this.getBinaryDataMetaPath(), `${PREFIX_METAFILE}_${executionId}_${tt.valueOf()}`), + this.resolveStoragePath('meta', `${PREFIX_METAFILE}_${executionId}_${tt.valueOf()}`), '', ); } @@ -116,8 +117,8 @@ export class BinaryDataFileSystem implements IBinaryDataManager { const timeAtNextHour = currentTime + 3600000 - (currentTime % 3600000); const timeoutTime = timeAtNextHour + this.persistedBinaryDataTTL * 60000; - const filePath = path.join( - this.getBinaryDataPersistMetaPath(), + const filePath = this.resolveStoragePath( + 'persistMeta', `${PREFIX_PERSISTED_METAFILE}_${executionId}_${timeoutTime}`, ); @@ -170,21 +171,18 @@ export class BinaryDataFileSystem implements IBinaryDataManager { const newBinaryDataId = this.generateFileName(prefix); return fs - .copyFile( - path.join(this.storagePath, binaryDataId), - path.join(this.storagePath, newBinaryDataId), - ) + .copyFile(this.resolveStoragePath(binaryDataId), this.resolveStoragePath(newBinaryDataId)) .then(() => newBinaryDataId); } async deleteBinaryDataByExecutionId(executionId: string): Promise { const regex = new RegExp(`${executionId}_*`); - const filenames = await fs.readdir(path.join(this.storagePath)); + const filenames = await fs.readdir(this.storagePath); const proms = filenames.reduce( (allProms, filename) => { if (regex.test(filename)) { - allProms.push(fs.rm(path.join(this.storagePath, filename))); + allProms.push(fs.rm(this.resolveStoragePath(filename))); } return allProms; @@ -253,4 +251,11 @@ export class BinaryDataFileSystem implements IBinaryDataManager { throw new Error(`Error finding file: ${filePath}`); } } + + private resolveStoragePath(...args: string[]) { + const returnPath = path.join(this.storagePath, ...args); + if (path.relative(this.storagePath, returnPath).startsWith('..')) + throw new FileNotFoundError('Invalid path detected'); + return returnPath; + } } diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000000000..c425675c89371 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,5 @@ +export class FileNotFoundError extends Error { + constructor(readonly filePath: string) { + super(`File not found: ${filePath}`); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cef794cfa5835..7a77667f595a5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,7 @@ export * from './LoadNodeListSearch'; export * from './NodeExecuteFunctions'; export * from './WorkflowExecute'; export { eventEmitter, NodeExecuteFunctions, UserSettings }; +export * from './errors'; declare module 'http' { export interface IncomingMessage { diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 56c76aaa1c4d0..7863b7193acf3 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.182.0", + "version": "0.182.1", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index b873b7c64f211..0447533dbfd4e 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "0.94.0", + "version": "0.94.1", "description": "CLI to simplify n8n credentials/node development", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 73ef88e91fe48..f83852c5b2ec3 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.214.0", + "version": "0.214.1", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 0a89d973c0287..ee1bc2cb27561 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "0.137.0", + "version": "0.137.1", "description": "Workflow base code of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5ef413748501..137ae4a86e183 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,6 +163,7 @@ importers: callsites: ^3.1.0 change-case: ^4.1.1 chokidar: 3.5.2 + class-transformer: ^0.5.1 class-validator: ^0.14.0 client-oauth2: ^4.2.5 compression: ^1.7.4 @@ -259,6 +260,7 @@ importers: bull: 4.10.2 callsites: 3.1.0 change-case: 4.1.2 + class-transformer: 0.5.1 class-validator: 0.14.0 client-oauth2: 4.3.3 compression: 1.7.4 @@ -4136,7 +4138,7 @@ packages: dependencies: '@storybook/client-logger': 7.0.0-beta.46 '@storybook/core-events': 7.0.0-beta.46 - '@storybook/csf': 0.0.2-next.9 + '@storybook/csf': 0.0.2-next.10 '@storybook/global': 5.0.0 '@storybook/manager-api': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe '@storybook/preview-api': 7.0.0-beta.46 @@ -4344,7 +4346,7 @@ packages: '@storybook/client-logger': 7.0.0-beta.46 '@storybook/components': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe '@storybook/core-events': 7.0.0-beta.46 - '@storybook/csf': 0.0.2-next.9 + '@storybook/csf': 0.0.2-next.10 '@storybook/docs-tools': 7.0.0-beta.46 '@storybook/global': 5.0.0 '@storybook/manager-api': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe @@ -4564,7 +4566,7 @@ packages: '@babel/core': 7.20.12 '@babel/preset-env': 7.20.2_@babel+core@7.20.12 '@babel/types': 7.20.7 - '@storybook/csf': 0.0.2-next.9 + '@storybook/csf': 0.0.2-next.10 '@storybook/csf-tools': 7.0.0-beta.46 '@storybook/node-logger': 7.0.0-beta.46 '@storybook/types': 7.0.0-beta.46 @@ -4604,7 +4606,7 @@ packages: react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: '@storybook/client-logger': 7.0.0-beta.46 - '@storybook/csf': 0.0.2-next.9 + '@storybook/csf': 0.0.2-next.10 '@storybook/global': 5.0.0 '@storybook/theming': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe '@storybook/types': 7.0.0-beta.46 @@ -4675,7 +4677,7 @@ packages: '@storybook/builder-manager': 7.0.0-beta.46 '@storybook/core-common': 7.0.0-beta.46 '@storybook/core-events': 7.0.0-beta.46 - '@storybook/csf': 0.0.2-next.9 + '@storybook/csf': 0.0.2-next.10 '@storybook/csf-tools': 7.0.0-beta.46 '@storybook/docs-mdx': 0.0.1-next.6 '@storybook/global': 5.0.0 @@ -4745,7 +4747,7 @@ packages: resolution: {integrity: sha512-H7zXfL1wf/1jWi5MaFISt/taxE41fgpV/uLfi5CHcHLX9ZgeQs2B/2utpUgwvBsxiL+E/jKAt5cLeuZCIvglMg==} dependencies: '@babel/types': 7.20.7 - '@storybook/csf': 0.0.2-next.9 + '@storybook/csf': 0.0.2-next.10 '@storybook/types': 7.0.0-beta.46 fs-extra: 11.1.0 recast: 0.23.1 @@ -4760,8 +4762,8 @@ packages: lodash: 4.17.21 dev: true - /@storybook/csf/0.0.2-next.9: - resolution: {integrity: sha512-ECOLMK425s+z8oA0aVAhBhhquuwTsZrM4oha/5De44JG8uYGXhqVrv/l27oxZEkwytuiQu+9f65HxYli+DY+3w==} + /@storybook/csf/0.0.2-next.10: + resolution: {integrity: sha512-m2PFgBP/xRIF85VrDhvesn9ktaD2pN3VUjvMqkAL/cINp/3qXsCyI81uw7N5VEOkQAbWrY2FcydnvEPDEdE8fA==} dependencies: type-fest: 2.19.0 dev: true @@ -4797,7 +4799,7 @@ packages: '@storybook/channels': 7.0.0-beta.46 '@storybook/client-logger': 7.0.0-beta.46 '@storybook/core-events': 7.0.0-beta.46 - '@storybook/csf': 0.0.2-next.9 + '@storybook/csf': 0.0.2-next.10 '@storybook/global': 5.0.0 '@storybook/router': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe '@storybook/theming': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe @@ -4887,7 +4889,7 @@ packages: '@storybook/channels': 7.0.0-beta.46 '@storybook/client-logger': 7.0.0-beta.46 '@storybook/core-events': 7.0.0-beta.46 - '@storybook/csf': 0.0.2-next.9 + '@storybook/csf': 0.0.2-next.10 '@storybook/global': 5.0.0 '@storybook/types': 7.0.0-beta.46 '@types/qs': 6.9.7 @@ -8088,6 +8090,10 @@ packages: resolution: {integrity: sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==} dev: false + /class-transformer/0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + dev: false + /class-utils/0.3.6: resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==} engines: {node: '>=0.10.0'}