refactor(core): Abstract away InstanceSettings and encryptionKey into injectable services (no-changelog) (#7471)

This change ensures that things like `encryptionKey` and `instanceId`
are always available directly where they are needed, instead of passing
them around throughout the code.
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2023-10-23 13:39:35 +02:00
committed by GitHub
parent 519680c2cf
commit b6de910cbe
94 changed files with 501 additions and 1070 deletions

View File

@@ -0,0 +1,19 @@
import { Service } from 'typedi';
import { AES, enc } from 'crypto-js';
import { InstanceSettings } from './InstanceSettings';
@Service()
export class Cipher {
constructor(private readonly instanceSettings: InstanceSettings) {}
encrypt(data: string | object) {
return AES.encrypt(
typeof data === 'string' ? data : JSON.stringify(data),
this.instanceSettings.encryptionKey,
).toString();
}
decrypt(data: string) {
return AES.decrypt(data, this.instanceSettings.encryptionKey).toString(enc.Utf8);
}
}

View File

@@ -1,17 +1,6 @@
export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS';
export const DOWNLOADED_NODES_SUBDIRECTORY = 'nodes';
export const ENCRYPTION_KEY_ENV_OVERWRITE = 'N8N_ENCRYPTION_KEY';
export const EXTENSIONS_SUBDIRECTORY = 'custom';
export const USER_FOLDER_ENV_OVERWRITE = 'N8N_USER_FOLDER';
export const USER_SETTINGS_FILE_NAME = 'config';
export const USER_SETTINGS_SUBFOLDER = '.n8n';
export const PLACEHOLDER_EMPTY_EXECUTION_ID = '__UNKNOWN__';
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
export const TUNNEL_SUBDOMAIN_ENV = 'N8N_TUNNEL_SUBDOMAIN';
export const RESPONSE_ERROR_MESSAGES = {
NO_ENCRYPTION_KEY: 'Encryption key is missing or was not set',
};
export const CUSTOM_NODES_CATEGORY = 'Custom Nodes';

View File

@@ -1,13 +1,11 @@
import type {
CredentialInformation,
ICredentialDataDecryptedObject,
ICredentialsEncrypted,
} from 'n8n-workflow';
import { ICredentials } from 'n8n-workflow';
import { AES, enc } from 'crypto-js';
import { Container } from 'typedi';
import type { ICredentialDataDecryptedObject, ICredentialsEncrypted } from 'n8n-workflow';
import { ICredentials, jsonParse } from 'n8n-workflow';
import { Cipher } from './Cipher';
export class Credentials extends ICredentials {
private readonly cipher = Container.get(Cipher);
/**
* Returns if the given nodeType has access to data
*/
@@ -24,30 +22,14 @@ export class Credentials extends ICredentials {
/**
* Sets new credential object
*/
setData(data: ICredentialDataDecryptedObject, encryptionKey: string): void {
this.data = AES.encrypt(JSON.stringify(data), encryptionKey).toString();
}
/**
* Sets new credentials for given key
*/
setDataKey(key: string, data: CredentialInformation, encryptionKey: string): void {
let fullData;
try {
fullData = this.getData(encryptionKey);
} catch (e) {
fullData = {};
}
fullData[key] = data;
return this.setData(fullData, encryptionKey);
setData(data: ICredentialDataDecryptedObject): void {
this.data = this.cipher.encrypt(data);
}
/**
* Returns the decrypted credential object
*/
getData(encryptionKey: string, nodeType?: string): ICredentialDataDecryptedObject {
getData(nodeType?: string): ICredentialDataDecryptedObject {
if (nodeType && !this.hasNodeAccess(nodeType)) {
throw new Error(
`The node of type "${nodeType}" does not have access to credentials "${this.name}" of type "${this.type}".`,
@@ -58,11 +40,10 @@ export class Credentials extends ICredentials {
throw new Error('No data is set so nothing can be returned.');
}
const decryptedData = AES.decrypt(this.data, encryptionKey);
const decryptedData = this.cipher.decrypt(this.data);
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return JSON.parse(decryptedData.toString(enc.Utf8));
return jsonParse(decryptedData);
} catch (e) {
throw new Error(
'Credentials could not be decrypted. The likely reason is that a different "encryptionKey" was used to encrypt the data.',
@@ -70,23 +51,6 @@ export class Credentials extends ICredentials {
}
}
/**
* Returns the decrypted credentials for given key
*/
getDataKey(key: string, encryptionKey: string, nodeType?: string): CredentialInformation {
const fullData = this.getData(encryptionKey, nodeType);
if (fullData === null) {
throw new Error('No data was set.');
}
if (!fullData.hasOwnProperty(key)) {
throw new Error(`No data for key "${key}" exists.`);
}
return fullData[key];
}
/**
* Returns the encrypted credentials to be saved
*/

View File

@@ -0,0 +1,86 @@
import path from 'path';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { createHash, randomBytes } from 'crypto';
import { Service } from 'typedi';
import { jsonParse } from 'n8n-workflow';
interface ReadOnlySettings {
encryptionKey: string;
instanceId: string;
}
interface WritableSettings {
tunnelSubdomain?: string;
}
type Settings = ReadOnlySettings & WritableSettings;
@Service()
export class InstanceSettings {
readonly userHome = this.getUserHome();
/** The path to the n8n folder in which all n8n related data gets saved */
readonly n8nFolder = path.join(this.userHome, '.n8n');
/** The path to the folder containing custom nodes and credentials */
readonly customExtensionDir = path.join(this.n8nFolder, 'custom');
/** The path to the folder containing installed nodes (like community nodes) */
readonly nodesDownloadDir = path.join(this.n8nFolder, 'nodes');
private readonly settingsFile = path.join(this.n8nFolder, 'config');
private settings = this.loadOrCreate();
get encryptionKey() {
return this.settings.encryptionKey;
}
get instanceId() {
return this.settings.instanceId;
}
get tunnelSubdomain() {
return this.settings.tunnelSubdomain;
}
update(newSettings: WritableSettings) {
this.save({ ...this.settings, ...newSettings });
}
/**
* The home folder path of the user.
* If none can be found it falls back to the current working directory
*/
private getUserHome() {
const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
return process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd();
}
private loadOrCreate(): Settings {
const { settingsFile } = this;
if (existsSync(settingsFile)) {
const content = readFileSync(settingsFile, 'utf8');
return jsonParse(content, {
errorMessage: `Error parsing n8n-config file "${settingsFile}". It does not seem to be valid JSON.`,
});
}
// If file doesn't exist, create new settings
const encryptionKey = process.env.N8N_ENCRYPTION_KEY ?? randomBytes(24).toString('base64');
const instanceId = createHash('sha256')
.update(encryptionKey.slice(Math.round(encryptionKey.length / 2)))
.digest('hex');
const settings = { encryptionKey, instanceId };
mkdirSync(path.dirname(settingsFile));
this.save(settings);
console.log(`UserSettings were generated and saved to: ${settingsFile}`);
return settings;
}
private save(settings: Settings) {
this.settings = settings;
writeFileSync(this.settingsFile, JSON.stringify(settings, null, '\t'), 'utf-8');
}
}

View File

@@ -15,12 +15,6 @@ export interface IResponseError extends Error {
statusCode?: number;
}
export interface IUserSettings {
encryptionKey?: string;
tunnelSubdomain?: string;
instanceId?: string;
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
errorWorkflow?: string;
timezone?: string;

View File

@@ -3,14 +3,11 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-shadow */
import type {
ClientOAuth2Options,
ClientOAuth2RequestObject,
@@ -143,9 +140,9 @@ import {
setWorkflowExecutionMetadata,
} from './WorkflowExecutionMetadata';
import { getSecretsProxy } from './Secrets';
import { getUserN8nFolderPath, getInstanceId } from './UserSettings';
import Container from 'typedi';
import type { BinaryData } from './BinaryData/types';
import { InstanceSettings } from './InstanceSettings';
axios.defaults.timeout = 300000;
// Prevent axios from adding x-form-www-urlencoded headers by default
@@ -2510,7 +2507,7 @@ const getCommonWorkflowFunctions = (
getRestApiUrl: () => additionalData.restApiUrl,
getInstanceBaseUrl: () => additionalData.instanceBaseUrl,
getInstanceId: async () => getInstanceId(),
getInstanceId: () => Container.get(InstanceSettings).instanceId,
getTimezone: () => getTimezone(workflow, additionalData),
prepareOutputData: async (outputData) => [outputData],
@@ -2600,7 +2597,6 @@ const getAllowedPaths = () => {
function isFilePathBlocked(filePath: string): boolean {
const allowedPaths = getAllowedPaths();
const resolvedFilePath = path.resolve(filePath);
const userFolder = getUserN8nFolderPath();
const blockFileAccessToN8nFiles = process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] !== 'false';
//if allowed paths are defined, allow access only to those paths
@@ -2616,7 +2612,8 @@ function isFilePathBlocked(filePath: string): boolean {
//restrict access to .n8n folder and other .env config related paths
if (blockFileAccessToN8nFiles) {
const restrictedPaths: string[] = [userFolder];
const { n8nFolder } = Container.get(InstanceSettings);
const restrictedPaths = [n8nFolder];
if (process.env[CONFIG_FILES]) {
restrictedPaths.push(...process.env[CONFIG_FILES].split(','));
@@ -2674,7 +2671,7 @@ const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunctions =>
},
getStoragePath() {
return path.join(getUserN8nFolderPath(), `storage/${node.type}`);
return path.join(Container.get(InstanceSettings).n8nFolder, `storage/${node.type}`);
},
async writeContentToFile(filePath, content, flag) {

View File

@@ -1,272 +0,0 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import fs from 'fs';
import path from 'path';
import { createHash, randomBytes } from 'crypto';
import { promisify } from 'util';
import { deepCopy } from 'n8n-workflow';
import {
ENCRYPTION_KEY_ENV_OVERWRITE,
EXTENSIONS_SUBDIRECTORY,
DOWNLOADED_NODES_SUBDIRECTORY,
RESPONSE_ERROR_MESSAGES,
USER_FOLDER_ENV_OVERWRITE,
USER_SETTINGS_FILE_NAME,
USER_SETTINGS_SUBFOLDER,
} from './Constants';
import type { IUserSettings } from './Interfaces';
const fsAccess = promisify(fs.access);
const fsReadFile = promisify(fs.readFile);
const fsMkdir = promisify(fs.mkdir);
const fsWriteFile = promisify(fs.writeFile);
let settingsCache: IUserSettings | undefined;
/**
* Creates the user settings if they do not exist yet
*
*/
export async function prepareUserSettings(): Promise<IUserSettings> {
const settingsPath = getUserSettingsPath();
let userSettings = await getUserSettings(settingsPath);
if (userSettings !== undefined) {
// Settings already exist, check if they contain the encryptionKey
if (userSettings.encryptionKey !== undefined) {
// Key already exists
if (userSettings.instanceId === undefined) {
userSettings.instanceId = await generateInstanceId(userSettings.encryptionKey);
settingsCache = userSettings;
}
return userSettings;
}
} else {
userSettings = {};
}
if (process.env[ENCRYPTION_KEY_ENV_OVERWRITE] !== undefined) {
// Use the encryption key which got set via environment
userSettings.encryptionKey = process.env[ENCRYPTION_KEY_ENV_OVERWRITE];
} else {
// Generate a new encryption key
userSettings.encryptionKey = randomBytes(24).toString('base64');
}
userSettings.instanceId = await generateInstanceId(userSettings.encryptionKey);
console.log(`UserSettings were generated and saved to: ${settingsPath}`);
return writeUserSettings(userSettings, settingsPath);
}
/**
* Returns the encryption key which is used to encrypt
* the credentials.
*
*/
export async function getEncryptionKey(): Promise<string> {
if (process.env[ENCRYPTION_KEY_ENV_OVERWRITE] !== undefined) {
return process.env[ENCRYPTION_KEY_ENV_OVERWRITE];
}
const userSettings = await getUserSettings();
if (userSettings?.encryptionKey === undefined) {
throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY);
}
return userSettings.encryptionKey;
}
/**
* Returns the instance ID
*
*/
export async function getInstanceId(): Promise<string> {
const userSettings = await getUserSettings();
if (userSettings === undefined) {
return '';
}
if (userSettings.instanceId === undefined) {
return '';
}
return userSettings.instanceId;
}
async function generateInstanceId(key?: string) {
const hash = key
? createHash('sha256')
.update(key.slice(Math.round(key.length / 2)))
.digest('hex')
: undefined;
return hash;
}
/**
* Adds/Overwrite the given settings in the currently
* saved user settings
*
* @param {IUserSettings} addSettings The settings to add/overwrite
* @param {string} [settingsPath] Optional settings file path
*/
export async function addToUserSettings(
addSettings: IUserSettings,
settingsPath?: string,
): Promise<IUserSettings> {
if (settingsPath === undefined) {
settingsPath = getUserSettingsPath();
}
let userSettings = await getUserSettings(settingsPath);
if (userSettings === undefined) {
userSettings = {};
}
// Add the settings
Object.assign(userSettings, addSettings);
return writeUserSettings(userSettings, settingsPath);
}
/**
* Writes a user settings file
*
* @param {IUserSettings} userSettings The settings to write
* @param {string} [settingsPath] Optional settings file path
*/
export async function writeUserSettings(
userSettings: IUserSettings,
settingsPath?: string,
): Promise<IUserSettings> {
if (settingsPath === undefined) {
settingsPath = getUserSettingsPath();
}
if (userSettings === undefined) {
userSettings = {};
}
// Check if parent folder exists if not create it.
try {
await fsAccess(path.dirname(settingsPath));
} catch (error) {
// Parent folder does not exist so create
await fsMkdir(path.dirname(settingsPath));
}
const settingsToWrite = { ...userSettings };
if (settingsToWrite.instanceId !== undefined) {
delete settingsToWrite.instanceId;
}
await fsWriteFile(settingsPath, JSON.stringify(settingsToWrite, null, '\t'));
settingsCache = deepCopy(userSettings);
return userSettings;
}
/**
* Returns the content of the user settings
*
*/
export async function getUserSettings(
settingsPath?: string,
ignoreCache?: boolean,
): Promise<IUserSettings | undefined> {
if (settingsCache !== undefined && ignoreCache !== true) {
return settingsCache;
}
if (settingsPath === undefined) {
settingsPath = getUserSettingsPath();
}
try {
await fsAccess(settingsPath);
} catch (error) {
// The file does not exist
return undefined;
}
const settingsFile = await fsReadFile(settingsPath, 'utf8');
try {
settingsCache = JSON.parse(settingsFile);
} catch (error) {
throw new Error(
`Error parsing n8n-config file "${settingsPath}". It does not seem to be valid JSON.`,
);
}
return settingsCache as IUserSettings;
}
/**
* Returns the path to the user settings
*
*/
export function getUserSettingsPath(): string {
const n8nFolder = getUserN8nFolderPath();
return path.join(n8nFolder, USER_SETTINGS_FILE_NAME);
}
/**
* Returns the path to the n8n folder in which all n8n
* related data gets saved
*
*/
export function getUserN8nFolderPath(): string {
return path.join(getUserHome(), USER_SETTINGS_SUBFOLDER);
}
/**
* Returns the path to the n8n user folder with the custom
* extensions like nodes and credentials
*
*/
export function getUserN8nFolderCustomExtensionPath(): string {
return path.join(getUserN8nFolderPath(), EXTENSIONS_SUBDIRECTORY);
}
/**
* Returns the path to the n8n user folder with the nodes that
* have been downloaded
*
*/
export function getUserN8nFolderDownloadedNodesPath(): string {
return path.join(getUserN8nFolderPath(), DOWNLOADED_NODES_SUBDIRECTORY);
}
/**
* Returns the home folder path of the user if
* none can be found it falls back to the current
* working directory
*
*/
export function getUserHome(): string {
if (process.env[USER_FOLDER_ENV_OVERWRITE] !== undefined) {
return process.env[USER_FOLDER_ENV_OVERWRITE];
} else {
let variableName = 'HOME';
if (process.platform === 'win32') {
variableName = 'USERPROFILE';
}
if (process.env[variableName] === undefined) {
// If for some reason the variable does not exist
// fall back to current folder
return process.cwd();
}
return process.env[variableName] as string;
}
}

View File

@@ -1,20 +1,21 @@
import * as NodeExecuteFunctions from './NodeExecuteFunctions';
import * as UserSettings from './UserSettings';
export * from './ActiveWorkflows';
export * from './BinaryData/BinaryData.service';
export * from './BinaryData/types';
export { Cipher } from './Cipher';
export * from './ClassLoader';
export * from './Constants';
export * from './Credentials';
export * from './DirectoryLoader';
export * from './Interfaces';
export { InstanceSettings } from './InstanceSettings';
export * from './LoadMappingOptions';
export * from './LoadNodeParameterOptions';
export * from './LoadNodeListSearch';
export * from './NodeExecuteFunctions';
export * from './WorkflowExecute';
export { NodeExecuteFunctions, UserSettings };
export { NodeExecuteFunctions };
export * from './errors';
export { ObjectStoreService } from './ObjectStore/ObjectStore.service.ee';
export { BinaryData } from './BinaryData/types';