Mở đầu: Chuyện thật không đùa với Chatbot thương mại điện tử
Tháng 9 năm 2024, tôi triển khai chatbot AI cho một trang thương mại điện tử lớn tại Việt Nam. Hệ thống sử dụng RAG (Retrieval-Augmented Generation) để trả lời câu hỏi khách hàng về sản phẩm, đơn hàng, và chính sách đổi trả. Mọi thứ hoạt động hoàn hảo cho đến khi một khách hàng nhập:
Thay vì sanitize input, tôi đã quên mất rằng AI có thể trả về raw HTML hoặc script tags trong response. Kết quả? Cookie của khách hàng bị đánh cắp, và tôi phải xử lý incident suốt 3 ngày liền.
Bài viết này là tất cả những gì tôi đã học được — từ những sai lầm đau đớn nhất — để bạn không phải lặp lại.
Tại sao XSS trong AI Output nguy hiểm hơn bạn nghĩ
Khi làm việc với AI API như
HolySheheep AI, nhiều developer mắc sai lầm nghiêm trọng: coi output của AI như "trusted content" vì nó đến từ server. Thực tế hoàn toàn ngược lại:
- AI models không được train để sanitize output theo security context của bạn
- Prompt injection có thể khiến AI trả về malicious content
- RAG systems có thể retrieve poisoned content từ vector database
- User-provided context được AI xử lý và có thể reflect lại
Với HolySheep AI, bạn nhận được output với độ trễ dưới 50ms và chi phí chỉ từ $0.42/MTok (DeepSeek V3.2) — nhưng không có gì đảm bảo output an toàn. Đó là trách nhiệm của developer.
Implementation: Sanitization Pipeline hoàn chỉnh
Dưới đây là kiến trúc sanitization mà tôi đã implement thành công cho 5 production systems:
Bước 1: Base Sanitizer với DOMPurify
// sanitizer.js - Production-ready XSS sanitizer
import DOMPurify from 'dompurify';
class AISanitizer {
constructor(options = {}) {
this.config = {
// Chỉ cho phép các tags an toàn
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 'b', 'i',
'ul', 'ol', 'li', 'a', 'span', 'div',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'code', 'pre', 'table', 'tr', 'td', 'th'
],
// Attributes an toàn cho từng tag
ALLOWED_ATTR: [
'href', 'target', 'class', 'id',
'data-product-id', 'data-order-id'
],
// Force safe links
ALLOW_DATA_ATTR: false,
// Không cho phép JS protocol
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
// Xóa comment nodes (có thể chứa IE-specific XSS)
FORBID_TAGS: ['script', 'style', 'iframe', 'form', 'input', 'object', 'embed'],
...options
};
}
sanitize(htmlString) {
if (!htmlString || typeof htmlString !== 'string') {
return '';
}
// Bước 1: Pre-process - loại bỏ encoding tricks
let processed = this.preProcess(htmlString);
// Bước 2: DOMPurify sanitization
const clean = DOMPurify.sanitize(processed, {
ALLOWED_TAGS: this.config.ALLOWED_TAGS,
ALLOWED_ATTR: this.config.ALLOWED_ATTR,
ALLOW_DATA_ATTR: this.config.ALLOW_DATA_ATTR,
ALLOWED_URI_REGEXP: this.config.ALLOWED_URI_REGEXP,
FORBID_TAGS: this.config.FORBID_TAGS,
KEEP_CONTENT: true
});
// Bước 3: Post-process - verify links
return this.postProcess(clean);
}
preProcess(input) {
return input
// Chuyển đổi HTML entities
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&')
// Loại bỏ null bytes
.replace(/\0/g, '')
// Normalize whitespace
.replace(/[\r\n]+/g, '\n')
.trim();
}
postProcess(clean) {
// Thêm rel="noopener noreferrer" cho external links
const parser = new DOMParser();
const doc = parser.parseFromString(clean, 'text/html');
doc.querySelectorAll('a[href]').forEach(link => {
const href = link.getAttribute('href');
// Force internal links hoặc safe external links
if (href.startsWith('http') || href.startsWith('//')) {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
}
});
return doc.body.innerHTML;
}
// Utility: Sanitize cho text-only context
sanitizeText(text) {
if (!text) return '';
return text
.replace(/[<>]/g, '')
.replace(/javascript:/gi, '')
.replace(/on\w+=/gi, '')
.trim();
}
}
export const aiSanitizer = new AISanitizer();
export default AISanitizer;
Bước 2: Integration với HolySheep AI API
// openai-client.js - HolySheep AI Integration với XSS Protection
import { aiSanitizer } from './sanitizer.js';
class HolySheepAIClient {
constructor(apiKey) {
this.baseURL = 'https://api.holysheep.ai/v1';
this.apiKey = apiKey;
this.sanitizer = aiSanitizer;
}
async chat(messages, options = {}) {
const response = await fetch(${this.baseURL}/chat/completions, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${this.apiKey}
},
body: JSON.stringify({
model: options.model || 'gpt-4.1',
messages: messages,
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 2000
})
});
if (!response.ok) {
throw new Error(API Error: ${response.status} ${response.statusText});
}
const data = await response.json();
// TRƯỚC KHI TRẢ VỀ CHO FRONTEND - SANITIZE OUTPUT
const rawContent = data.choices[0].message.content;
const sanitizedContent = this.sanitizer.sanitize(rawContent);
return {
id: data.id,
model: data.model,
content: sanitizedContent, // ✅ Safe content
rawContent: rawContent, // ⚠️ Chỉ dùng cho debugging
usage: data.usage,
finishReason: data.choices[0].finish_reason
};
}
// Streaming với sanitization chunk-by-chunk
async *chatStream(messages, options = {}) {
const response = await fetch(${this.baseURL}/chat/completions, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${this.apiKey}
},
body: JSON.stringify({
model: options.model || 'gpt-4.1',
messages: messages,
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 2000,
stream: true
})
});
if (!response.ok) {
throw new Error(API Error: ${response.status});
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let fullContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
yield { done: true, content: '' };
return;
}
try {
const parsed = JSON.parse(data);
if (parsed.choices?.[0]?.delta?.content) {
const chunk = parsed.choices[0].delta.content;
fullContent += chunk;
// Sanitize từng chunk để stream an toàn
const sanitizedChunk = this.sanitizer.sanitize(chunk);
yield { done: false, content: sanitizedChunk };
}
} catch (e) {
// Ignore parse errors for incomplete JSON
}
}
}
}
}
}
// Sử dụng:
const client = new HolySheepAIClient('YOUR_HOLYSHEEP_API_KEY');
// Ví dụ: Chatbot trả lời về sản phẩm
const response = await client.chat([
{
role: 'system',
content: 'Bạn là trợ lý bán hàng. Chỉ trả lời về sản phẩm. Không chạy code.'
},
{
role: 'user',
content: 'Cho tôi biết thông tin về sản phẩm iPhone 15'
}
]);
console.log(response.content); // ✅ Đã sanitize - an toàn hiển thị
Bước 3: Frontend React Component với Security
// AIChatMessage.jsx - React component an toàn
import React, { useMemo } from 'react';
import DOMPurify from 'dompurify';
const AIChatMessage = ({ message, isBot = false }) => {
// Double sanitization ở frontend - defense in depth
const sanitizedHTML = useMemo(() => {
if (!message) return '';
// 1. Server-side sanitization đã làm, nhưng frontend vẫn kiểm tra lại
const raw = typeof message === 'string' ? message : message.content || '';
// 2. DOMPurify với cấu hình strict
const clean = DOMPurify.sanitize(raw, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'span'],
ALLOWED_ATTR: ['href', 'target', 'class'],
// Không cho phép bất kỳ data URI nào
FORBID_DATA_URI: true,
// Loại bỏ entire link nếu có vấn đề
ALLOW_ARIA_ATTR: false
});
// 3. Escape text content trong attributes
const safeHTML = clean.replace(
/on\w+="[^"]*"/gi,
''
).replace(
/javascript:/gi,
'unsafe:_'
);
return safeHTML;
}, [message]);
return (
<div className={message ${isBot ? 'bot-message' : 'user-message'}}>
<div
className="message-content"
dangerouslySetInnerHTML={{ __html: sanitizedHTML }}
/>
{isBot && (
<div className="security-badge" title="Nội dung đã được kiểm tra bảo mật">
✅ Verified
</div>
)}
</div>
);
};
export default AIChatMessage;
RAG System: Special Considerations
Với hệ thống RAG (Retrieval-Augmented Generation), vector database có thể chứa poisoned documents. Đây là cách tôi bảo mật:
// rag-sanitizer.js - RAG-specific XSS protection
import { aiSanitizer } from './sanitizer.js';
class RAGPipeline {
constructor(vectorDB, llmClient) {
this.vectorDB = vectorDB;
this.llmClient = llmClient;
this.sanitizer = aiSanitizer;
}
async query(userQuery, options = {}) {
// 1. Sanitize user query TRƯỚC KHI search
const cleanQuery = this.sanitizer.sanitizeText(userQuery);
// 2. Retrieve documents
const documents = await this.vectorDB.search(cleanQuery, {
topK: options.topK || 5,
filter: options.filter
});
// 3. CRITICAL: Sanitize ALL retrieved content
const sanitizedContext = documents.map(doc => {
// Kiểm tra metadata trust score
if (doc.metadata?.trustScore < 0.5) {
console.warn(Low trust document filtered: ${doc.id});
return null;
}
return {
...doc,
content: this.sanitizer.sanitize(doc.content),
metadata: {
...doc.metadata,
// Không bao giờ trust user-provided metadata
source: this.sanitizer.sanitizeText(doc.metadata?.source || 'unknown'),
// Validate all data types
timestamp: Number(doc.metadata?.timestamp) || Date.now()
}
};
}).filter(Boolean);
// 4. Build prompt với sanitized context
const prompt = this.buildPrompt(sanitizedContext, cleanQuery);
// 5. Gọi LLM
const response = await this.llmClient.chat([
{ role: 'system', content: 'You are a helpful assistant. Always answer based on the provided context.' },
{ role: 'user', content: prompt }
]);
// 6. Final sanitization của output
return {
answer: this.sanitizer.sanitize(response.content),
sources: sanitizedContext.map(d => ({
id: d.id,
source: d.metadata.source,
score: d.score
})),
metadata: {
contextLength: sanitizedContext.length,
querySanitized: cleanQuery !== userQuery,
timestamp: Date.now()
}
};
}
buildPrompt(context, query) {
const contextText = context
.map((doc, i) => [Document ${i + 1}]\nSource: ${doc.metadata.source}\n${doc.content})
.join('\n\n');
return `Context:
${contextText}
Question: ${query}
Instructions: Answer based ONLY on the context above. If information is not in context, say "Tôi không tìm thấy thông tin này trong dữ liệu được cung cấp."`;
}
}
// Inject vào vector DB query để detect poisoned content
class SecureVectorDB {
constructor(client) {
this.client = client;
}
async search(query, options) {
// Scan cho suspicious patterns TRƯỚC KHI search
const suspiciousPatterns = [
/