mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-21 20:00:02 +00:00
feat(core): Allow customizing auth cookie samesite attribute and CSP headers (#13855)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
18
packages/@n8n/config/src/configs/auth.config.ts
Normal file
18
packages/@n8n/config/src/configs/auth.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Config, Env, Nested } from '../decorators';
|
||||||
|
|
||||||
|
@Config
|
||||||
|
class CookieConfig {
|
||||||
|
/** This sets the `Secure` flag on n8n auth cookie */
|
||||||
|
@Env('N8N_SECURE_COOKIE')
|
||||||
|
secure: boolean = true;
|
||||||
|
|
||||||
|
/** This sets the `Samesite` flag on n8n auth cookie */
|
||||||
|
@Env('N8N_SAMESITE_COOKIE')
|
||||||
|
samesite: 'strict' | 'lax' | 'none' = 'lax';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Config
|
||||||
|
export class AuthConfig {
|
||||||
|
@Nested
|
||||||
|
cookie: CookieConfig;
|
||||||
|
}
|
||||||
@@ -24,4 +24,12 @@ export class SecurityConfig {
|
|||||||
*/
|
*/
|
||||||
@Env('N8N_SECURITY_AUDIT_DAYS_ABANDONED_WORKFLOW')
|
@Env('N8N_SECURITY_AUDIT_DAYS_ABANDONED_WORKFLOW')
|
||||||
daysAbandonedWorkflow: number = 90;
|
daysAbandonedWorkflow: number = 90;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) headers as [helmet.js](https://helmetjs.github.io/#content-security-policy) nested directives object.
|
||||||
|
* Example: { "frame-ancestors": ["http://localhost:3000"] }
|
||||||
|
*/
|
||||||
|
// TODO: create a new type that parses and validates this string into a strongly-typed object
|
||||||
|
@Env('N8N_CONTENT_SECURITY_POLICY')
|
||||||
|
contentSecurityPolicy: string = '{}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AiAssistantConfig } from './configs/aiAssistant.config';
|
import { AiAssistantConfig } from './configs/aiAssistant.config';
|
||||||
|
import { AuthConfig } from './configs/auth.config';
|
||||||
import { CacheConfig } from './configs/cache.config';
|
import { CacheConfig } from './configs/cache.config';
|
||||||
import { CredentialsConfig } from './configs/credentials.config';
|
import { CredentialsConfig } from './configs/credentials.config';
|
||||||
import { DatabaseConfig } from './configs/database.config';
|
import { DatabaseConfig } from './configs/database.config';
|
||||||
@@ -38,6 +39,9 @@ export { WorkflowsConfig } from './configs/workflows.config';
|
|||||||
|
|
||||||
@Config
|
@Config
|
||||||
export class GlobalConfig {
|
export class GlobalConfig {
|
||||||
|
@Nested
|
||||||
|
auth: AuthConfig;
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
database: DatabaseConfig;
|
database: DatabaseConfig;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ describe('GlobalConfig', () => {
|
|||||||
port: 5678,
|
port: 5678,
|
||||||
listen_address: '0.0.0.0',
|
listen_address: '0.0.0.0',
|
||||||
protocol: 'http',
|
protocol: 'http',
|
||||||
|
auth: {
|
||||||
|
cookie: {
|
||||||
|
samesite: 'lax',
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
database: {
|
database: {
|
||||||
logging: {
|
logging: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -277,6 +283,7 @@ describe('GlobalConfig', () => {
|
|||||||
restrictFileAccessTo: '',
|
restrictFileAccessTo: '',
|
||||||
blockFileAccessToN8nFiles: true,
|
blockFileAccessToN8nFiles: true,
|
||||||
daysAbandonedWorkflow: 90,
|
daysAbandonedWorkflow: 90,
|
||||||
|
contentSecurityPolicy: '{}',
|
||||||
},
|
},
|
||||||
executions: {
|
executions: {
|
||||||
pruneData: true,
|
pruneData: true,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { GlobalConfig } from '@n8n/config';
|
||||||
import type { NextFunction, Response } from 'express';
|
import type { NextFunction, Response } from 'express';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
@@ -24,11 +25,13 @@ describe('AuthService', () => {
|
|||||||
mfaEnabled: false,
|
mfaEnabled: false,
|
||||||
};
|
};
|
||||||
const user = mock<User>(userData);
|
const user = mock<User>(userData);
|
||||||
|
const globalConfig = mock<GlobalConfig>({ auth: { cookie: { secure: true, samesite: 'lax' } } });
|
||||||
const jwtService = new JwtService(mock());
|
const jwtService = new JwtService(mock());
|
||||||
const urlService = mock<UrlService>();
|
const urlService = mock<UrlService>();
|
||||||
const userRepository = mock<UserRepository>();
|
const userRepository = mock<UserRepository>();
|
||||||
const invalidAuthTokenRepository = mock<InvalidAuthTokenRepository>();
|
const invalidAuthTokenRepository = mock<InvalidAuthTokenRepository>();
|
||||||
const authService = new AuthService(
|
const authService = new AuthService(
|
||||||
|
globalConfig,
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
jwtService,
|
jwtService,
|
||||||
@@ -44,10 +47,11 @@ describe('AuthService', () => {
|
|||||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNzM1NTQyNX0.YE-ZGGIQRNQ4DzUe9rjXvOOFFN9ufU34WibsCxAsc4o'; // Generated using `authService.issueJWT(user, browserId)`
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNzM1NTQyNX0.YE-ZGGIQRNQ4DzUe9rjXvOOFFN9ufU34WibsCxAsc4o'; // Generated using `authService.issueJWT(user, browserId)`
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.resetAllMocks();
|
||||||
jest.setSystemTime(now);
|
jest.setSystemTime(now);
|
||||||
config.set('userManagement.jwtSessionDurationHours', 168);
|
config.set('userManagement.jwtSessionDurationHours', 168);
|
||||||
config.set('userManagement.jwtRefreshTimeoutHours', 0);
|
config.set('userManagement.jwtRefreshTimeoutHours', 0);
|
||||||
|
globalConfig.auth.cookie = { secure: true, samesite: 'lax' };
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createJWTHash', () => {
|
describe('createJWTHash', () => {
|
||||||
@@ -127,6 +131,33 @@ describe('AuthService', () => {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: 604800000,
|
maxAge: 604800000,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('issueCookie', () => {
|
||||||
|
const res = mock<Response>();
|
||||||
|
it('should issue a cookie with the correct options', () => {
|
||||||
|
authService.issueCookie(res, user, browserId);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 604800000,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow changing cookie options', () => {
|
||||||
|
globalConfig.auth.cookie = { secure: false, samesite: 'none' };
|
||||||
|
|
||||||
|
authService.issueCookie(res, user, browserId);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 604800000,
|
||||||
|
sameSite: 'none',
|
||||||
secure: false,
|
secure: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -231,7 +262,7 @@ describe('AuthService', () => {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: 604800000,
|
maxAge: 604800000,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
secure: false,
|
secure: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newToken = res.cookie.mock.calls[0].at(1);
|
const newToken = res.cookie.mock.calls[0].at(1);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { Container, Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import type { NextFunction, Response } from 'express';
|
import type { NextFunction, Response } from 'express';
|
||||||
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
||||||
@@ -35,24 +35,13 @@ interface PasswordResetToken {
|
|||||||
hash: string;
|
hash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const restEndpoint = Container.get(GlobalConfig).endpoints.rest;
|
|
||||||
// The browser-id check needs to be skipped on these endpoints
|
|
||||||
const skipBrowserIdCheckEndpoints = [
|
|
||||||
// we need to exclude push endpoint because we can't send custom header on websocket requests
|
|
||||||
// TODO: Implement a custom handshake for push, to avoid having to send any data on querystring or headers
|
|
||||||
`/${restEndpoint}/push`,
|
|
||||||
|
|
||||||
// We need to exclude binary-data downloading endpoint because we can't send custom headers on `<embed>` tags
|
|
||||||
`/${restEndpoint}/binary-data/`,
|
|
||||||
|
|
||||||
// oAuth callback urls aren't called by the frontend. therefore we can't send custom header on these requests
|
|
||||||
`/${restEndpoint}/oauth1-credential/callback`,
|
|
||||||
`/${restEndpoint}/oauth2-credential/callback`,
|
|
||||||
];
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
// The browser-id check needs to be skipped on these endpoints
|
||||||
|
private skipBrowserIdCheckEndpoints: string[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly globalConfig: GlobalConfig,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly license: License,
|
private readonly license: License,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
@@ -62,6 +51,20 @@ export class AuthService {
|
|||||||
) {
|
) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
this.authMiddleware = this.authMiddleware.bind(this);
|
this.authMiddleware = this.authMiddleware.bind(this);
|
||||||
|
|
||||||
|
const restEndpoint = globalConfig.endpoints.rest;
|
||||||
|
this.skipBrowserIdCheckEndpoints = [
|
||||||
|
// we need to exclude push endpoint because we can't send custom header on websocket requests
|
||||||
|
// TODO: Implement a custom handshake for push, to avoid having to send any data on querystring or headers
|
||||||
|
`/${restEndpoint}/push`,
|
||||||
|
|
||||||
|
// We need to exclude binary-data downloading endpoint because we can't send custom headers on `<embed>` tags
|
||||||
|
`/${restEndpoint}/binary-data/`,
|
||||||
|
|
||||||
|
// oAuth callback urls aren't called by the frontend. therefore we can't send custom header on these requests
|
||||||
|
`/${restEndpoint}/oauth1-credential/callback`,
|
||||||
|
`/${restEndpoint}/oauth2-credential/callback`,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
async authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
@@ -117,11 +120,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = this.issueJWT(user, browserId);
|
const token = this.issueJWT(user, browserId);
|
||||||
|
const { samesite, secure } = this.globalConfig.auth.cookie;
|
||||||
res.cookie(AUTH_COOKIE_NAME, token, {
|
res.cookie(AUTH_COOKIE_NAME, token, {
|
||||||
maxAge: this.jwtExpiration * Time.seconds.toMilliseconds,
|
maxAge: this.jwtExpiration * Time.seconds.toMilliseconds,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: samesite,
|
||||||
secure: config.getEnv('secure_cookie'),
|
secure,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +163,7 @@ export class AuthService {
|
|||||||
|
|
||||||
// Check if the token was issued for another browser session, ignoring the endpoints that can't send custom headers
|
// Check if the token was issued for another browser session, ignoring the endpoints that can't send custom headers
|
||||||
const endpoint = req.route ? `${req.baseUrl}${req.route.path}` : req.baseUrl;
|
const endpoint = req.route ? `${req.baseUrl}${req.route.path}` : req.baseUrl;
|
||||||
if (req.method === 'GET' && skipBrowserIdCheckEndpoints.includes(endpoint)) {
|
if (req.method === 'GET' && this.skipBrowserIdCheckEndpoints.includes(endpoint)) {
|
||||||
this.logger.debug(`Skipped browserId check on ${endpoint}`);
|
this.logger.debug(`Skipped browserId check on ${endpoint}`);
|
||||||
} else if (
|
} else if (
|
||||||
jwtPayload.browserId &&
|
jwtPayload.browserId &&
|
||||||
|
|||||||
@@ -120,12 +120,6 @@ export const schema = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
secure_cookie: {
|
|
||||||
doc: 'This sets the `Secure` flag on n8n auth cookie',
|
|
||||||
format: Boolean,
|
|
||||||
default: true,
|
|
||||||
env: 'N8N_SECURE_COOKIE',
|
|
||||||
},
|
|
||||||
ssl_key: {
|
ssl_key: {
|
||||||
format: String,
|
format: String,
|
||||||
default: '',
|
default: '',
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { SecurityConfig } from '@n8n/config';
|
||||||
import { Container, Service } from '@n8n/di';
|
import { Container, Service } from '@n8n/di';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { access as fsAccess } from 'fs/promises';
|
import { access as fsAccess } from 'fs/promises';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
import { InstanceSettings } from 'n8n-core';
|
import { InstanceSettings } from 'n8n-core';
|
||||||
|
import { jsonParse } from 'n8n-workflow';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
|
||||||
import { AbstractServer } from '@/abstract-server';
|
import { AbstractServer } from '@/abstract-server';
|
||||||
@@ -348,8 +351,21 @@ export class Server extends AbstractServer {
|
|||||||
const isTLSEnabled =
|
const isTLSEnabled =
|
||||||
this.globalConfig.protocol === 'https' && !!(this.sslKey && this.sslCert);
|
this.globalConfig.protocol === 'https' && !!(this.sslKey && this.sslCert);
|
||||||
const isPreviewMode = process.env.N8N_PREVIEW_MODE === 'true';
|
const isPreviewMode = process.env.N8N_PREVIEW_MODE === 'true';
|
||||||
|
const cspDirectives = jsonParse<{ [key: string]: Iterable<string> }>(
|
||||||
|
Container.get(SecurityConfig).contentSecurityPolicy,
|
||||||
|
{
|
||||||
|
errorMessage: 'The contentSecurityPolicy is not valid JSON.',
|
||||||
|
},
|
||||||
|
);
|
||||||
const securityHeadersMiddleware = helmet({
|
const securityHeadersMiddleware = helmet({
|
||||||
contentSecurityPolicy: false,
|
contentSecurityPolicy: isEmpty(cspDirectives)
|
||||||
|
? false
|
||||||
|
: {
|
||||||
|
useDefaults: false,
|
||||||
|
directives: {
|
||||||
|
...cspDirectives,
|
||||||
|
},
|
||||||
|
},
|
||||||
xFrameOptions:
|
xFrameOptions:
|
||||||
isPreviewMode || inE2ETests || inDevelopment ? false : { action: 'sameorigin' },
|
isPreviewMode || inE2ETests || inDevelopment ? false : { action: 'sameorigin' },
|
||||||
dnsPrefetchControl: false,
|
dnsPrefetchControl: false,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export class FrontendService {
|
|||||||
versionCli: N8N_VERSION,
|
versionCli: N8N_VERSION,
|
||||||
concurrency: config.getEnv('executions.concurrency.productionLimit'),
|
concurrency: config.getEnv('executions.concurrency.productionLimit'),
|
||||||
authCookie: {
|
authCookie: {
|
||||||
secure: config.getEnv('secure_cookie'),
|
secure: this.globalConfig.auth.cookie.secure,
|
||||||
},
|
},
|
||||||
releaseChannel: this.globalConfig.generic.releaseChannel,
|
releaseChannel: this.globalConfig.generic.releaseChannel,
|
||||||
oauthCallbackUrls: {
|
oauthCallbackUrls: {
|
||||||
|
|||||||
Reference in New Issue
Block a user