🗄️ SQL injection: AI hay nối chuỗi và cách sửa bằng parameterized query

By Better Wise

Vì sao lỗi nối chuỗi vẫn nằm trong code AI viết

Bài trước bạn đã có bản đồ 6 rủi ro phổ biến trong code do AI sinh. Đứng đầu danh sách đó là SQL injection, và lý do rất đơn giản: nó là kiểu lỗi mà AI tạo ra nhiều nhất, sửa cũng nhanh nhất, nhưng hậu quả thì thuộc loại nặng nhất.

Sự cố tôi từng điều tra: một endpoint tra đơn hàng, code do AI viết trong 30 giây, chạy ổn suốt sáu tháng ở môi trường dev. Đến khi lên production, chỉ cần một người gõ dấu nháy đơn vào ô tra cứu là toàn bộ bảng đơn hàng lộ ra. Không có tường lửa nào cứu được một câu query đã tự nguyện mời dữ liệu lạ vào làm lệnh.

Sau bài này, bạn cần làm được:

  • Nhận ra câu SQL bị nối chuỗi trong code do AI viết, kể cả khi nó trông rất "gọn".
  • Giải thích được vì sao input không bao giờ được trở thành một phần của câu lệnh.
  • Thay đúng bằng parameterized query ở Node.js và Python.
  • Xử lý được bốn chỗ parameterized query không tự cứu được: ORDER BY, LIKE, IN, và raw query trong ORM.

Dấu hiệu: biến từ request nằm ngay trong chuỗi SQL

Đoạn code dưới đây là mẫu AI sinh ra rất thường xuyên khi bạn nhờ "viết API tra đơn hàng theo mã khách":

// CÓ LỖI: input được ghép thẳng vào câu lệnh
app.get('/orders', (req, res) => {
  const customerId = req.query.customerId;
  const sql =
    "SELECT id, total, status FROM orders WHERE customer_id = '" +
    customerId +
    "'";
  db.query(sql, (err, rows) => {
    if (err) return res.status(500).end();
    res.json(rows);
  });
});

