mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 04:47:29 -08:00
fix(core): Fix XSS validation and separate URL validation (#10424)
This commit is contained in:
parent
9d6ad88c14
commit
91467ab325
|
@ -155,6 +155,7 @@
|
||||||
"reflect-metadata": "0.2.2",
|
"reflect-metadata": "0.2.2",
|
||||||
"replacestream": "4.0.3",
|
"replacestream": "4.0.3",
|
||||||
"samlify": "2.8.9",
|
"samlify": "2.8.9",
|
||||||
|
"sanitize-html": "2.12.1",
|
||||||
"semver": "7.5.4",
|
"semver": "7.5.4",
|
||||||
"shelljs": "0.8.5",
|
"shelljs": "0.8.5",
|
||||||
"simple-git": "3.17.0",
|
"simple-git": "3.17.0",
|
||||||
|
|
|
@ -9,7 +9,7 @@ import type {
|
||||||
UserUpdatePayload,
|
UserUpdatePayload,
|
||||||
} from '@/requests';
|
} from '@/requests';
|
||||||
import { BadRequestError } from './errors/response-errors/bad-request.error';
|
import { BadRequestError } from './errors/response-errors/bad-request.error';
|
||||||
import { NoXss } from './databases/utils/customValidators';
|
import { NoXss } from '@/validators/no-xss.validator';
|
||||||
|
|
||||||
export async function validateEntity(
|
export async function validateEntity(
|
||||||
entity:
|
entity:
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { IsEmail, IsString, Length } from 'class-validator';
|
||||||
import type { IUser, IUserSettings } from 'n8n-workflow';
|
import type { IUser, IUserSettings } from 'n8n-workflow';
|
||||||
import type { SharedWorkflow } from './SharedWorkflow';
|
import type { SharedWorkflow } from './SharedWorkflow';
|
||||||
import type { SharedCredentials } from './SharedCredentials';
|
import type { SharedCredentials } from './SharedCredentials';
|
||||||
import { NoXss } from '../utils/customValidators';
|
import { NoXss } from '@/validators/no-xss.validator';
|
||||||
import { objectRetriever, lowerCaser } from '../utils/transformers';
|
import { objectRetriever, lowerCaser } from '../utils/transformers';
|
||||||
import { WithTimestamps, jsonColumnType } from './AbstractEntity';
|
import { WithTimestamps, jsonColumnType } from './AbstractEntity';
|
||||||
import type { IPersonalizationSurveyAnswers } from '@/Interfaces';
|
import type { IPersonalizationSurveyAnswers } from '@/Interfaces';
|
||||||
|
@ -25,6 +25,7 @@ import {
|
||||||
} from '@/permissions/global-roles';
|
} from '@/permissions/global-roles';
|
||||||
import { hasScope, type ScopeOptions, type Scope } from '@n8n/permissions';
|
import { hasScope, type ScopeOptions, type Scope } from '@n8n/permissions';
|
||||||
import type { ProjectRelation } from './ProjectRelation';
|
import type { ProjectRelation } from './ProjectRelation';
|
||||||
|
import { NoUrl } from '@/validators/no-url.validator';
|
||||||
|
|
||||||
export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member';
|
export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member';
|
||||||
export type AssignableRole = Exclude<GlobalRole, 'global:owner'>;
|
export type AssignableRole = Exclude<GlobalRole, 'global:owner'>;
|
||||||
|
@ -51,12 +52,14 @@ export class User extends WithTimestamps implements IUser {
|
||||||
|
|
||||||
@Column({ length: 32, nullable: true })
|
@Column({ length: 32, nullable: true })
|
||||||
@NoXss()
|
@NoXss()
|
||||||
|
@NoUrl()
|
||||||
@IsString({ message: 'First name must be of type string.' })
|
@IsString({ message: 'First name must be of type string.' })
|
||||||
@Length(1, 32, { message: 'First name must be $constraint1 to $constraint2 characters long.' })
|
@Length(1, 32, { message: 'First name must be $constraint1 to $constraint2 characters long.' })
|
||||||
firstName: string;
|
firstName: string;
|
||||||
|
|
||||||
@Column({ length: 32, nullable: true })
|
@Column({ length: 32, nullable: true })
|
||||||
@NoXss()
|
@NoXss()
|
||||||
|
@NoUrl()
|
||||||
@IsString({ message: 'Last name must be of type string.' })
|
@IsString({ message: 'Last name must be of type string.' })
|
||||||
@Length(1, 32, { message: 'Last name must be $constraint1 to $constraint2 characters long.' })
|
@Length(1, 32, { message: 'Last name must be $constraint1 to $constraint2 characters long.' })
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
import { NoXss } from '@db/utils/customValidators';
|
|
||||||
import { validate } from 'class-validator';
|
|
||||||
|
|
||||||
describe('customValidators', () => {
|
|
||||||
describe('NoXss', () => {
|
|
||||||
class Person {
|
|
||||||
@NoXss()
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
const person = new Person();
|
|
||||||
|
|
||||||
const invalidNames = ['http://google.com', '<script src/>', 'www.domain.tld'];
|
|
||||||
|
|
||||||
const validNames = [
|
|
||||||
'Johann Strauß',
|
|
||||||
'Вагиф Сәмәдоғлу',
|
|
||||||
'René Magritte',
|
|
||||||
'সুকুমার রায়',
|
|
||||||
'མགོན་པོ་རྡོ་རྗེ།',
|
|
||||||
'عبدالحليم حافظ',
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('Block XSS', () => {
|
|
||||||
for (const name of invalidNames) {
|
|
||||||
test(name, async () => {
|
|
||||||
person.name = name;
|
|
||||||
const validationErrors = await validate(person);
|
|
||||||
expect(validationErrors[0].property).toEqual('name');
|
|
||||||
expect(validationErrors[0].constraints).toEqual({ NoXss: 'Malicious name' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Allow Valid names', () => {
|
|
||||||
for (const name of validNames) {
|
|
||||||
test(name, async () => {
|
|
||||||
person.name = name;
|
|
||||||
expect(await validate(person)).toBeEmptyArray();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { registerDecorator } from 'class-validator';
|
|
||||||
|
|
||||||
export function NoXss() {
|
|
||||||
return (object: object, propertyName: string): void => {
|
|
||||||
registerDecorator({
|
|
||||||
name: 'NoXss',
|
|
||||||
target: object.constructor,
|
|
||||||
propertyName,
|
|
||||||
constraints: [propertyName],
|
|
||||||
options: { message: `Malicious ${propertyName}` },
|
|
||||||
validator: {
|
|
||||||
validate(value: string) {
|
|
||||||
return !/(^http|^www)|<(\s*)?(script|a)|(\.[\p{L}\d-]+)/u.test(value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -13,7 +13,7 @@ import type {
|
||||||
|
|
||||||
import { Expose } from 'class-transformer';
|
import { Expose } from 'class-transformer';
|
||||||
import { IsBoolean, IsEmail, IsIn, IsOptional, IsString, Length } from 'class-validator';
|
import { IsBoolean, IsEmail, IsIn, IsOptional, IsString, Length } from 'class-validator';
|
||||||
import { NoXss } from '@db/utils/customValidators';
|
import { NoXss } from '@/validators/no-xss.validator';
|
||||||
import type { PublicUser, SecretsProvider, SecretsProviderState } from '@/Interfaces';
|
import type { PublicUser, SecretsProvider, SecretsProviderState } from '@/Interfaces';
|
||||||
import { AssignableRole } from '@db/entities/User';
|
import { AssignableRole } from '@db/entities/User';
|
||||||
import type { GlobalRole, User } from '@db/entities/User';
|
import type { GlobalRole, User } from '@db/entities/User';
|
||||||
|
@ -26,6 +26,7 @@ import type { ProjectRole } from './databases/entities/ProjectRelation';
|
||||||
import type { Scope } from '@n8n/permissions';
|
import type { Scope } from '@n8n/permissions';
|
||||||
import type { ScopesField } from './services/role.service';
|
import type { ScopesField } from './services/role.service';
|
||||||
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
|
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
|
||||||
|
import { NoUrl } from '@/validators/no-url.validator';
|
||||||
|
|
||||||
export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'lastName'> {
|
export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'lastName'> {
|
||||||
@Expose()
|
@Expose()
|
||||||
|
@ -34,12 +35,14 @@ export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'la
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@NoXss()
|
@NoXss()
|
||||||
|
@NoUrl()
|
||||||
@IsString({ message: 'First name must be of type string.' })
|
@IsString({ message: 'First name must be of type string.' })
|
||||||
@Length(1, 32, { message: 'First name must be $constraint1 to $constraint2 characters long.' })
|
@Length(1, 32, { message: 'First name must be $constraint1 to $constraint2 characters long.' })
|
||||||
firstName: string;
|
firstName: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@NoXss()
|
@NoXss()
|
||||||
|
@NoUrl()
|
||||||
@IsString({ message: 'Last name must be of type string.' })
|
@IsString({ message: 'Last name must be of type string.' })
|
||||||
@Length(1, 32, { message: 'Last name must be $constraint1 to $constraint2 characters long.' })
|
@Length(1, 32, { message: 'Last name must be $constraint1 to $constraint2 characters long.' })
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { NoUrl } from '../no-url.validator';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
|
||||||
|
describe('NoUrl', () => {
|
||||||
|
class Entity {
|
||||||
|
@NoUrl()
|
||||||
|
name = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = new Entity();
|
||||||
|
|
||||||
|
describe('URLs', () => {
|
||||||
|
const URLS = ['http://google.com', 'www.domain.tld'];
|
||||||
|
|
||||||
|
for (const str of URLS) {
|
||||||
|
test(`should block ${str}`, async () => {
|
||||||
|
entity.name = str;
|
||||||
|
const errors = await validate(entity);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
const [error] = errors;
|
||||||
|
expect(error.property).toEqual('name');
|
||||||
|
expect(error.constraints).toEqual({ NoUrl: 'Potentially malicious string' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { NoXss } from '../no-xss.validator';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
|
||||||
|
describe('NoXss', () => {
|
||||||
|
class Entity {
|
||||||
|
@NoXss()
|
||||||
|
name = '';
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
timestamp = '';
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
version = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = new Entity();
|
||||||
|
|
||||||
|
describe('Scripts', () => {
|
||||||
|
const XSS_STRINGS = ['<script src/>', "<script>alert('xss')</script>"];
|
||||||
|
|
||||||
|
for (const str of XSS_STRINGS) {
|
||||||
|
test(`should block ${str}`, async () => {
|
||||||
|
entity.name = str;
|
||||||
|
const errors = await validate(entity);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
const [error] = errors;
|
||||||
|
expect(error.property).toEqual('name');
|
||||||
|
expect(error.constraints).toEqual({ NoXss: 'Potentially malicious string' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Names', () => {
|
||||||
|
const VALID_NAMES = [
|
||||||
|
'Johann Strauß',
|
||||||
|
'Вагиф Сәмәдоғлу',
|
||||||
|
'René Magritte',
|
||||||
|
'সুকুমার রায়',
|
||||||
|
'མགོན་པོ་རྡོ་རྗེ།',
|
||||||
|
'عبدالحليم حافظ',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of VALID_NAMES) {
|
||||||
|
test(`should allow ${name}`, async () => {
|
||||||
|
entity.name = name;
|
||||||
|
expect(await validate(entity)).toBeEmptyArray();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ISO-8601 timestamps', () => {
|
||||||
|
const VALID_TIMESTAMPS = ['2022-01-01T00:00:00.000Z', '2022-01-01T00:00:00.000+02:00'];
|
||||||
|
|
||||||
|
for (const timestamp of VALID_TIMESTAMPS) {
|
||||||
|
test(`should allow ${timestamp}`, async () => {
|
||||||
|
entity.timestamp = timestamp;
|
||||||
|
await expect(validate(entity)).resolves.toBeEmptyArray();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Semver versions', () => {
|
||||||
|
const VALID_VERSIONS = ['1.0.0', '1.0.0-alpha.1'];
|
||||||
|
|
||||||
|
for (const version of VALID_VERSIONS) {
|
||||||
|
test(`should allow ${version}`, async () => {
|
||||||
|
entity.version = version;
|
||||||
|
await expect(validate(entity)).resolves.toBeEmptyArray();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
27
packages/cli/src/validators/no-url.validator.ts
Normal file
27
packages/cli/src/validators/no-url.validator.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import type { ValidationOptions, ValidatorConstraintInterface } from 'class-validator';
|
||||||
|
import { registerDecorator, ValidatorConstraint } from 'class-validator';
|
||||||
|
|
||||||
|
const URL_REGEX = /^(https?:\/\/|www\.)/i;
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'NoUrl', async: false })
|
||||||
|
class NoUrlConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: string) {
|
||||||
|
return !URL_REGEX.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage() {
|
||||||
|
return 'Potentially malicious string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoUrl(options?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
name: 'NoUrl',
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options,
|
||||||
|
validator: NoUrlConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
26
packages/cli/src/validators/no-xss.validator.ts
Normal file
26
packages/cli/src/validators/no-xss.validator.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import type { ValidationOptions, ValidatorConstraintInterface } from 'class-validator';
|
||||||
|
import { registerDecorator, ValidatorConstraint } from 'class-validator';
|
||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'NoXss', async: false })
|
||||||
|
class NoXssConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: string) {
|
||||||
|
return value === sanitizeHtml(value, { allowedTags: [], allowedAttributes: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage() {
|
||||||
|
return 'Potentially malicious string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoXss(options?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
name: 'NoXss',
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options,
|
||||||
|
validator: NoXssConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -824,6 +824,9 @@ importers:
|
||||||
samlify:
|
samlify:
|
||||||
specifier: 2.8.9
|
specifier: 2.8.9
|
||||||
version: 2.8.9
|
version: 2.8.9
|
||||||
|
sanitize-html:
|
||||||
|
specifier: 2.12.1
|
||||||
|
version: 2.12.1
|
||||||
semver:
|
semver:
|
||||||
specifier: ^7.5.4
|
specifier: ^7.5.4
|
||||||
version: 7.6.0
|
version: 7.6.0
|
||||||
|
@ -22445,7 +22448,7 @@ snapshots:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
domutils: 3.0.1
|
domutils: 3.0.1
|
||||||
entities: 4.4.0
|
entities: 4.5.0
|
||||||
|
|
||||||
http-cache-semantics@4.1.1:
|
http-cache-semantics@4.1.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
Loading…
Reference in a new issue