🌐 XSS và CSRF: lỗi render dữ liệu và thao tác thiếu token

By Better Wise

Vì sao bài này quan trọng

Bài trước bạn đã bắt được lỗi AI nối chuỗi SQL và sửa bằng parameterized query. Bài này là người anh em cùng họ của lỗi đó: dữ liệu người dùng vẫn đi vào chỗ không nên đi, nhưng lần này nó đi vào HTML và vào các thao tác làm thay đổi dữ liệu.

Sự cố tôi hay kể: một bạn dev nhờ AI viết trang profile, AI trả về đúng 3 dòng, trong đó có el.innerHTML = user.bio. Sáu tháng sau, một tài khoản đặt bio thành thẻ img có onerror, và mọi người xem profile đó đều chạy đoạn script lạ trong phiên đăng nhập của chính họ. Không ai review dòng đó, vì nó "chỉ là hiển thị text".

Sau 25 phút này bạn sẽ:

  • Nhận ra 2 kiểu render dữ liệu không escape mà AI rất hay sinh ra.
  • Viết được bản sửa tối thiểu: textContent, escape, hoặc để template engine tự escape.
  • Nhận ra endpoint thay đổi dữ liệu thiếu CSRF token và biết 3 lớp phòng thủ đi kèm.
  • Phân biệt XSS và CSRF khi ngồi review code người khác.

XSS: khi AI tin dữ liệu người dùng là văn bản vô hại

XSS xảy ra khi dữ liệu người dùng được đưa vào trang mà không escape, nên trình duyệt hiểu nó là HTML hoặc JavaScript và chạy nó. AI rất hay mắc lỗi này vì innerHTML và nối chuỗi là cách ngắn nhất để "làm cho nó chạy".

Ví dụ 1: innerHTML ở frontend

// SAI: AI sinh ra để hiển thị bình luận
const el = document.getElementById('comment');
el.innerHTML = comment.text;

Với comment.text là Xin chào <img src=x onerror="...">, trình duyệt sẽ tạo thẻ img, ảnh lỗi, và chạy onerror. Đây là stored XSS: payload nằm trong database nên ai xem cũng dính.

Bản sửa tối thiểu, khi bạn chỉ cần hiển thị văn bản:

// ĐÚNG: trình duyệt coi toàn bộ là text, không parse thẻ
el.textContent = comment.text;

Ví dụ 2: nối chuỗi ở server

// SAI: reflected XSS
app.get('/hello', (req, res) => {
  res.send('<h1>Xin chào ' + req.query.name + '</h1>');
});

Ai gửi link /hello?name=<script>...</script> cho nạn nhân là chạy được script trong origin của bạn. Bản sửa tối thiểu: để template engine escape.

// ĐÚNG
res.render('hello', { name: req.query.name });
// hello.ejs: <h1>Xin chào <%= name %></h1>

Ghi nhớ cặp cú pháp này của EJS: dấu = sau % thì tự escape, dấu - sau % thì in thô. AI rất thích dùng dấu - vì "cho đẹp", và đó chính là lỗ hổng.

Với React, JSX tự escape, nhưng dangerouslySetInnerHTML thì tắt escape. Tên prop đã tự cảnh báo, đừng bỏ qua nó.

Lớp phòng thủ bổ sung (không thay thế việc escape): đặt cookie session với HttpOnly để script không đọc được cookie, và dùng Content-Security-Policy để chặn inline script.

CSRF: khi trình duyệt tự gửi cookie thay người dùng

CSRF khai thác một hành vi mặc định của trình duyệt: mọi request tới domain A đều tự mang cookie của domain A, kể cả khi request đó do một site khác khởi tạo. Kẻ tấn công không cần đọc được gì, chỉ cần khiến trình duyệt gửi request.

Ví dụ 3: endpoint chuyển tiền không token

- app.post('/api/transfer', (req, res) => {
-   transfer(req.session.userId, req.body.to, req.body.amount);
-   res.json({ ok: true });
- });
+ app.post('/api/transfer', csrfProtection, (req, res) => {
+   if (!sameOrigin(req)) return res.status(403).end();
+   transfer(req.session.userId, req.body.to, req.body.amount);
+   res.json({ ok: true });
+ });

Kẻ tấn công chỉ cần đặt một form ẩn trỏ tới https://your-app.example/api/transfer trên trang của họ, kèm một dòng script submit. Người dùng đang đăng nhập mở trang đó, trình duyệt gửi cookie session, server thấy session hợp lệ và chuyển tiền. AI viết endpoint này rất nhanh vì nó chỉ nghĩ tới "đã có session là đủ".

Ba lớp phòng thủ, nên có ít nhất lớp 1 và 2:

  1. CSRF token: server sinh token gắn với session, form hoặc header phải gửi kèm. Site khác không đọc được token nên không gửi đúng được.
  2. Cookie SameSite: đặt sameSite: 'Lax' (hoặc 'Strict') để cookie không đi kèm request cross-site.
app.use(session({
  cookie: { httpOnly: true, secure: true, sameSite: 'Lax' }
}));
  1. Kiểm tra Origin (dự phòng Referer) với mọi request POST/PUT/PATCH/DELETE.

