Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions backend/src/predictions/dto/update-prediction-note.dto.ts
Original file line number Diff line number Diff line change
@@ -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('<script>alert(1)</script>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 `<img src=x onerror=...`.
.replace(/<[^>]*>?/g, '')
.trim();
}

export class UpdatePredictionNoteDto {
@ApiProperty({
description: 'Personal note for the prediction',
Expand Down
28 changes: 28 additions & 0 deletions backend/src/predictions/predictions.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,34 @@ describe('PredictionsService', () => {
).rejects.toThrow(PredictionNotFoundException);
expect(submitPrediction).not.toHaveBeenCalled();
});

it.each([
['<script>alert(1)</script>Real analysis', 'Real analysis'],
['<img src=x onerror=alert(1)>note', 'note'],
['<b>bold</b> and <i>italic</i>', 'bold and italic'],
['<style>body{}</style>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', () => {
Expand Down
7 changes: 5 additions & 2 deletions backend/src/predictions/predictions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}

Expand Down