feat(inquiries): sanitize HTML in inquiry message at application layer (TEC-2929)
- Add SanitizeHtmlService (whitelist: b, i, br, p, a) using sanitize-html. - Force rel="noopener noreferrer nofollow" and target="_blank" on anchors. - Restrict URL schemes to http/https/mailto/tel; drop javascript: links. - Wire sanitizer into CreateInquiryHandler before InquiryEntity.createNew. - Register provider in InquiriesModule. - Add unit tests: 7 for the service + 2 handler-level XSS payload tests (<script>...</script> and <img onerror=...> stripped). Defense-in-depth complement to global SanitizeInputMiddleware so internal command paths bypassing HTTP middleware (queues, imports) stay safe. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,6 +2,7 @@ import type { EventBus } from '@nestjs/cqrs';
|
||||
import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository';
|
||||
import { CreateInquiryCommand } from '../commands/create-inquiry/create-inquiry.command';
|
||||
import { CreateInquiryHandler } from '../commands/create-inquiry/create-inquiry.handler';
|
||||
import { SanitizeHtmlService } from '../services/sanitize-html.service';
|
||||
|
||||
describe('CreateInquiryHandler', () => {
|
||||
let handler: CreateInquiryHandler;
|
||||
@@ -10,6 +11,7 @@ describe('CreateInquiryHandler', () => {
|
||||
let mockPrisma: {
|
||||
listing: { findUnique: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
let sanitizer: SanitizeHtmlService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockInquiryRepo = {
|
||||
@@ -29,11 +31,14 @@ describe('CreateInquiryHandler', () => {
|
||||
|
||||
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
|
||||
sanitizer = new SanitizeHtmlService();
|
||||
|
||||
handler = new CreateInquiryHandler(
|
||||
mockInquiryRepo as any,
|
||||
mockEventBus as unknown as EventBus,
|
||||
mockPrisma as any,
|
||||
mockLogger as any,
|
||||
sanitizer,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,4 +100,48 @@ describe('CreateInquiryHandler', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sanitizes <script> and on* handlers before persisting', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' });
|
||||
mockInquiryRepo.save.mockResolvedValue(undefined);
|
||||
|
||||
const command = new CreateInquiryCommand(
|
||||
'user-1',
|
||||
'listing-1',
|
||||
'<p>Chào anh</p><script>alert(1)</script><img src=x onerror="alert(2)"/>',
|
||||
null,
|
||||
);
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
const savedInquiry = mockInquiryRepo.save.mock.calls[0][0];
|
||||
const persisted: string = savedInquiry.message;
|
||||
|
||||
expect(persisted).toContain('<p>Chào anh</p>');
|
||||
expect(persisted).not.toContain('<script');
|
||||
expect(persisted).not.toContain('alert(1)');
|
||||
expect(persisted).not.toContain('onerror');
|
||||
expect(persisted).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('forces rel/target on anchor tags and drops javascript: URLs', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' });
|
||||
mockInquiryRepo.save.mockResolvedValue(undefined);
|
||||
|
||||
const command = new CreateInquiryCommand(
|
||||
'user-1',
|
||||
'listing-1',
|
||||
'<a href="https://example.com">ok</a><a href="javascript:alert(1)">bad</a>',
|
||||
null,
|
||||
);
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
const persisted: string = mockInquiryRepo.save.mock.calls[0][0].message;
|
||||
|
||||
expect(persisted).toContain('href="https://example.com"');
|
||||
expect(persisted).toContain('rel="noopener noreferrer nofollow"');
|
||||
expect(persisted).toContain('target="_blank"');
|
||||
expect(persisted).not.toContain('javascript:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { SanitizeHtmlService } from '../services/sanitize-html.service';
|
||||
|
||||
describe('SanitizeHtmlService', () => {
|
||||
const sanitizer = new SanitizeHtmlService();
|
||||
|
||||
it('strips <script> tags', () => {
|
||||
const out = sanitizer.sanitizeInquiryMessage('<p>Hi</p><script>alert(1)</script>');
|
||||
expect(out).toBe('<p>Hi</p>');
|
||||
});
|
||||
|
||||
it('drops inline event handlers like onerror', () => {
|
||||
const out = sanitizer.sanitizeInquiryMessage('<img src=x onerror="alert(1)" />');
|
||||
expect(out).not.toContain('onerror');
|
||||
expect(out).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('keeps allow-listed inline tags', () => {
|
||||
const out = sanitizer.sanitizeInquiryMessage('<b>bold</b> <i>italic</i><br/><p>para</p>');
|
||||
expect(out).toContain('<b>bold</b>');
|
||||
expect(out).toContain('<i>italic</i>');
|
||||
expect(out).toContain('<br />');
|
||||
expect(out).toContain('<p>para</p>');
|
||||
});
|
||||
|
||||
it('forces rel and target on <a>', () => {
|
||||
const out = sanitizer.sanitizeInquiryMessage('<a href="https://example.com">x</a>');
|
||||
expect(out).toContain('href="https://example.com"');
|
||||
expect(out).toContain('rel="noopener noreferrer nofollow"');
|
||||
expect(out).toContain('target="_blank"');
|
||||
});
|
||||
|
||||
it('drops javascript: scheme links', () => {
|
||||
const out = sanitizer.sanitizeInquiryMessage('<a href="javascript:alert(1)">x</a>');
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
it('returns empty string for nullish input', () => {
|
||||
expect(sanitizer.sanitizeInquiryMessage(null)).toBe('');
|
||||
expect(sanitizer.sanitizeInquiryMessage(undefined)).toBe('');
|
||||
expect(sanitizer.sanitizeInquiryMessage('')).toBe('');
|
||||
});
|
||||
|
||||
it('preserves plain Vietnamese text', () => {
|
||||
const text = 'Tôi muốn xem nhà vào cuối tuần này, anh sắp xếp giúp ạ.';
|
||||
expect(sanitizer.sanitizeInquiryMessage(text)).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { createId } from '@paralleldrive/cuid2';
|
||||
import { DomainException, NotFoundException, PrismaService, LoggerService } from '@modules/shared';
|
||||
import { InquiryEntity } from '../../../domain/entities/inquiry.entity';
|
||||
import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository';
|
||||
import { SanitizeHtmlService } from '../../services/sanitize-html.service';
|
||||
import { CreateInquiryCommand } from './create-inquiry.command';
|
||||
|
||||
export interface CreateInquiryResult {
|
||||
@@ -19,6 +20,7 @@ export class CreateInquiryHandler implements ICommandHandler<CreateInquiryComman
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly sanitizer: SanitizeHtmlService,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateInquiryCommand): Promise<CreateInquiryResult> {
|
||||
@@ -33,11 +35,12 @@ export class CreateInquiryHandler implements ICommandHandler<CreateInquiryComman
|
||||
}
|
||||
|
||||
const id = createId();
|
||||
const sanitizedMessage = this.sanitizer.sanitizeInquiryMessage(command.message);
|
||||
const inquiry = InquiryEntity.createNew(
|
||||
id,
|
||||
command.listingId,
|
||||
command.userId,
|
||||
command.message,
|
||||
sanitizedMessage,
|
||||
command.phone,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
/**
|
||||
* Whitelist-based HTML sanitizer for inquiry messages.
|
||||
*
|
||||
* Defense-in-depth complement to the global SanitizeInputMiddleware:
|
||||
* - the global middleware strips ALL tags from incoming HTTP request bodies,
|
||||
* - this service runs at the application layer right before persistence,
|
||||
* guaranteeing safe content even if a future code path (queue replay,
|
||||
* internal command dispatch, import job) bypasses the HTTP middleware.
|
||||
*
|
||||
* Allow-list is intentionally minimal per TEC-2929:
|
||||
* <b>, <i>, <br>, <p>, <a> (links forced to rel=noopener noreferrer nofollow,
|
||||
* target=_blank, http(s) only).
|
||||
*
|
||||
* Everything else (including <script>, <iframe>, <img onerror=...>, inline
|
||||
* event handlers, javascript: URLs, etc.) is dropped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SanitizeHtmlService {
|
||||
private static readonly OPTIONS: sanitizeHtml.IOptions = {
|
||||
allowedTags: ['b', 'i', 'br', 'p', 'a'],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'rel', 'target'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||
allowedSchemesAppliedToAttributes: ['href'],
|
||||
allowProtocolRelative: false,
|
||||
disallowedTagsMode: 'discard',
|
||||
transformTags: {
|
||||
a: sanitizeHtml.simpleTransform('a', {
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
target: '_blank',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize a single string of inquiry message content.
|
||||
*
|
||||
* Returns the sanitized string. Always returns a string (never throws on
|
||||
* non-string input — empty string is returned for null/undefined to keep
|
||||
* call sites simple).
|
||||
*/
|
||||
sanitizeInquiryMessage(input: string | null | undefined): string {
|
||||
if (typeof input !== 'string' || input.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return sanitizeHtml(input, SanitizeHtmlService.OPTIONS);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { CreateInquiryHandler } from './application/commands/create-inquiry/crea
|
||||
import { MarkInquiryReadHandler } from './application/commands/mark-inquiry-read/mark-inquiry-read.handler';
|
||||
import { GetInquiriesByAgentHandler } from './application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler';
|
||||
import { GetInquiriesByListingHandler } from './application/queries/get-inquiries-by-listing/get-inquiries-by-listing.handler';
|
||||
import { SanitizeHtmlService } from './application/services/sanitize-html.service';
|
||||
import { INQUIRY_REPOSITORY } from './domain/repositories/inquiry.repository';
|
||||
import { PrismaInquiryRepository } from './infrastructure/repositories/prisma-inquiry.repository';
|
||||
import { InquiriesController } from './presentation/controllers/inquiries.controller';
|
||||
@@ -20,6 +21,7 @@ const QueryHandlers = [
|
||||
controllers: [InquiriesController],
|
||||
providers: [
|
||||
{ provide: INQUIRY_REPOSITORY, useClass: PrismaInquiryRepository },
|
||||
SanitizeHtmlService,
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user