From 0ed46711f426f7edf5fa7833673b6b07348a3bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Thu, 18 Apr 2024 10:18:09 +0200 Subject: [PATCH] feat(core): Setup helmet.js for setting security headers (#9027) --- packages/cli/package.json | 3 +- packages/cli/src/AbstractServer.ts | 3 +- packages/cli/src/Server.ts | 113 ++++++++++++++++------------- pnpm-lock.yaml | 26 ++----- 4 files changed, 73 insertions(+), 72 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 9b5919e465..14ea196882 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -65,7 +65,6 @@ "@types/basic-auth": "^1.1.3", "@types/bcryptjs": "^2.4.2", "@types/compression": "1.0.1", - "@types/connect-history-api-fallback": "^1.3.1", "@types/convict": "^6.1.1", "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.21", @@ -115,7 +114,6 @@ "class-transformer": "0.5.1", "class-validator": "0.14.0", "compression": "1.7.4", - "connect-history-api-fallback": "1.6.0", "convict": "6.2.4", "cookie-parser": "1.4.6", "csrf": "3.1.0", @@ -132,6 +130,7 @@ "formidable": "3.5.1", "google-timezones-json": "1.1.0", "handlebars": "4.7.8", + "helmet": "7.1.0", "infisical-node": "1.3.0", "inquirer": "7.3.3", "ioredis": "5.3.2", diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index 89f521a4f1..cf622863a6 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -32,7 +32,7 @@ export abstract class AbstractServer { protected externalHooks: ExternalHooks; - protected protocol: string; + protected protocol = config.getEnv('protocol'); protected sslKey: string; @@ -65,7 +65,6 @@ export abstract class AbstractServer { const proxyHops = config.getEnv('proxy_hops'); if (proxyHops > 0) this.app.set('trust proxy', proxyHops); - this.protocol = config.getEnv('protocol'); this.sslKey = config.getEnv('ssl_key'); this.sslCert = config.getEnv('ssl_cert'); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 37db779c1f..2ff51e4236 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -6,20 +6,16 @@ import { Container, Service } from 'typedi'; import { exec as callbackExec } from 'child_process'; import { access as fsAccess } from 'fs/promises'; -import { join as pathJoin } from 'path'; import { promisify } from 'util'; import cookieParser from 'cookie-parser'; import express from 'express'; +import helmet from 'helmet'; import { engine as expressHandlebars } from 'express-handlebars'; -import type { ServeStaticOptions } from 'serve-static'; - import { type Class, InstanceSettings } from 'n8n-core'; - import type { IN8nUISettings } from 'n8n-workflow'; // @ts-ignore import timezones from 'google-timezones-json'; -import history from 'connect-history-api-fallback'; import config from '@/config'; import { Queue } from '@/Queue'; @@ -31,6 +27,7 @@ import { inE2ETests, N8N_VERSION, TEMPLATES_DIR, + Time, } from '@/constants'; import { CredentialsController } from '@/credentials/credentials.controller'; import type { APIRequest, CurlHelper } from '@/requests'; @@ -248,30 +245,6 @@ export class Server extends AbstractServer { const { restEndpoint, app } = this; setupPushHandler(restEndpoint, app); - const nonUIRoutes: Readonly = [ - 'assets', - 'healthz', - 'metrics', - 'e2e', - this.restEndpoint, - this.endpointPresetCredentials, - isApiEnabled() ? '' : publicApiEndpoint, - ...config.getEnv('endpoints.additionalNonUIRoutes').split(':'), - ].filter((u) => !!u); - const nonUIRoutesRegex = new RegExp(`^/(${nonUIRoutes.join('|')})/?.*$`); - - // Make sure that Vue history mode works properly - this.app.use( - history({ - rewrites: [ - { - from: nonUIRoutesRegex, - to: ({ parsedUrl }) => parsedUrl.pathname!.toString(), - }, - ], - }), - ); - if (config.getEnv('executions.mode') === 'queue') { await Container.get(Queue).init(); } @@ -381,19 +354,10 @@ export class Server extends AbstractServer { ); } + const maxAge = Time.days.toMilliseconds; + const cacheOptions = inE2ETests ? {} : { maxAge }; const { staticCacheDir } = Container.get(InstanceSettings); if (frontendService) { - const staticOptions: ServeStaticOptions = { - cacheControl: false, - setHeaders: (res: express.Response, path: string) => { - const isIndex = path === pathJoin(staticCacheDir, 'index.html'); - const cacheControl = isIndex - ? 'no-cache, no-store, must-revalidate' - : 'max-age=86400, immutable'; - res.header('Cache-Control', cacheControl); - }, - }; - const serveIcons: express.RequestHandler = async (req, res) => { // eslint-disable-next-line prefer-const let { scope, packageName } = req.params; @@ -402,7 +366,7 @@ export class Server extends AbstractServer { if (filePath) { try { await fsAccess(filePath); - return res.sendFile(filePath); + return res.sendFile(filePath, cacheOptions); } catch {} } res.sendStatus(404); @@ -411,19 +375,68 @@ export class Server extends AbstractServer { this.app.use('/icons/@:scope/:packageName/*/*.(svg|png)', serveIcons); this.app.use('/icons/:packageName/*/*.(svg|png)', serveIcons); + const isTLSEnabled = this.protocol === 'https' && !!(this.sslKey && this.sslCert); + const isPreviewMode = process.env.N8N_PREVIEW_MODE === 'true'; + const securityHeadersMiddleware = helmet({ + contentSecurityPolicy: false, + xFrameOptions: isPreviewMode || inE2ETests ? false : { action: 'sameorigin' }, + dnsPrefetchControl: false, + // This is only relevant for Internet-explorer, which we do not support + ieNoOpen: false, + // This is already disabled in AbstractServer + xPoweredBy: false, + // Enable HSTS headers only when n8n handles TLS. + // if n8n is behind a reverse-proxy, then these headers needs to be configured there + strictTransportSecurity: isTLSEnabled + ? { + maxAge: 180 * Time.days.toSeconds, + includeSubDomains: false, + preload: false, + } + : false, + }); + + // Route all UI urls to index.html to support history-api + const nonUIRoutes: Readonly = [ + 'assets', + 'types', + 'healthz', + 'metrics', + 'e2e', + this.restEndpoint, + this.endpointPresetCredentials, + isApiEnabled() ? '' : publicApiEndpoint, + ...config.getEnv('endpoints.additionalNonUIRoutes').split(':'), + ].filter((u) => !!u); + const nonUIRoutesRegex = new RegExp(`^/(${nonUIRoutes.join('|')})/?.*$`); + const historyApiHandler: express.RequestHandler = (req, res, next) => { + const { + method, + headers: { accept }, + } = req; + if ( + method === 'GET' && + accept && + (accept.includes('text/html') || accept.includes('*/*')) && + !nonUIRoutesRegex.test(req.path) + ) { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + securityHeadersMiddleware(req, res, () => { + res.sendFile('index.html', { root: staticCacheDir, maxAge, lastModified: true }); + }); + } else { + next(); + } + }; + this.app.use( '/', - express.static(staticCacheDir), - express.static(EDITOR_UI_DIST_DIR, staticOptions), + express.static(staticCacheDir, cacheOptions), + express.static(EDITOR_UI_DIST_DIR, cacheOptions), + historyApiHandler, ); - - const startTime = new Date().toUTCString(); - this.app.use('/index.html', (req, res, next) => { - res.setHeader('Last-Modified', startTime); - next(); - }); } else { - this.app.use('/', express.static(staticCacheDir)); + this.app.use('/', express.static(staticCacheDir, cacheOptions)); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed97830731..73904efe18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -544,9 +544,6 @@ importers: compression: specifier: 1.7.4 version: 1.7.4 - connect-history-api-fallback: - specifier: 1.6.0 - version: 1.6.0 convict: specifier: 6.2.4 version: 6.2.4 @@ -595,6 +592,9 @@ importers: handlebars: specifier: 4.7.8 version: 4.7.8 + helmet: + specifier: 7.1.0 + version: 7.1.0 infisical-node: specifier: 1.3.0 version: 1.3.0 @@ -764,9 +764,6 @@ importers: '@types/compression': specifier: 1.0.1 version: 1.0.1 - '@types/connect-history-api-fallback': - specifier: ^1.3.1 - version: 1.3.5 '@types/convict': specifier: ^6.1.1 version: 6.1.1 @@ -9555,13 +9552,6 @@ packages: '@types/node': 18.16.16 dev: true - /@types/connect-history-api-fallback@1.3.5: - resolution: {integrity: sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==} - dependencies: - '@types/express-serve-static-core': 4.17.43 - '@types/node': 18.16.16 - dev: true - /@types/connect@3.4.35: resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} dependencies: @@ -13027,11 +13017,6 @@ packages: resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} dev: true - /connect-history-api-fallback@1.6.0: - resolution: {integrity: sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==} - engines: {node: '>=0.8'} - dev: false - /consola@3.2.3: resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} engines: {node: ^14.18.0 || >=16.10.0} @@ -16404,6 +16389,11 @@ packages: resolution: {integrity: sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==} dev: false + /helmet@7.1.0: + resolution: {integrity: sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==} + engines: {node: '>=16.0.0'} + dev: false + /help-me@4.2.0: resolution: {integrity: sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==} dependencies: