mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
fix(core): Add input validation for workflow and data table names (#30594)
This commit is contained in:
@@ -1,15 +1,9 @@
|
||||
import xss from 'xss';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { scopesSchema } from '../../schemas/scopes.schema';
|
||||
import { xssCheck } from '../../utils/xss-check';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
const xssCheck = (value: string) =>
|
||||
value ===
|
||||
xss(value, {
|
||||
whiteList: {},
|
||||
});
|
||||
|
||||
export class UpdateApiKeyRequestDto extends Z.class({
|
||||
label: z.string().max(50).min(1).refine(xssCheck),
|
||||
scopes: scopesSchema,
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import xss from 'xss';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { xssCheck } from '../../utils/xss-check';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
const xssCheck = (value: string) =>
|
||||
value ===
|
||||
xss(value, {
|
||||
whiteList: {}, // no tags are allowed
|
||||
});
|
||||
|
||||
const URL_REGEX = /^(https?:\/\/|www\.)|(\.[\p{L}\d-]+)/iu;
|
||||
const urlCheck = (value: string) => !URL_REGEX.test(value);
|
||||
|
||||
|
||||
@@ -108,6 +108,21 @@ describe('CreateWorkflowDto', () => {
|
||||
request: { name: 'a'.repeat(129), nodes: [], connections: {} },
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'name containing a script tag',
|
||||
request: { name: '<script>alert(1)</script>', nodes: [], connections: {} },
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'name containing an img onerror payload',
|
||||
request: { name: '<img src=x onerror=alert(1)>', nodes: [], connections: {} },
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'name containing inline HTML markup',
|
||||
request: { name: 'Report <b>bold</b>', nodes: [], connections: {} },
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'missing nodes',
|
||||
request: { name: 'Test', connections: {} },
|
||||
|
||||
@@ -88,6 +88,21 @@ describe('UpdateWorkflowDto', () => {
|
||||
request: { name: 'a'.repeat(129) },
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'name containing a script tag',
|
||||
request: { name: '<script>alert(1)</script>' },
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'name containing an img onerror payload',
|
||||
request: { name: '<img src=x onerror=alert(1)>' },
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'name containing inline HTML markup',
|
||||
request: { name: 'Report <b>bold</b>' },
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'invalid nodes type',
|
||||
request: { nodes: 'not-an-array' },
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { IPinData, IConnections, IDataObject, INode, IWorkflowSettings } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { xssCheck } from '../../utils/xss-check';
|
||||
|
||||
export const WORKFLOW_NAME_MAX_LENGTH = 128;
|
||||
|
||||
/** Maximum allowed size for pinned data in bytes (12 MB) */
|
||||
@@ -17,7 +19,8 @@ export const workflowNameSchema = z
|
||||
.min(1, { message: 'Workflow name is required' })
|
||||
.max(WORKFLOW_NAME_MAX_LENGTH, {
|
||||
message: `Workflow name must be ${WORKFLOW_NAME_MAX_LENGTH} characters or less`,
|
||||
});
|
||||
})
|
||||
.refine(xssCheck, { message: 'Potentially malicious string' });
|
||||
|
||||
export const workflowDescriptionSchema = z.string().nullable();
|
||||
|
||||
|
||||
@@ -438,6 +438,7 @@ export {
|
||||
} from './schemas/eval-collections.schema';
|
||||
|
||||
export { ALLOWED_DOMAINS, isAllowedDomain } from './utils/allowed-domains';
|
||||
export { xssCheck } from './utils/xss-check';
|
||||
|
||||
export type { PublishTimelineEvent } from './schemas/workflow-publish-timeline.schema';
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { dataTableNameSchema } from '../data-table.schema';
|
||||
|
||||
describe('dataTableNameSchema', () => {
|
||||
describe('Valid names', () => {
|
||||
test.each([
|
||||
'Customers',
|
||||
'Customer orders 2024',
|
||||
'orders-q1',
|
||||
"Q1 'Quarterly' Report",
|
||||
'orders & invoices',
|
||||
'a',
|
||||
])('accepts %p', (value) => {
|
||||
expect(dataTableNameSchema.safeParse(value).success).toBe(true);
|
||||
});
|
||||
|
||||
test('trims surrounding whitespace', () => {
|
||||
const result = dataTableNameSchema.safeParse(' Customers ');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBe('Customers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid names', () => {
|
||||
test.each([
|
||||
['empty string', ''],
|
||||
['only whitespace', ' '],
|
||||
['too long', 'a'.repeat(129)],
|
||||
['contains a script tag', '<script>alert(1)</script>'],
|
||||
['contains an img onerror payload', '<img src=x onerror=alert(1)>'],
|
||||
['contains inline HTML markup', 'Customers <b>bold</b>'],
|
||||
['contains an svg onload payload', '<svg onload=alert(1)>'],
|
||||
])('rejects %s', (_label, value) => {
|
||||
expect(dataTableNameSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { ListDataTableQueryDto } from '../dto';
|
||||
import { xssCheck } from '../utils/xss-check';
|
||||
|
||||
export const insertRowReturnType = z.union([z.literal('all'), z.literal('count'), z.literal('id')]);
|
||||
|
||||
export const dataTableNameSchema = z.string().trim().min(1).max(128);
|
||||
export const dataTableNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.refine(xssCheck, { message: 'Potentially malicious string' });
|
||||
export const dataTableIdSchema = z
|
||||
.string()
|
||||
.max(36)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { xssCheck } from '../xss-check';
|
||||
|
||||
describe('xssCheck', () => {
|
||||
test.each([
|
||||
'My Workflow',
|
||||
'My Workflow 2024',
|
||||
'Workflow with spaces and 123 numbers',
|
||||
"O'Brien's workflow",
|
||||
'workflow & report',
|
||||
'a',
|
||||
'name-with-dashes_and.dots',
|
||||
'name/with/slashes',
|
||||
'name (with) (parens)',
|
||||
])('returns true for plain string %p', (value) => {
|
||||
expect(xssCheck(value)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'<script>alert(1)</script>',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'<svg onload=alert(1)>',
|
||||
'<a href="javascript:alert(1)">click</a>',
|
||||
'<iframe src="evil"></iframe>',
|
||||
'Name with <b>bold</b>',
|
||||
'<style>body{}</style>',
|
||||
'<SCRIPT>alert(1)</SCRIPT>',
|
||||
'<script src=//evil.com></script>',
|
||||
'7 > 3 is true',
|
||||
'< not really a tag',
|
||||
])('returns false for value containing HTML-significant characters %p', (value) => {
|
||||
expect(xssCheck(value)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true for empty string', () => {
|
||||
expect(xssCheck('')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import xss from 'xss';
|
||||
|
||||
/**
|
||||
* Returns `true` when the value is preserved by `xss({ whiteList: {} })`,
|
||||
* i.e. contains no HTML-significant characters.
|
||||
*
|
||||
* Use as a zod refine guard for user-supplied names (workflows, data tables,
|
||||
* user names, API keys, etc.).
|
||||
*/
|
||||
export const xssCheck = (value: string): boolean => value === xss(value, { whiteList: {} });
|
||||
@@ -24,7 +24,6 @@ import { objectRetriever, sqlite } from '../utils/transformers';
|
||||
|
||||
@Entity()
|
||||
export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkflowDb {
|
||||
// TODO: Add XSS check
|
||||
@Index({ unique: true })
|
||||
@Length(1, 128, {
|
||||
message: 'Workflow name must be $constraint1 to $constraint2 characters long.',
|
||||
|
||||
Reference in New Issue
Block a user