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 type { IInquiryRepository } from '../../domain/repositories/inquiry.repository';
import { CreateInquiryCommand } from '../commands/create-inquiry/create-inquiry.command'; import { CreateInquiryCommand } from '../commands/create-inquiry/create-inquiry.command';
import { CreateInquiryHandler } from '../commands/create-inquiry/create-inquiry.handler'; import { CreateInquiryHandler } from '../commands/create-inquiry/create-inquiry.handler';
import { SanitizeHtmlService } from '../services/sanitize-html.service';
describe('CreateInquiryHandler', () => { describe('CreateInquiryHandler', () => {
let handler: CreateInquiryHandler; let handler: CreateInquiryHandler;
@@ -10,6 +11,7 @@ describe('CreateInquiryHandler', () => {
let mockPrisma: { let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> }; listing: { findUnique: ReturnType<typeof vi.fn> };
}; };
let sanitizer: SanitizeHtmlService;
beforeEach(() => { beforeEach(() => {
mockInquiryRepo = { mockInquiryRepo = {
@@ -29,11 +31,14 @@ describe('CreateInquiryHandler', () => {
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
sanitizer = new SanitizeHtmlService();
handler = new CreateInquiryHandler( handler = new CreateInquiryHandler(
mockInquiryRepo as any, mockInquiryRepo as any,
mockEventBus as unknown as EventBus, mockEventBus as unknown as EventBus,
mockPrisma as any, mockPrisma as any,
mockLogger 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 { DomainException, NotFoundException, PrismaService, LoggerService } from '@modules/shared';
import { InquiryEntity } from '../../../domain/entities/inquiry.entity'; import { InquiryEntity } from '../../../domain/entities/inquiry.entity';
import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository'; import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository';
import { SanitizeHtmlService } from '../../services/sanitize-html.service';
import { CreateInquiryCommand } from './create-inquiry.command'; import { CreateInquiryCommand } from './create-inquiry.command';
export interface CreateInquiryResult { export interface CreateInquiryResult {
@@ -19,6 +20,7 @@ export class CreateInquiryHandler implements ICommandHandler<CreateInquiryComman
private readonly eventBus: EventBus, private readonly eventBus: EventBus,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly logger: LoggerService, private readonly logger: LoggerService,
private readonly sanitizer: SanitizeHtmlService,
) {} ) {}
async execute(command: CreateInquiryCommand): Promise<CreateInquiryResult> { async execute(command: CreateInquiryCommand): Promise<CreateInquiryResult> {
@@ -33,11 +35,12 @@ export class CreateInquiryHandler implements ICommandHandler<CreateInquiryComman
} }
const id = createId(); const id = createId();
const sanitizedMessage = this.sanitizer.sanitizeInquiryMessage(command.message);
const inquiry = InquiryEntity.createNew( const inquiry = InquiryEntity.createNew(
id, id,
command.listingId, command.listingId,
command.userId, command.userId,
command.message, sanitizedMessage,
command.phone, 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 { 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 { 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 { 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 { INQUIRY_REPOSITORY } from './domain/repositories/inquiry.repository';
import { PrismaInquiryRepository } from './infrastructure/repositories/prisma-inquiry.repository'; import { PrismaInquiryRepository } from './infrastructure/repositories/prisma-inquiry.repository';
import { InquiriesController } from './presentation/controllers/inquiries.controller'; import { InquiriesController } from './presentation/controllers/inquiries.controller';
@@ -20,6 +21,7 @@ const QueryHandlers = [
controllers: [InquiriesController], controllers: [InquiriesController],
providers: [ providers: [
{ provide: INQUIRY_REPOSITORY, useClass: PrismaInquiryRepository }, { provide: INQUIRY_REPOSITORY, useClass: PrismaInquiryRepository },
SanitizeHtmlService,
...CommandHandlers, ...CommandHandlers,
...QueryHandlers, ...QueryHandlers,
], ],