Skip to content

Commit

Permalink
feat(core): Setup helmet.js for setting security headers (#9027)
Browse files Browse the repository at this point in the history
  • Loading branch information
netroy committed Apr 18, 2024
1 parent 46e432b commit 0ed4671
Show file tree
Hide file tree
Showing 4 changed files with 73 additions and 72 deletions.
3 changes: 1 addition & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/AbstractServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export abstract class AbstractServer {

protected externalHooks: ExternalHooks;

protected protocol: string;
protected protocol = config.getEnv('protocol');

protected sslKey: string;

Expand Down Expand Up @@ -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');

Expand Down
113 changes: 63 additions & 50 deletions packages/cli/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -248,30 +245,6 @@ export class Server extends AbstractServer {
const { restEndpoint, app } = this;
setupPushHandler(restEndpoint, app);

const nonUIRoutes: Readonly<string[]> = [
'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();
}
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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<string[]> = [
'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));
}
}

Expand Down
26 changes: 8 additions & 18 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 0ed4671

Please sign in to comment.