Quy tắc: mọi endpoint thay đổi trạng thái mà xác thực bằng cookie session đều cần CSRF token. Endpoint chỉ đọc dữ liệu thì không cần, và API dùng bearer token trong header cũng không bị kiểu tấn công này.

Phân biệt nhanh khi ngồi review

XSS CSRF
Lỗi gốc render dữ liệu không escape thao tác thay đổi dữ liệu thiếu token và thiếu kiểm tra nguồn
Cái bị lợi dụng trình duyệt chạy script trong trang của bạn trình duyệt tự gửi cookie của bạn
Hậu quả chạy JS trong phiên nạn nhân thực hiện hành động thay nạn nhân
Bản sửa tối thiểu textContent, escape, template engine tự escape CSRF token + SameSite + kiểm tra Origin

Tự kiểm tra nhanh:

Cách sửa tối thiểu cho innerHTML khi chỉ cần hiển thị văn bản là dùng _____. Trong EJS, cú pháp có dấu _____ sau % thì tự escape, còn dấu _____ thì in thô. Thuộc tính cookie dùng để chặn trình duyệt gửi cookie trong request cross-site là _____. Ngoài CSRF token, server nên kiểm tra header _____ với các request thay đổi dữ liệu.

Front: XSS là gì? Back: Lỗi cho phép dữ liệu người dùng được chạy như HTML hoặc JavaScript trong trình duyệt của người khác.

Front: Stored XSS khác reflected XSS ở đâu? Back: Stored: payload lưu trong database, ai xem cũng dính. Reflected: payload nằm trong request và phản hồi ngay.

Front: API DOM nào AI hay dùng sai khi render comment? Back: innerHTML. Nếu chỉ cần hiển thị văn bản thì dùng textContent.

Front: Trong EJS, cú pháp nào tự escape? Back: Cú pháp có dấu = ngay sau %.

Front: Trong EJS, cú pháp nào in thô và tạo lỗ hổng? Back: Cú pháp có dấu trừ ngay sau %.

Front: React có tự escape khi render chuỗi không? Back: Có, trừ khi bạn dùng dangerouslySetInnerHTML.

Front: CSRF là gì? Back: Kẻ tấn công lợi dụng cookie session của nạn nhân để gửi request thay đổi dữ liệu từ một site khác.

Front: Vì sao CSRF hoạt động được? Back: Trình duyệt tự gửi cookie của domain đích kèm mọi request, kể cả request do site khác khởi tạo.

Front: CSRF token chặn được gì? Back: Site khác không đọc được token nên không gửi kèm token đúng trong request giả mạo.

Front: Thuộc tính cookie nào giảm CSRF? Back: SameSite=Lax hoặc Strict, kèm Secure khi chạy HTTPS.

Front: Cookie HttpOnly giúp gì khi bị XSS? Back: Script không đọc được cookie session, nên thiệt hại giảm dù XSS vẫn xảy ra.

Front: Header nào nên kiểm tra với POST, PUT, PATCH, DELETE? Back: Origin, dự phòng bằng Referer.

Front: Những endpoint nào cần CSRF token? Back: Mọi endpoint thay đổi trạng thái mà xác thực bằng cookie session.

Front: Lớp phòng thủ nào chặn inline script như một lớp bổ sung? Back: Content-Security-Policy (CSP).

Bốn câu hỏi tôi luôn hỏi khi đọc code AI có render hoặc POST

  • Chuỗi này có đi qua innerHTML, dangerouslySetInnerHTML, document.write, hoặc nối chuỗi HTML ở server không? Nếu có, đó là chỗ phải escape hoặc đổi sang textContent.
  • Template có dùng cú pháp in thô không? Dấu trừ trong EJS, |safe trong Jinja, v-html trong Vue, tất cả đều là công tắc tắt escape.
  • Endpoint này có thay đổi dữ liệu không? Nếu có và xác thực bằng cookie session, thiếu CSRF token là lỗi chặn deploy.
  • Cookie session đã có HttpOnly, Secure, SameSite chưa? Ba thuộc tính này là mặc định an toàn, đừng để AI dùng config mặc định của framework.

Lỗi phổ biến nhất của người mới: sửa XSS bằng cách chặn ký tự < bằng regex. Đừng làm vậy, chỉ cần escape đúng ngữ cảnh (HTML, attribute, URL là ba ngữ cảnh khác nhau) hoặc dùng thư viện sanitize đã được kiểm chứng.

Chốt lại

  • XSS là lỗi render: dữ liệu người dùng vào HTML mà không escape. Sửa bằng textContent, escape đúng ngữ cảnh, hoặc để template engine tự escape.
  • CSRF là lỗi thao tác thiếu token: endpoint thay đổi dữ liệu dựa vào cookie session mà không có CSRF token, không có SameSite, không kiểm tra Origin.
  • Cả hai đều là loại AI sinh ra trong 3 dòng code và trông rất "bình thường", nên chỉ có review mới bắt được.

Bài tiếp theo là checkpoint của module: bạn sẽ đọc một đoạn code AI viết và tự chỉ ra lỗi injection nằm ở đâu, gồm cả SQL injection ở bài trước và hai lỗi hôm nay.