diff --git a/backend/src/predictions/dto/update-prediction-note.dto.ts b/backend/src/predictions/dto/update-prediction-note.dto.ts index 7467b092..2077edaa 100644 --- a/backend/src/predictions/dto/update-prediction-note.dto.ts +++ b/backend/src/predictions/dto/update-prediction-note.dto.ts @@ -1,6 +1,37 @@ import { IsString, MaxLength } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +/** + * Strip HTML markup from a user-supplied prediction note. + * + * Notes are personal free text and are never rendered as markup, so tags carry + * no meaning here and are removed rather than escaped. Removing them keeps the + * stored value equal to what the user meant to write, and leaves nothing for a + * downstream consumer to mis-render. + * + * Applied in the service rather than as a `@Transform` decorator: the note + * route does not enable `transform: true` on its ValidationPipe, so a decorator + * would silently not run. Sanitising at the write keeps it independent of pipe + * configuration. + * + * @param input - The user-provided note + * @returns The note with markup removed and surrounding whitespace trimmed + * + * @example + * sanitizeNote('hi') // returns 'hi' + * sanitizeNote(' spaced ') // returns 'spaced' + */ +export function sanitizeNote(input: string): string { + if (!input) return input; + return input + // Drop script and style bodies wholesale. Removing only the tags would + // leave the code itself sitting in the note as plain text. + .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '') + // Remaining tags, including unclosed ones such as `]*>?/g, '') + .trim(); +} + export class UpdatePredictionNoteDto { @ApiProperty({ description: 'Personal note for the prediction', diff --git a/backend/src/predictions/predictions.service.spec.ts b/backend/src/predictions/predictions.service.spec.ts index 6ef08512..e94bcfb3 100644 --- a/backend/src/predictions/predictions.service.spec.ts +++ b/backend/src/predictions/predictions.service.spec.ts @@ -861,6 +861,34 @@ describe('PredictionsService', () => { ).rejects.toThrow(PredictionNotFoundException); expect(submitPrediction).not.toHaveBeenCalled(); }); + + it.each([ + ['Real analysis', 'Real analysis'], + ['note', 'note'], + ['bold and italic', 'bold and italic'], + ['clean', 'clean'], + [' padded ', 'padded'], + ['plain text, unchanged', 'plain text, unchanged'], + ])('should sanitize %j before saving', async (input, expected) => { + const user = makeUser(); + const prediction = { + id: 'pred-1', + user, + market: makeMarket(), + note: null, + } as unknown as Prediction; + + mockPredictionsRepo.findOne.mockResolvedValue(prediction); + mockPredictionsRepo.save.mockImplementation(async (p) => p as Prediction); + + await service.updateNote('pred-1', { note: input }, user); + + // Assert on what reaches the repository, not on the return value: the + // sanitised text is what actually gets stored. + expect(mockPredictionsRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ note: expected }), + ); + }); }); describe('findById', () => { diff --git a/backend/src/predictions/predictions.service.ts b/backend/src/predictions/predictions.service.ts index 967bbf94..65b73d91 100644 --- a/backend/src/predictions/predictions.service.ts +++ b/backend/src/predictions/predictions.service.ts @@ -22,7 +22,10 @@ import { BatchPredictionResultDto, BatchSubmitResponseDto, } from './dto/batch-submit-response.dto'; -import { UpdatePredictionNoteDto } from './dto/update-prediction-note.dto'; +import { + UpdatePredictionNoteDto, + sanitizeNote, +} from './dto/update-prediction-note.dto'; import { ListMarketPredictionsDto, MarketPredictionResponseDto, @@ -712,7 +715,7 @@ export class PredictionsService { throw new PredictionNotFoundException(predictionId); } - prediction.note = dto.note; + prediction.note = sanitizeNote(dto.note); return this.predictionsRepository.save(prediction); }