From f3d84fc29ee969659b4cc7f7f64ec9c327ea4ebc Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 4 Aug 2019 14:24:48 +0200 Subject: [PATCH] :sparkles: Make it possible to secure n8n via basic auth --- docker/images/n8n/README.md | 40 +++++++- packages/cli/README.md | 37 +++++++- packages/cli/commands/start.ts | 146 +++++++++++++++-------------- packages/cli/config/index.ts | 24 +++++ packages/cli/package.json | 2 + packages/cli/src/ResponseHelper.ts | 8 ++ packages/cli/src/Server.ts | 52 ++++++++-- 7 files changed, 223 insertions(+), 86 deletions(-) diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index 2f1eec51d3..acda4d8d10 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -53,6 +53,22 @@ docker run -it --rm \ n8n start --tunnel ``` + +## Securing n8n + +By default n8n can be accessed by everybody. This is OK if you have it only running +locally buy if you deploy it on a server which is accessible from the web you have +to make sure that n8n is protected! +Right now we have very basic protection via basic-auth in place. It can be activated +by setting the following environment variables: + +``` +N8N_BASIC_AUTH_ACTIVE=true +N8N_BASIC_AUTH_USER= +N8N_BASIC_AUTH_PASSWORD= +``` + + ## Persist data The workflow data gets by default saved in an SQLite database in the user @@ -73,11 +89,6 @@ By default n8n uses SQLite to save credentials, past executions and workflows. n8n however also supports MongoDB and PostgresDB. To use them simply a few environment variables have to be set. -To avoid passing sensitive information via environment variables "_FILE" may be -appended to the database environment variables (for example "DB_POSTGRESDB_PASSWORD_FILE"). -It will then load the data from a file with the given name. That makes it possible to -load data easily from Docker- and Kubernetes-Secrets. - It is important to still persist the data in the `/root/.n8` folder. The reason is that it contains n8n user data. That is the name of the webhook (in case) the n8n tunnel gets used and even more important the encryption key @@ -133,6 +144,25 @@ docker run -it --rm \ n8n start ``` + +## Passing Senstive Data via File + +To avoid passing sensitive information via environment variables "_FILE" may be +appended to some environment variables. It will then load the data from a file +with the given name. That makes it possible to load data easily from +Docker- and Kubernetes-Secrets. + +The following environment variables support file input: + - DB_MONGODB_CONNECTION_URL + - DB_POSTGRESDB_DATABASE_FILE + - DB_POSTGRESDB_HOST_FILE + - DB_POSTGRESDB_PASSWORD_FILE + - DB_POSTGRESDB_PORT_FILE + - DB_POSTGRESDB_USER_FILE + - N8N_BASIC_AUTH_PASSWORD_FILE + - N8N_BASIC_AUTH_USER_FILE + + ## License n8n is licensed under **Apache 2.0 with Commons Clause** diff --git a/packages/cli/README.md b/packages/cli/README.md index 1b7039d601..42f029923a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -67,6 +67,20 @@ To use it simply start n8n with `--tunnel` n8n start --tunnel ``` +### Securing n8n + +By default n8n can be accessed by everybody. This is OK if you have it only running +locally buy if you deploy it on a server which is accessible from the web you have +to make sure that n8n is protected! +Right now we have very basic protection via basic-auth in place. It can be activated +by setting the following environment variables: + +``` +N8N_BASIC_AUTH_ACTIVE=true +N8N_BASIC_AUTH_USER= +N8N_BASIC_AUTH_PASSWORD= +``` + ### Start with other Database @@ -74,11 +88,6 @@ By default n8n uses SQLite to save credentials, past executions and workflows. n8n however also supports MongoDB and PostgresDB. To use them simply a few environment variables have to be set. -To avoid passing sensitive information via environment variables "_FILE" may be -appended to the database environment variables (for example "DB_POSTGRESDB_PASSWORD_FILE"). -It will then load the data from a file with the given name. That makes it possible to -load data easily from Docker- and Kubernetes-Secrets. - #### Start with MongoDB as Database @@ -125,6 +134,24 @@ n8n start ``` +## Passing Senstive Data via File + +To avoid passing sensitive information via environment variables "_FILE" may be +appended to some environment variables. It will then load the data from a file +with the given name. That makes it possible to load data easily from +Docker- and Kubernetes-Secrets. + +The following environment variables support file input: + - DB_MONGODB_CONNECTION_URL + - DB_POSTGRESDB_DATABASE_FILE + - DB_POSTGRESDB_HOST_FILE + - DB_POSTGRESDB_PASSWORD_FILE + - DB_POSTGRESDB_PORT_FILE + - DB_POSTGRESDB_USER_FILE + - N8N_BASIC_AUTH_PASSWORD_FILE + - N8N_BASIC_AUTH_USER_FILE + + ## Execute Workflow from CLI Workflows can not just be started by triggers, webhooks or manually via the diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index 6c67c4c0f6..e7e1dc3757 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -23,7 +23,7 @@ import { promisify } from "util"; const tunnel = promisify(localtunnel); let activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner | undefined; - +let processExistCode = 0; /** * Opens the UI in browser @@ -68,89 +68,97 @@ flag "--init" to fix this problem!`); // Wrap that the process does not close but we can still use async (async () => { - // Start directly with the init of the database to improve startup time - const startDbInitPromise = Db.init(); + try { + // Start directly with the init of the database to improve startup time + const startDbInitPromise = Db.init(); - // Make sure the settings exist - const userSettings = await UserSettings.prepareUserSettings(); + // Make sure the settings exist + const userSettings = await UserSettings.prepareUserSettings(); - // Load all node and credential types - const loadNodesAndCredentials = LoadNodesAndCredentials(); - await loadNodesAndCredentials.init(); + // Load all node and credential types + const loadNodesAndCredentials = LoadNodesAndCredentials(); + await loadNodesAndCredentials.init(); - // Add the found types to an instance other parts of the application can use - const nodeTypes = NodeTypes(); - await nodeTypes.init(loadNodesAndCredentials.nodeTypes); - const credentialTypes = CredentialTypes(); - await credentialTypes.init(loadNodesAndCredentials.credentialTypes); + // Add the found types to an instance other parts of the application can use + const nodeTypes = NodeTypes(); + await nodeTypes.init(loadNodesAndCredentials.nodeTypes); + const credentialTypes = CredentialTypes(); + await credentialTypes.init(loadNodesAndCredentials.credentialTypes); - // Wait till the database is ready - await startDbInitPromise; + // Wait till the database is ready + await startDbInitPromise; - if (args.options.tunnel !== undefined) { - console.log('\nWaiting for tunnel ...'); + if (args.options.tunnel !== undefined) { + console.log('\nWaiting for tunnel ...'); - if (userSettings.tunnelSubdomain === undefined) { - // When no tunnel subdomain did exist yet create a new random one - const availableCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; - userSettings.tunnelSubdomain = Array.from({ length: 24 }).map(() => { - return availableCharacters.charAt(Math.floor(Math.random() * availableCharacters.length)); - }).join(''); + if (userSettings.tunnelSubdomain === undefined) { + // When no tunnel subdomain did exist yet create a new random one + const availableCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + userSettings.tunnelSubdomain = Array.from({ length: 24 }).map(() => { + return availableCharacters.charAt(Math.floor(Math.random() * availableCharacters.length)); + }).join(''); - await UserSettings.writeUserSettings(userSettings); + await UserSettings.writeUserSettings(userSettings); + } + + const tunnelSettings: localtunnel.TunnelConfig = { + host: 'https://hooks.n8n.cloud', + subdomain: userSettings.tunnelSubdomain, + }; + + const port = config.get('port') as number; + + // @ts-ignore + const webhookTunnel = await tunnel(port, tunnelSettings); + + process.env.WEBHOOK_TUNNEL_URL = webhookTunnel.url + '/'; + console.log(`Tunnel URL: ${process.env.WEBHOOK_TUNNEL_URL}\n`); } - const tunnelSettings: localtunnel.TunnelConfig = { - host: 'https://hooks.n8n.cloud', - subdomain: userSettings.tunnelSubdomain, - }; + await Server.start(); - const port = config.get('port') as number; + // Start to get active workflows and run their triggers + activeWorkflowRunner = ActiveWorkflowRunner.getInstance(); + await activeWorkflowRunner.init(); - // @ts-ignore - const webhookTunnel = await tunnel(port, tunnelSettings); + const editorUrl = GenericHelpers.getBaseUrl(); + console.log(`\nEditor is now accessible via:\n${editorUrl}`); - process.env.WEBHOOK_TUNNEL_URL = webhookTunnel.url + '/'; - console.log(`Tunnel URL: ${process.env.WEBHOOK_TUNNEL_URL}\n`); - } + // Allow to open n8n editor by pressing "o" + if (Boolean(process.stdout.isTTY) && process.stdin.setRawMode) { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding('utf8'); + let inputText = ''; - Server.start(); - - // Start to get active workflows and run their triggers - activeWorkflowRunner = ActiveWorkflowRunner.getInstance(); - await activeWorkflowRunner.init(); - - const editorUrl = GenericHelpers.getBaseUrl(); - console.log(`\nEditor is now accessible via:\n${editorUrl}`); - - // Allow to open n8n editor by pressing "o" - if (Boolean(process.stdout.isTTY) && process.stdin.setRawMode) { - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdin.setEncoding('utf8'); - let inputText = ''; - - if (args.options.browser !== undefined) { - openBrowser(); - } - console.log(`\nPress "o" to open in Browser.`); - process.stdin.on("data", (key) => { - if (key === 'o') { + if (args.options.browser !== undefined) { openBrowser(); - inputText = ''; - } else { - // When anything else got pressed, record it and send it on enter into the child process - if (key.charCodeAt(0) === 13) { - // send to child process and print in terminal - process.stdout.write('\n'); + } + console.log(`\nPress "o" to open in Browser.`); + process.stdin.on("data", (key) => { + if (key === 'o') { + openBrowser(); inputText = ''; } else { - // record it and write into terminal - inputText += key; - process.stdout.write(key); + // When anything else got pressed, record it and send it on enter into the child process + if (key.charCodeAt(0) === 13) { + // send to child process and print in terminal + process.stdout.write('\n'); + inputText = ''; + } else { + // record it and write into terminal + inputText += key; + process.stdout.write(key); + } } - } - }); + }); + } + } catch (error) { + console.error(`There was an error: ${error.message}`); + + processExistCode = 1; + // @ts-ignore + process.emit('SIGINT'); } })(); @@ -161,7 +169,7 @@ flag "--init" to fix this problem!`); setTimeout(() => { // In case that something goes wrong with shutdown we // kill after max. 30 seconds no matter what - process.exit(); + process.exit(processExistCode); }, 30000); const removePromises = []; @@ -175,7 +183,7 @@ flag "--init" to fix this problem!`); await Promise.all(removePromises); - process.exit(); + process.exit(processExistCode); }); }); }; diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 64daf41455..bb86b16eb3 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -121,6 +121,30 @@ const config = convict({ doc: 'HTTP Protocol via which n8n can be reached' }, + security: { + basicAuth: { + active: { + format: 'Boolean', + default: false, + env: 'N8N_BASIC_AUTH_ACTIVE', + doc: 'If basic auth should be activated for editor and REST-API' + }, + user: { + format: String, + default: '', + env: 'N8N_BASIC_AUTH_USER', + doc: 'The name of the basic auth user' + }, + password: { + format: String, + default: '', + env: 'N8N_BASIC_AUTH_PASSWORD', + doc: 'The password of the basic auth user' + }, + } + + }, + endpoints: { rest: { format: String, diff --git a/packages/cli/package.json b/packages/cli/package.json index 8c14c4c9e8..c418047c3a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -39,6 +39,7 @@ "dist" ], "devDependencies": { + "@types/basic-auth": "^1.1.2", "@types/compression": "0.0.36", "@types/connect-history-api-fallback": "^1.3.1", "@types/convict": "^4.2.1", @@ -59,6 +60,7 @@ "typescript": "~3.5.2" }, "dependencies": { + "basic-auth": "^2.0.1", "body-parser": "^1.18.3", "compression": "^1.7.4", "connect-history-api-fallback": "^1.6.0", diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 66b503f326..73fd28235c 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -45,6 +45,14 @@ export class ReponseError extends Error { } + +export function basicAuthAuthorizationError(resp: Response, realm: string, message?: string) { + resp.statusCode = 401; + resp.setHeader('WWW-Authenticate', `Basic realm="${realm}"`); + resp.end(message); +} + + export function sendSuccessResponse(res: Response, data: any, raw?: boolean) { // tslint:disable-line:no-any res.setHeader('Content-Type', 'application/json'); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 4b6e0f1dd2..d9bbc02b26 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -61,11 +61,12 @@ import { Not, } from 'typeorm'; -import * as parseUrl from 'parseurl'; +import * as basicAuth from 'basic-auth'; +import * as compression from 'compression'; import * as config from '../config'; // @ts-ignore import * as timezones from 'google-timezones-json'; -import * as compression from 'compression'; +import * as parseUrl from 'parseurl'; class App { @@ -92,7 +93,6 @@ class App { this.saveManualExecutions = config.get('executions.saveDataManualExecutions') as boolean; this.timezone = config.get('generic.timezone') as string; - this.config(); this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance(); this.testWebhooks = TestWebhooks.getInstance(); this.push = Push.getInstance(); @@ -112,8 +112,44 @@ class App { } - private config(): void { + async config(): Promise { + // Check for basic auth credentials if activated + const basicAuthActive = config.get('security.basicAuth.active') as boolean; + if (basicAuthActive === true) { + const basicAuthUser = await GenericHelpers.getConfigValue('security.basicAuth.user') as string; + if (basicAuthUser === '') { + throw new Error('Basic auth is activated but no user got defined. Please set one!'); + } + + const basicAuthPassword = await GenericHelpers.getConfigValue('security.basicAuth.password') as string; + if (basicAuthPassword === '') { + throw new Error('Basic auth is activated but no password got defined. Please set one!'); + } + + const authIgnoreRegex = new RegExp(`^\/(rest|${this.endpointWebhook}|${this.endpointWebhookTest})\/.*$`) + this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { + if (req.url.match(authIgnoreRegex)) { + return next(); + } + const realm = 'n8n - Editor UI'; + const basicAuthData = basicAuth(req); + + if (basicAuthData === undefined) { + // Authorization data is missing + return ResponseHelper.basicAuthAuthorizationError(res, realm, 'Authorization is required!'); + } + + if (basicAuthData.name !== basicAuthUser || basicAuthData.pass !== basicAuthPassword) { + // Provided authentication data is wrong + return ResponseHelper.basicAuthAuthorizationError(res, realm, 'Authorization data is wrong!'); + } + + next(); + }); + } + + // Compress the repsonse data this.app.use(compression()); // Get push connections @@ -1036,12 +1072,14 @@ class App { } -export function start() { +export async function start(): Promise { const PORT = config.get('port'); - const app = new App().app; + const app = new App(); - app.listen(PORT, () => { + await app.config(); + + app.app.listen(PORT, () => { console.log('n8n ready on port ' + PORT); }); }