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: 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 = [
      /