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);
}