Cách nhận biết không cần đọc hiểu cả hàm: tìm dấu +, template literal `, .format(), %, hoặc f-string nằm bên trong một chuỗi SQL, và bên trong đó có biến đến từ req, params, query, body, hoặc bất kỳ thứ gì người dùng gửi lên. Có đủ hai thứ đó là có lỗi.

Python cũng y hệt:

# CÓ LỖI
email = request.args.get("email")
cursor.execute("SELECT id, role FROM users WHERE email = '%s'" % email)

Vì sao chỉ một dấu nháy đơn là đủ để hỏng

Sign in to view the full content of this lesson. Sign in

Database không đọc code của bạn theo ý bạn. Nó nhận một chuỗi ký tự và tự phân tích: đâu là lệnh, đâu là dữ liệu. Dấu nháy đơn chính là ranh giới giữa hai phần đó.

Khi bạn ghép chuỗi, input của người dùng được dán vào trước khi database phân tích câu lệnh. Nếu input chứa dấu nháy đơn, ranh giới bị đóng sớm, và phần còn lại của input được database hiểu là lệnh chứ không phải giá trị. Đó là toàn bộ cơ chế. Không cần lỗi cú pháp, không cần quyền cao, chỉ cần một chỗ mà dữ liệu và lệnh nằm chung một chuỗi.

Parameterized query tồn tại để dữ liệu không bao giờ được phân tích như lệnh.

Sửa đúng: parameterized query

Driver gửi câu lệnh SQL đã được biên dịch trước, và gửi giá trị qua một kênh riêng. Giá trị được bind vào placeholder sau khi câu lệnh đã được hiểu. Dù input có chứa dấu nháy đơn, nó vẫn chỉ là một chuỗi ký tự trong một cột.

Node.js với mysql2:

// ĐÚNG: placeholder ?, giá trị truyền bằng mảng
app.get('/orders', (req, res) => {
  const sql = 'SELECT id, total, status FROM orders WHERE customer_id = ?';
  db.query(sql, [req.query.customerId], (err, rows) => {
    if (err) return res.status(500).end();
    res.json(rows);
  });
});

Node.js với pg (PostgreSQL): placeholder là $1, $2, ... theo thứ tự.

const sql = 'SELECT id, role FROM users WHERE email = $1';
const { rows } = await pool.query(sql, [email]);

Python với sqlite3 hoặc psycopg:

# ĐÚNG: placeholder ?, giá trị truyền bằng tuple
cursor.execute("SELECT id, role FROM users WHERE email = ?", (email,))

Ba điểm cần nhớ khi sửa:

  1. Placeholder phải nằm ngoài dấu nháy trong câu SQL. Viết WHERE email = '?' là sai, nó thành so sánh với ký tự ?.
  2. Không truyền giá trị bằng cách nối chuỗi vào placeholder. "... WHERE id = " + id vẫn là nối chuỗi.
  3. Đừng thay bằng hàm escape thủ công. Nó dễ sai, phụ thuộc charset, và không xử lý được trường hợp số học. Parameterized query đúng ngay từ thiết kế.

Bốn chỗ AI hay viết ẩu mà parameterized query không cứu

Đây là phần khiến nhiều người sửa xong vẫn còn lỗi. Placeholder chỉ bind được giá trị, không bind được cấu trúc của câu lệnh.

Tình huống AI thường viết Cách đúng
Sắp xếp động "ORDER BY " + req.query.sort Allowlist: ánh xạ input qua map cột và hướng hợp lệ
Tìm kiếm WHERE name LIKE '%?%' WHERE name LIKE ? và truyền giá trị %keyword%
Lọc theo danh sách WHERE id IN ( + ids.join(',') + ) Sinh đúng số ? theo độ dài mảng, truyền mảng giá trị
ORM sequelize.query(\... ${name}`)` Dùng replacements / bind của ORM đó

Ví dụ sửa hai chỗ khó nhất:

// ORDER BY: chỉ cho phép cột và hướng nằm trong allowlist
const sortable = { created_at: 'created_at', total: 'total' };
const column = sortable[req.query.sort] || 'created_at';
const direction = req.query.dir === 'asc' ? 'ASC' : 'DESC';
db.query(`SELECT id, total FROM orders ORDER BY ${column} ${direction}`);

Ở đây nối chuỗi vẫn xuất hiện, nhưng giá trị đưa vào đã được chọn từ một danh sách cố định do bạn viết, không phải từ input.

// LIKE: ký tự % nằm trong giá trị, không nằm trong câu lệnh
const keyword = `%${req.query.q}%`;
db.query('SELECT id, name FROM users WHERE name LIKE ?', [keyword]);

Bài tập nhanh: soi bốn đoạn code

Đoạn nào có lỗi, đoạn nào an toàn?

// A
const sql = `SELECT * FROM posts WHERE slug = '${req.params.slug}'`;

// C
const sql = "SELECT * FROM products ORDER BY " + req.query.sort;

// D
db.query("SELECT * FROM users WHERE name LIKE '%?%'", [keyword]);
# B
cursor.execute("SELECT id FROM users WHERE email = ?", (email,))

Xem đáp án và lý do

  • A: có lỗi. Template literal chèn req.params.slug thẳng vào chuỗi SQL. Sửa: WHERE slug = ? và truyền [req.params.slug].
  • B: an toàn. Placeholder ? nằm ngoài dấu nháy, giá trị truyền bằng tuple.
  • C: có lỗi. Placeholder không bind được tên cột hay từ khóa ORDER BY. Sửa bằng allowlist như ví dụ trên.
  • D: có lỗi. ? nằm trong dấu nháy nên driver coi đó là ký tự bình thường, còn keyword thì không được dùng. Sửa: WHERE name LIKE ? và truyền %keyword%.

Ba thói quen khi review query do AI viết

Một: grep trước, đọc sau. Tìm query(, execute(, raw( rồi xem ngay bên trong có biến request không. Nhanh hơn nhiều so với đọc tuần tự cả file.

Hai: nghi ngờ mọi chỗ "trông gọn". AI rất thích template literal vì nó ngắn. Càng ngắn càng dễ che mất việc input đang đi thẳng vào câu lệnh.

Ba: thử bằng dữ liệu có dấu nháy đơn. Nhập một chuỗi chứa ' vào mọi ô tra cứu trước khi deploy. Nếu app trả về lỗi SQL thay vì "không tìm thấy", bạn còn việc phải làm.

Sai lầm phổ biến nhất: sửa endpoint chính nhưng bỏ quên các endpoint phụ như autocomplete, export CSV, hay API nội bộ. AI sinh code theo từng đoạn, nên lỗi cũng rải rác theo từng đoạn.

Chốt lại và bước tiếp

  • Input đi vào câu SQL bằng phép nối chuỗi là lỗi, không có ngoại lệ.
  • Parameterized query tách lệnh khỏi dữ liệu; placeholder phải nằm ngoài dấu nháy.
  • Bốn chỗ phải xử lý riêng: ORDER BY và tên cột (allowlist), LIKE (% trong giá trị), IN (sinh placeholder theo mảng), raw query trong ORM (dùng bind của ORM).
  • Escape thủ công không phải bản sửa. Hãy bind tham số.

Bài sau chuyển sang một họ lỗi khác cũng do AI sinh ra rất nhiều: XSS và CSRF, tức là khi dữ liệu được render thẳng ra trang và khi thao tác thay đổi dữ liệu thiếu token bảo vệ.

Front: Dấu hiệu nào cho thấy câu SQL do AI viết có nguy cơ injection? Back: Chuỗi SQL được ghép từ biến lấy từ request bằng phép cộng chuỗi, template literal, % hoặc f-string.

Front: Parameterized query ngăn injection bằng cách nào? Back: Gửi câu lệnh và giá trị qua hai kênh riêng; driver bind giá trị vào placeholder sau khi câu lệnh đã được phân tích.

Front: Placeholder của driver mysql2 trong Node.js là gì? Back: Dấu ? và giá trị truyền vào dưới dạng mảng.

Front: Placeholder của thư viện pg với PostgreSQL có dạng nào? Back: $1, $2, ... theo đúng thứ tự tham số.

Front: Vì sao hàm escape thủ công không phải bản sửa đủ tốt? Back: Dễ sai sót, phụ thuộc charset, và không thay thế được việc tách lệnh khỏi dữ liệu.

Front: Phần nào của câu SQL không thể parameterize? Back: Tên bảng, tên cột và từ khóa như ORDER BY, ASC, DESC.

Front: Xử lý ORDER BY động an toàn bằng cách nào? Back: Ánh xạ input qua allowlist cột và hướng sắp xếp hợp lệ rồi mới đưa vào câu lệnh.

Front: Với mệnh đề IN chứa mảng giá trị thì làm gì? Back: Sinh đúng số placeholder theo độ dài mảng rồi truyền mảng giá trị vào.

Front: Khi dùng LIKE với parameterized query, đặt % ở đâu? Back: Trong giá trị truyền vào, ví dụ tham số là %keyword%, không nối vào câu SQL.

Front: Placeholder đặt trong dấu nháy đơn, ví dụ WHERE email = '?', thì sao? Back: Sai: database so sánh với ký tự ?, giá trị truyền vào không được dùng.

Front: AI thường viết sai thế nào khi dùng ORM? Back: Dùng raw query với template literal nội suy biến thay vì dùng replacements hoặc bind của ORM.

Front: Vì sao lỗi nối chuỗi hay sót ở các endpoint phụ? Back: AI sinh code theo từng đoạn, nên autocomplete, export CSV hay API nội bộ thường bị bỏ qua khi sửa.

Front: Cách kiểm tra nhanh nhất trước khi deploy? Back: Grep các chỗ nối chuỗi vào SQL, thay bằng bind tham số, rồi nhập thử dữ liệu có dấu nháy đơn.

Trong parameterized query, driver gửi câu lệnh SQL và giá trị qua _____, nên input không thể trở thành một phần của câu lệnh. Dấu hiệu nhận biết nhanh là chuỗi SQL có phép _____ hoặc template literal chứa biến lấy từ request. Với Node.js và driver mysql2, placeholder mặc định là _____ và giá trị truyền vào dưới dạng _____. Với PostgreSQL và thư viện pg, tham số đầu tiên được viết là _____. Không thể parameterize tên bảng, tên cột hay từ khóa ORDER BY, nên phải dùng _____ các giá trị hợp lệ. Khi dùng LIKE, ký tự đại diện % phải nằm trong _____, không nằm trong câu lệnh SQL.