fix(core): Use class-validator with XSS check for survey answers (#10490)

Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
Iván Ovejero
2024-08-21 16:18:16 +02:00
committed by GitHub
parent d5acde5ce4
commit 547a60642c
15 changed files with 274 additions and 102 deletions

View File

@@ -349,10 +349,40 @@ describe('MeController', () => {
);
});
it('should throw BadRequestError on XSS attempt', async () => {
const req = mock<MeRequest.SurveyAnswers>({
body: { 'test-answer': '<script>alert("XSS")</script>' },
});
test.each([
'automationGoalDevops',
'companyIndustryExtended',
'otherCompanyIndustryExtended',
'automationGoalSm',
'usageModes',
])('should throw BadRequestError on XSS attempt for an array field %s', async (fieldName) => {
const req = mock<MeRequest.SurveyAnswers>();
req.body = {
version: 'v4',
personalization_survey_n8n_version: '1.0.0',
personalization_survey_submitted_at: new Date().toISOString(),
[fieldName]: ['<script>alert("XSS")</script>'],
};
await expect(controller.storeSurveyAnswers(req)).rejects.toThrowError(BadRequestError);
});
test.each([
'automationGoalDevopsOther',
'companySize',
'companyType',
'automationGoalSmOther',
'roleOther',
'reportedSource',
'reportedSourceOther',
])('should throw BadRequestError on XSS attempt for a string field %s', async (fieldName) => {
const req = mock<MeRequest.SurveyAnswers>();
req.body = {
version: 'v4',
personalization_survey_n8n_version: '1.0.0',
personalization_survey_submitted_at: new Date().toISOString(),
[fieldName]: '<script>alert("XSS")</script>',
};
await expect(controller.storeSurveyAnswers(req)).rejects.toThrowError(BadRequestError);
});

View File

@@ -6,7 +6,7 @@ import { randomBytes } from 'crypto';
import { AuthService } from '@/auth/auth.service';
import { Delete, Get, Patch, Post, RestController } from '@/decorators';
import { PasswordUtility } from '@/services/password.utility';
import { validateEntity, validateRecordNoXss } from '@/GenericHelpers';
import { validateEntity } from '@/GenericHelpers';
import type { User } from '@db/entities/User';
import {
AuthenticatedRequest,
@@ -25,6 +25,7 @@ import { isApiEnabled } from '@/PublicApi';
import { EventService } from '@/events/event.service';
import { MfaService } from '@/Mfa/mfa.service';
import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error';
import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto';
export const API_KEY_PREFIX = 'n8n_api_';
@@ -195,7 +196,7 @@ export class MeController {
if (!personalizationAnswers) {
this.logger.debug(
'Request to store user personalization survey failed because of empty payload',
'Request to store user personalization survey failed because of undefined payload',
{
userId: req.user.id,
},
@@ -203,12 +204,18 @@ export class MeController {
throw new BadRequestError('Personalization answers are mandatory');
}
await validateRecordNoXss(personalizationAnswers);
const validatedAnswers = plainToInstance(
PersonalizationSurveyAnswersV4,
personalizationAnswers,
{ excludeExtraneousValues: true },
);
await validateEntity(validatedAnswers);
await this.userRepository.save(
{
id: req.user.id,
personalizationAnswers,
personalizationAnswers: validatedAnswers,
},
{ transaction: false },
);
@@ -217,7 +224,7 @@ export class MeController {
this.eventService.emit('user-submitted-personalization-survey', {
userId: req.user.id,
answers: personalizationAnswers,
answers: validatedAnswers,
});
return { success: true };

View File

@@ -0,0 +1,109 @@
import { NoXss } from '@/validators/no-xss.validator';
import { Expose } from 'class-transformer';
import { IsString, IsArray, IsOptional, IsEmail, IsEnum } from 'class-validator';
import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow';
export class PersonalizationSurveyAnswersV4 implements IPersonalizationSurveyAnswersV4 {
@NoXss()
@Expose()
@IsEnum(['v4'])
version: 'v4';
@NoXss()
@Expose()
@IsString()
personalization_survey_submitted_at: string;
@NoXss()
@Expose()
@IsString()
personalization_survey_n8n_version: string;
@Expose()
@IsOptional()
@IsArray()
@NoXss({ each: true })
@IsString({ each: true })
automationGoalDevops?: string[] | null;
@NoXss()
@Expose()
@IsOptional()
@IsString()
automationGoalDevopsOther?: string | null;
@NoXss({ each: true })
@Expose()
@IsOptional()
@IsArray()
@IsString({ each: true })
companyIndustryExtended?: string[] | null;
@NoXss({ each: true })
@Expose()
@IsOptional()
@IsString({ each: true })
otherCompanyIndustryExtended?: string[] | null;
@NoXss()
@Expose()
@IsOptional()
@IsString()
companySize?: string | null;
@NoXss()
@Expose()
@IsOptional()
@IsString()
companyType?: string | null;
@NoXss({ each: true })
@Expose()
@IsOptional()
@IsArray()
@IsString({ each: true })
automationGoalSm?: string[] | null;
@NoXss()
@Expose()
@IsOptional()
@IsString()
automationGoalSmOther?: string | null;
@NoXss({ each: true })
@Expose()
@IsOptional()
@IsArray()
@IsString({ each: true })
usageModes?: string[] | null;
@NoXss()
@Expose()
@IsOptional()
@IsEmail()
email?: string | null;
@NoXss()
@Expose()
@IsOptional()
@IsString()
role?: string | null;
@NoXss()
@Expose()
@IsOptional()
@IsString()
roleOther?: string | null;
@NoXss()
@Expose()
@IsOptional()
@IsString()
reportedSource?: string | null;
@NoXss()
@Expose()
@IsOptional()
@IsString()
reportedSourceOther?: string | null;
}