🚀 Release 0.216.1 (#5531)

* 🚀 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)

* 📚 Update CHANGELOG.md

---------

Co-authored-by: janober <janober@users.noreply.github.com>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <netroy@users.noreply.github.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
github-actions[bot] 2023-02-21 14:24:02 +01:00 committed by GitHub
parent fe782c8f6a
commit 7400c35a48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 272 additions and 138 deletions

View file

@ -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) # [0.216.0](https://github.com/n8n-io/n8n/compare/n8n@0.215.2...n8n@0.216.0) (2023-02-16)

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "0.216.0", "version": "0.216.1",
"private": true, "private": true,
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
"engines": { "engines": {

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "0.216.0", "version": "0.216.1",
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -127,6 +127,7 @@
"callsites": "^3.1.0", "callsites": "^3.1.0",
"change-case": "^4.1.1", "change-case": "^4.1.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"class-transformer": "^0.5.1",
"client-oauth2": "^4.2.5", "client-oauth2": "^4.2.5",
"compression": "^1.7.4", "compression": "^1.7.4",
"connect-history-api-fallback": "^1.6.0", "connect-history-api-fallback": "^1.6.0",

View file

@ -22,6 +22,7 @@ import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { TagEntity } from '@db/entities/TagEntity'; import type { TagEntity } from '@db/entities/TagEntity';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import type { UserUpdatePayload } from '@/requests';
/** /**
* Returns the base URL n8n is reachable from * Returns the base URL n8n is reachable from
@ -99,7 +100,7 @@ export async function generateUniqueName(
} }
export async function validateEntity( export async function validateEntity(
entity: WorkflowEntity | CredentialsEntity | TagEntity | User, entity: WorkflowEntity | CredentialsEntity | TagEntity | User | UserUpdatePayload,
): Promise<void> { ): Promise<void> {
const errors = await validate(entity); const errors = await validate(entity);

View file

@ -33,6 +33,7 @@ import {
LoadNodeParameterOptions, LoadNodeParameterOptions,
LoadNodeListSearch, LoadNodeListSearch,
UserSettings, UserSettings,
FileNotFoundError,
} from 'n8n-core'; } from 'n8n-core';
import type { import type {
@ -55,7 +56,6 @@ import history from 'connect-history-api-fallback';
import config from '@/config'; import config from '@/config';
import * as Queue from '@/Queue'; import * as Queue from '@/Queue';
import { InternalHooksManager } from '@/InternalHooksManager'; import { InternalHooksManager } from '@/InternalHooksManager';
import { getCredentialTranslationPath } from '@/TranslationHelpers';
import { getSharedWorkflowIds } from '@/WorkflowHelpers'; import { getSharedWorkflowIds } from '@/WorkflowHelpers';
import { nodesController } from '@/api/nodes.api'; import { nodesController } from '@/api/nodes.api';
@ -86,6 +86,7 @@ import {
MeController, MeController,
OwnerController, OwnerController,
PasswordResetController, PasswordResetController,
TranslationController,
UsersController, UsersController,
} from '@/controllers'; } from '@/controllers';
@ -347,6 +348,7 @@ class Server extends AbstractServer {
new OwnerController({ config, internalHooks, repositories, logger }), new OwnerController({ config, internalHooks, repositories, logger }),
new MeController({ externalHooks, internalHooks, repositories, logger }), new MeController({ externalHooks, internalHooks, repositories, logger }),
new PasswordResetController({ config, externalHooks, internalHooks, repositories, logger }), new PasswordResetController({ config, externalHooks, internalHooks, repositories, logger }),
new TranslationController(config, this.credentialTypes),
new UsersController({ new UsersController({
config, config,
mailer, 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<object | null> => {
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<object | void> => {
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 // Node-Types
// ---------------------------------------- // ----------------------------------------
@ -1160,6 +1120,7 @@ class Server extends AbstractServer {
// TODO UM: check if this needs permission check for UM // TODO UM: check if this needs permission check for UM
const identifier = req.params.path; const identifier = req.params.path;
const binaryDataManager = BinaryDataManager.getInstance(); const binaryDataManager = BinaryDataManager.getInstance();
try {
const binaryPath = binaryDataManager.getBinaryPath(identifier); const binaryPath = binaryDataManager.getBinaryPath(identifier);
let { mode, fileName, mimeType } = req.query; let { mode, fileName, mimeType } = req.query;
if (!fileName || !mimeType) { if (!fileName || !mimeType) {
@ -1175,6 +1136,10 @@ class Server extends AbstractServer {
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
} }
res.sendFile(binaryPath); res.sendFile(binaryPath);
} catch (error) {
if (error instanceof FileNotFoundError) res.writeHead(404).end();
else throw error;
}
}, },
); );

View file

@ -1,7 +1,6 @@
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { readdir } from 'fs/promises'; import { readdir } from 'fs/promises';
import type { Dirent } from 'fs'; import type { Dirent } from 'fs';
import { NODES_BASE_DIR } from '@/constants';
const ALLOWED_VERSIONED_DIRNAME_LENGTH = [2, 3]; // e.g. v1, v10 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, `v${maxVersion}`, 'translations', locale, `${nodeType}.json`)
: join(nodeDir, '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`);
}

View file

@ -2,4 +2,5 @@ export { AuthController } from './auth.controller';
export { MeController } from './me.controller'; export { MeController } from './me.controller';
export { OwnerController } from './owner.controller'; export { OwnerController } from './owner.controller';
export { PasswordResetController } from './passwordReset.controller'; export { PasswordResetController } from './passwordReset.controller';
export { TranslationController } from './translation.controller';
export { UsersController } from './users.controller'; export { UsersController } from './users.controller';

View file

@ -1,4 +1,5 @@
import validator from 'validator'; import validator from 'validator';
import { plainToInstance } from 'class-transformer';
import { Delete, Get, Patch, Post, RestController } from '@/decorators'; import { Delete, Get, Patch, Post, RestController } from '@/decorators';
import { import {
compareHash, compareHash,
@ -7,13 +8,13 @@ import {
validatePassword, validatePassword,
} from '@/UserManagement/UserManagementHelper'; } from '@/UserManagement/UserManagementHelper';
import { BadRequestError } from '@/ResponseHelper'; import { BadRequestError } from '@/ResponseHelper';
import { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { validateEntity } from '@/GenericHelpers'; import { validateEntity } from '@/GenericHelpers';
import { issueCookie } from '@/auth/jwt'; import { issueCookie } from '@/auth/jwt';
import { Response } from 'express'; import { Response } from 'express';
import type { Repository } from 'typeorm'; import type { Repository } from 'typeorm';
import type { ILogger } from 'n8n-workflow'; import type { ILogger } from 'n8n-workflow';
import { AuthenticatedRequest, MeRequest } from '@/requests'; import { AuthenticatedRequest, MeRequest, UserUpdatePayload } from '@/requests';
import type { import type {
PublicUser, PublicUser,
IDatabaseCollections, IDatabaseCollections,
@ -61,38 +62,40 @@ export class MeController {
* Update the logged-in user's settings, except password. * Update the logged-in user's settings, except password.
*/ */
@Patch('/') @Patch('/')
async updateCurrentUser(req: MeRequest.Settings, res: Response): Promise<PublicUser> { async updateCurrentUser(req: MeRequest.UserUpdate, res: Response): Promise<PublicUser> {
const { email } = req.body; const { id: userId, email: currentEmail } = req.user;
const payload = plainToInstance(UserUpdatePayload, req.body);
const { email } = payload;
if (!email) { if (!email) {
this.logger.debug('Request to update user email failed because of missing email in payload', { this.logger.debug('Request to update user email failed because of missing email in payload', {
userId: req.user.id, userId,
payload: req.body, payload,
}); });
throw new BadRequestError('Email is mandatory'); throw new BadRequestError('Email is mandatory');
} }
if (!validator.isEmail(email)) { if (!validator.isEmail(email)) {
this.logger.debug('Request to update user email failed because of invalid email in payload', { this.logger.debug('Request to update user email failed because of invalid email in payload', {
userId: req.user.id, userId,
invalidEmail: email, invalidEmail: email,
}); });
throw new BadRequestError('Invalid email address'); throw new BadRequestError('Invalid email address');
} }
const { email: currentEmail } = req.user; await validateEntity(payload);
const newUser = new User();
Object.assign(newUser, req.user, req.body); await this.userRepository.update(userId, payload);
const user = await this.userRepository.findOneOrFail({
where: { id: userId },
relations: { globalRole: true },
});
await validateEntity(newUser); this.logger.info('User updated successfully', { userId });
const user = await this.userRepository.save(newUser);
this.logger.info('User updated successfully', { userId: user.id });
await issueCookie(res, user); await issueCookie(res, user);
const updatedKeys = Object.keys(req.body); const updatedKeys = Object.keys(payload);
void this.internalHooks.onUserUpdate({ void this.internalHooks.onUserUpdate({
user, user,
fields_changed: updatedKeys, fields_changed: updatedKeys,

View file

@ -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');
}
}
}

View file

@ -111,6 +111,9 @@ export class User extends AbstractEntity implements IUser {
@AfterLoad() @AfterLoad()
@AfterUpdate() @AfterUpdate()
computeIsPending(): void { computeIsPending(): void {
this.isPending = this.password === null; this.isPending =
this.globalRole?.name === 'owner' && this.globalRole.scope === 'global'
? false
: this.password === null;
} }
} }

View file

@ -3,11 +3,12 @@ import jwt from 'jsonwebtoken';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import passport from 'passport'; import passport from 'passport';
import { Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { sync as globSync } from 'fast-glob';
import { LoggerProxy as Logger } from 'n8n-workflow'; import { LoggerProxy as Logger } from 'n8n-workflow';
import type { JwtPayload } from '@/Interfaces'; import type { JwtPayload } from '@/Interfaces';
import type { AuthenticatedRequest } from '@/requests'; import type { AuthenticatedRequest } from '@/requests';
import config from '@/config'; 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 { issueCookie, resolveJwtContent } from '@/auth/jwt';
import { import {
isAuthenticatedRequest, isAuthenticatedRequest,
@ -61,6 +62,10 @@ const refreshExpiringCookie: RequestHandler = async (req: AuthenticatedRequest,
const passportMiddleware = passport.authenticate('jwt', { session: false }) as RequestHandler; 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 * This sets up the auth middlewares in the correct order
*/ */
@ -79,12 +84,7 @@ export const setupAuthMiddlewares = (
// TODO: refactor me!!! // TODO: refactor me!!!
// skip authentication for preflight requests // skip authentication for preflight requests
req.method === 'OPTIONS' || req.method === 'OPTIONS' ||
req.url === '/index.html' || staticAssets.includes(req.url.slice(1)) ||
req.url === '/favicon.ico' ||
req.url.startsWith('/css/') ||
req.url.startsWith('/js/') ||
req.url.startsWith('/fonts/') ||
req.url.includes('.svg') ||
req.url.startsWith(`/${restEndpoint}/settings`) || req.url.startsWith(`/${restEndpoint}/settings`) ||
req.url.startsWith(`/${restEndpoint}/login`) || req.url.startsWith(`/${restEndpoint}/login`) ||
req.url.startsWith(`/${restEndpoint}/logout`) || req.url.startsWith(`/${restEndpoint}/logout`) ||

View file

@ -10,11 +10,28 @@ import type {
IWorkflowSettings, IWorkflowSettings,
} from 'n8n-workflow'; } 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 { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import type * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer'; import type * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer';
export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'lastName'> {
@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< export type AuthlessRequest<
RouteParams = {}, RouteParams = {},
ResponseBody = {}, ResponseBody = {},
@ -144,11 +161,7 @@ export declare namespace ExecutionRequest {
// ---------------------------------- // ----------------------------------
export declare namespace MeRequest { export declare namespace MeRequest {
export type Settings = AuthenticatedRequest< export type UserUpdate = AuthenticatedRequest<{}, {}, UserUpdatePayload>;
{},
{},
Pick<PublicUser, 'email' | 'firstName' | 'lastName'>
>;
export type Password = AuthenticatedRequest< export type Password = AuthenticatedRequest<
{}, {},
{}, {},

View file

@ -1,3 +1,5 @@
import 'reflect-metadata';
jest.mock('@sentry/node'); jest.mock('@sentry/node');
jest.mock('@n8n_io/license-sdk'); jest.mock('@n8n_io/license-sdk');
jest.mock('@/telemetry'); jest.mock('@/telemetry');

View file

@ -28,40 +28,74 @@ describe('MeController', () => {
describe('updateCurrentUser', () => { describe('updateCurrentUser', () => {
it('should throw BadRequestError if email is missing in the payload', async () => { it('should throw BadRequestError if email is missing in the payload', async () => {
const req = mock<MeRequest.Settings>({}); const req = mock<MeRequest.UserUpdate>({});
expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError( expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError(
new BadRequestError('Email is mandatory'), new BadRequestError('Email is mandatory'),
); );
}); });
it('should throw BadRequestError if email is invalid', async () => { it('should throw BadRequestError if email is invalid', async () => {
const req = mock<MeRequest.Settings>({ body: { email: 'invalid-email' } }); const req = mock<MeRequest.UserUpdate>({ body: { email: 'invalid-email' } });
expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError( expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError(
new BadRequestError('Invalid email address'), new BadRequestError('Invalid email address'),
); );
}); });
it('should update the user in the DB, and issue a new cookie', async () => { it('should update the user in the DB, and issue a new cookie', async () => {
const req = mock<MeRequest.Settings>({ const user = mock<User>({
user: mock({ id: '123', password: 'password', authIdentities: [] }), id: '123',
body: { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }, password: 'password',
authIdentities: [],
globalRoleId: '1',
}); });
const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' };
const req = mock<MeRequest.UserUpdate>({ user, body: reqBody });
const res = mock<Response>(); const res = mock<Response>();
userRepository.save.calledWith(anyObject()).mockResolvedValue(req.user); userRepository.findOneOrFail.mockResolvedValue(user);
jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
await controller.updateCurrentUser(req, res); await controller.updateCurrentUser(req, res);
expect(userRepository.update).toHaveBeenCalled();
const cookieOptions = captor<CookieOptions>(); const cookieOptions = captor<CookieOptions>();
expect(res.cookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME, 'signed-token', cookieOptions); expect(res.cookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME, 'signed-token', cookieOptions);
expect(cookieOptions.value.httpOnly).toBe(true); expect(cookieOptions.value.httpOnly).toBe(true);
expect(cookieOptions.value.sameSite).toBe('lax'); expect(cookieOptions.value.sameSite).toBe('lax');
expect(externalHooks.run).toHaveBeenCalledWith('user.profile.update', [ expect(externalHooks.run).toHaveBeenCalledWith('user.profile.update', [
req.user.email, user.email,
anyObject(), anyObject(),
]); ]);
}); });
it('should not allow updating any other fields on a user besides email and name', async () => {
const user = mock<User>({
id: '123',
password: 'password',
authIdentities: [],
globalRoleId: '1',
});
const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' };
const req = mock<MeRequest.UserUpdate>({ user, body: reqBody });
const res = mock<Response>();
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', () => { describe('updatePassword', () => {

View file

@ -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<Config>();
const credentialTypes = mock<ICredentialTypes>();
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<TranslationRequest.Credential>({ 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<TranslationRequest.Credential>({ 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);
});
});
});

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-core", "name": "n8n-core",
"version": "0.155.0", "version": "0.155.1",
"description": "Core functionality of n8n", "description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",

View file

@ -7,6 +7,7 @@ import type { BinaryMetadata } from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow';
import type { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces'; import type { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces';
import { FileNotFoundError } from '../errors';
const PREFIX_METAFILE = 'binarymeta'; const PREFIX_METAFILE = 'binarymeta';
const PREFIX_PERSISTED_METAFILE = 'persistedmeta'; const PREFIX_PERSISTED_METAFILE = 'persistedmeta';
@ -85,17 +86,17 @@ export class BinaryDataFileSystem implements IBinaryDataManager {
} }
getBinaryPath(identifier: string): string { getBinaryPath(identifier: string): string {
return path.join(this.storagePath, identifier); return this.resolveStoragePath(identifier);
} }
getMetadataPath(identifier: string): string { getMetadataPath(identifier: string): string {
return path.join(this.storagePath, `${identifier}.metadata`); return this.resolveStoragePath(`${identifier}.metadata`);
} }
async markDataForDeletionByExecutionId(executionId: string): Promise<void> { async markDataForDeletionByExecutionId(executionId: string): Promise<void> {
const tt = new Date(new Date().getTime() + this.binaryDataTTL * 60000); const tt = new Date(new Date().getTime() + this.binaryDataTTL * 60000);
return fs.writeFile( 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 timeAtNextHour = currentTime + 3600000 - (currentTime % 3600000);
const timeoutTime = timeAtNextHour + this.persistedBinaryDataTTL * 60000; const timeoutTime = timeAtNextHour + this.persistedBinaryDataTTL * 60000;
const filePath = path.join( const filePath = this.resolveStoragePath(
this.getBinaryDataPersistMetaPath(), 'persistMeta',
`${PREFIX_PERSISTED_METAFILE}_${executionId}_${timeoutTime}`, `${PREFIX_PERSISTED_METAFILE}_${executionId}_${timeoutTime}`,
); );
@ -170,21 +171,18 @@ export class BinaryDataFileSystem implements IBinaryDataManager {
const newBinaryDataId = this.generateFileName(prefix); const newBinaryDataId = this.generateFileName(prefix);
return fs return fs
.copyFile( .copyFile(this.resolveStoragePath(binaryDataId), this.resolveStoragePath(newBinaryDataId))
path.join(this.storagePath, binaryDataId),
path.join(this.storagePath, newBinaryDataId),
)
.then(() => newBinaryDataId); .then(() => newBinaryDataId);
} }
async deleteBinaryDataByExecutionId(executionId: string): Promise<void> { async deleteBinaryDataByExecutionId(executionId: string): Promise<void> {
const regex = new RegExp(`${executionId}_*`); 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( const proms = filenames.reduce(
(allProms, filename) => { (allProms, filename) => {
if (regex.test(filename)) { if (regex.test(filename)) {
allProms.push(fs.rm(path.join(this.storagePath, filename))); allProms.push(fs.rm(this.resolveStoragePath(filename)));
} }
return allProms; return allProms;
@ -253,4 +251,11 @@ export class BinaryDataFileSystem implements IBinaryDataManager {
throw new Error(`Error finding file: ${filePath}`); 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;
}
} }

View file

@ -0,0 +1,5 @@
export class FileNotFoundError extends Error {
constructor(readonly filePath: string) {
super(`File not found: ${filePath}`);
}
}

View file

@ -15,6 +15,7 @@ export * from './LoadNodeListSearch';
export * from './NodeExecuteFunctions'; export * from './NodeExecuteFunctions';
export * from './WorkflowExecute'; export * from './WorkflowExecute';
export { eventEmitter, NodeExecuteFunctions, UserSettings }; export { eventEmitter, NodeExecuteFunctions, UserSettings };
export * from './errors';
declare module 'http' { declare module 'http' {
export interface IncomingMessage { export interface IncomingMessage {

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "0.182.0", "version": "0.182.1",
"description": "Workflow Editor UI for n8n", "description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-node-dev", "name": "n8n-node-dev",
"version": "0.94.0", "version": "0.94.1",
"description": "CLI to simplify n8n credentials/node development", "description": "CLI to simplify n8n credentials/node development",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-nodes-base", "name": "n8n-nodes-base",
"version": "0.214.0", "version": "0.214.1",
"description": "Base nodes of n8n", "description": "Base nodes of n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-workflow", "name": "n8n-workflow",
"version": "0.137.0", "version": "0.137.1",
"description": "Workflow base code of n8n", "description": "Workflow base code of n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",

View file

@ -163,6 +163,7 @@ importers:
callsites: ^3.1.0 callsites: ^3.1.0
change-case: ^4.1.1 change-case: ^4.1.1
chokidar: 3.5.2 chokidar: 3.5.2
class-transformer: ^0.5.1
class-validator: ^0.14.0 class-validator: ^0.14.0
client-oauth2: ^4.2.5 client-oauth2: ^4.2.5
compression: ^1.7.4 compression: ^1.7.4
@ -259,6 +260,7 @@ importers:
bull: 4.10.2 bull: 4.10.2
callsites: 3.1.0 callsites: 3.1.0
change-case: 4.1.2 change-case: 4.1.2
class-transformer: 0.5.1
class-validator: 0.14.0 class-validator: 0.14.0
client-oauth2: 4.3.3 client-oauth2: 4.3.3
compression: 1.7.4 compression: 1.7.4
@ -4136,7 +4138,7 @@ packages:
dependencies: dependencies:
'@storybook/client-logger': 7.0.0-beta.46 '@storybook/client-logger': 7.0.0-beta.46
'@storybook/core-events': 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/global': 5.0.0
'@storybook/manager-api': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe '@storybook/manager-api': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe
'@storybook/preview-api': 7.0.0-beta.46 '@storybook/preview-api': 7.0.0-beta.46
@ -4344,7 +4346,7 @@ packages:
'@storybook/client-logger': 7.0.0-beta.46 '@storybook/client-logger': 7.0.0-beta.46
'@storybook/components': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe '@storybook/components': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe
'@storybook/core-events': 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/docs-tools': 7.0.0-beta.46 '@storybook/docs-tools': 7.0.0-beta.46
'@storybook/global': 5.0.0 '@storybook/global': 5.0.0
'@storybook/manager-api': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe '@storybook/manager-api': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe
@ -4564,7 +4566,7 @@ packages:
'@babel/core': 7.20.12 '@babel/core': 7.20.12
'@babel/preset-env': 7.20.2_@babel+core@7.20.12 '@babel/preset-env': 7.20.2_@babel+core@7.20.12
'@babel/types': 7.20.7 '@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/csf-tools': 7.0.0-beta.46
'@storybook/node-logger': 7.0.0-beta.46 '@storybook/node-logger': 7.0.0-beta.46
'@storybook/types': 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 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies: dependencies:
'@storybook/client-logger': 7.0.0-beta.46 '@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/global': 5.0.0
'@storybook/theming': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe '@storybook/theming': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe
'@storybook/types': 7.0.0-beta.46 '@storybook/types': 7.0.0-beta.46
@ -4675,7 +4677,7 @@ packages:
'@storybook/builder-manager': 7.0.0-beta.46 '@storybook/builder-manager': 7.0.0-beta.46
'@storybook/core-common': 7.0.0-beta.46 '@storybook/core-common': 7.0.0-beta.46
'@storybook/core-events': 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/csf-tools': 7.0.0-beta.46
'@storybook/docs-mdx': 0.0.1-next.6 '@storybook/docs-mdx': 0.0.1-next.6
'@storybook/global': 5.0.0 '@storybook/global': 5.0.0
@ -4745,7 +4747,7 @@ packages:
resolution: {integrity: sha512-H7zXfL1wf/1jWi5MaFISt/taxE41fgpV/uLfi5CHcHLX9ZgeQs2B/2utpUgwvBsxiL+E/jKAt5cLeuZCIvglMg==} resolution: {integrity: sha512-H7zXfL1wf/1jWi5MaFISt/taxE41fgpV/uLfi5CHcHLX9ZgeQs2B/2utpUgwvBsxiL+E/jKAt5cLeuZCIvglMg==}
dependencies: dependencies:
'@babel/types': 7.20.7 '@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 '@storybook/types': 7.0.0-beta.46
fs-extra: 11.1.0 fs-extra: 11.1.0
recast: 0.23.1 recast: 0.23.1
@ -4760,8 +4762,8 @@ packages:
lodash: 4.17.21 lodash: 4.17.21
dev: true dev: true
/@storybook/csf/0.0.2-next.9: /@storybook/csf/0.0.2-next.10:
resolution: {integrity: sha512-ECOLMK425s+z8oA0aVAhBhhquuwTsZrM4oha/5De44JG8uYGXhqVrv/l27oxZEkwytuiQu+9f65HxYli+DY+3w==} resolution: {integrity: sha512-m2PFgBP/xRIF85VrDhvesn9ktaD2pN3VUjvMqkAL/cINp/3qXsCyI81uw7N5VEOkQAbWrY2FcydnvEPDEdE8fA==}
dependencies: dependencies:
type-fest: 2.19.0 type-fest: 2.19.0
dev: true dev: true
@ -4797,7 +4799,7 @@ packages:
'@storybook/channels': 7.0.0-beta.46 '@storybook/channels': 7.0.0-beta.46
'@storybook/client-logger': 7.0.0-beta.46 '@storybook/client-logger': 7.0.0-beta.46
'@storybook/core-events': 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/global': 5.0.0
'@storybook/router': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe '@storybook/router': 7.0.0-beta.46_6l5554ty5ajsajah6yazvrjhoe
'@storybook/theming': 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/channels': 7.0.0-beta.46
'@storybook/client-logger': 7.0.0-beta.46 '@storybook/client-logger': 7.0.0-beta.46
'@storybook/core-events': 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/global': 5.0.0
'@storybook/types': 7.0.0-beta.46 '@storybook/types': 7.0.0-beta.46
'@types/qs': 6.9.7 '@types/qs': 6.9.7
@ -8088,6 +8090,10 @@ packages:
resolution: {integrity: sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==} resolution: {integrity: sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==}
dev: false dev: false
/class-transformer/0.5.1:
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
dev: false
/class-utils/0.3.6: /class-utils/0.3.6:
resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==} resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}