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:
Ho Ngoc Hai
2026-04-20 10:47:22 +07:00
parent 69d37c4e77
commit 3287298592
5 changed files with 154 additions and 1 deletions

View File

@@ -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:');
});
});

View File

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

View File

@@ -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,
);

View File

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

View File

@@ -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,
],