SQL injection là một bug khi dựng chuỗi
SQL injection xảy ra khi đầu vào của người dùng trở thành một phần của đoạn văn bản SQL mà cơ sở dữ liệu phân tích. Một khi ranh giới đó bị xóa nhòa, khi một giá trị người dùng gõ vào trở thành cú pháp mà cơ sở dữ liệu thực thi, người dùng có thể làm mọi thứ mà bạn làm được.
Đây là anti-pattern kinh điển, viết bằng mã giả mà ngôn ngữ nào cũng có thể tạo ra:
-- DON'T DO THIS
query = "SELECT * FROM users WHERE name = '" + user_input + "'"
Nếu user_input là Ada, bạn có một lần tra cứu bình thường. Nếu user_input là ' OR 1=1 --, bạn nhận được:
SELECT * FROM users WHERE name = '' OR 1=1 --'
-- biến dấu nháy phía sau thành chú thích, OR 1=1 khớp với mọi hàng, và kẻ tấn công vừa lấy sạch bảng người dùng của bạn. Những phiên bản tệ hơn nối thêm ; và một câu lệnh thứ hai để xóa bảng, rút trộm dữ liệu hoặc chèn một tài khoản admin mới.
Lỗ hổng không nằm ở SQLite. Nó nằm ở đoạn code đã dựng nên chuỗi đó.
Truy vấn tham số hóa: cách sửa thật sự
Truy vấn tham số hóa (parameterized query) tách văn bản SQL khỏi giá trị. SQL có các placeholder, ? hoặc :name, và bạn truyền giá trị đi kèm. SQLite phân tích và biên dịch SQL một lần, rồi bind giá trị của bạn vào kế hoạch đã biên dịch. Giá trị không thể trở thành SQL.
Chạy một truy vấn tra cứu trông có vẻ dễ bị tấn công theo cách an toàn:
Trong SQLite shell, bạn gõ trực tiếp giá trị, nhưng trong code ứng dụng, phiên bản tương đương trông như thế này (driver sqlite3 của Python):
# Python - parameterized, safe
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))
Truyền SQL và tuple giá trị thành hai đối số riêng biệt. Driver gửi chúng tới SQLite một cách riêng rẽ. Ngay cả khi user_input là ' OR 1=1 --, SQLite sẽ tìm một người dùng có tên đúng nguyên văn là ' OR 1=1 -- và không tìm thấy ai.
"An toàn" ở đây thật sự nghĩa là gì
Sự an toàn không đến từ việc so khớp mẫu hay escape. Nó đến từ cấu trúc. SQLite biên dịch câu lệnh thành dạng nội bộ trước khi nhìn thấy giá trị của bạn:
-- The compiled statement has a slot, not a string.
SELECT * FROM users WHERE name = ?
^
placeholder slot
Khi bạn bind một giá trị, nó đi vào ô đó như một dữ liệu có kiểu: TEXT, INTEGER, BLOB, hay gì đi nữa. SQLite không bao giờ phân tích lại nó như SQL. Không có cú pháp nào để kẻ tấn công chèn vào, vì trình phân tích cú pháp đã làm xong việc.
Đó là lý do truy vấn tham số hóa đáng tin cậy theo cách mà escape không bao giờ đạt được. Escape cố gắng lọc bỏ các ký tự nguy hiểm khỏi một chuỗi. Bind thì ngay từ đầu không bao giờ dựng ra chuỗi nguy hiểm.
Đừng dùng định dạng chuỗi
Ngôn ngữ nào cũng có một lối tắt hấp dẫn (f-string trong Python, template literal trong JavaScript, String.format trong Java), và cái nào cũng là cách tự bắn vào chân khi dùng với SQL.
# DON'T - f-string interpolates the value into the SQL text
cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")
# DON'T - same problem, % formatting
cursor.execute("SELECT * FROM users WHERE name = '%s'" % user_input)
# DO - placeholder + values argument
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))
Hai cách đầu ghép đầu vào của người dùng vào chuỗi SQL trước khi driver kịp nhìn thấy. Đến lúc SQLite nhận được truy vấn, thiệt hại đã xảy ra. Cách thứ ba giữ SQL và giá trị ở hai làn riêng.
Quy tắc này mang tính máy móc: nếu bạn thấy mình đang dựng chuỗi SQL bằng +, f-string, format hay template literal ở chỗ có giá trị, hãy dừng lại và dùng placeholder.
Nhiều tham số và placeholder có tên
Truy vấn thực tế thường có nhiều hơn một giá trị. SQLite hỗ trợ cả placeholder theo vị trí ? lẫn placeholder có tên :name:
Trong code ứng dụng, chúng trở thành:
# Positional
cursor.execute(
"SELECT * FROM orders WHERE customer = ? AND status = ?",
("Ada", "paid"),
)
# Named - clearer when there are several parameters
cursor.execute(
"SELECT * FROM orders WHERE total > :min_total AND status = :status",
{"min_total": 50, "status": "paid"},
)
Tham số có tên mở rộng tốt hơn. Khi có quá ba hay bốn giá trị, ?, ?, ?, ? thành trò đoán mò; còn :customer, :total, :status, :created_at tự giải thích chính nó.
Định danh cần cách tiếp cận khác
Tham số được bind chỉ dùng được cho giá trị: những thứ ở vế phải của =, bên trong IN (...), trong VALUES (...). Chúng không dùng được cho tên bảng, tên cột hay các từ khóa SQL như ASC/DESC.
-- This does NOT work. The placeholder can't stand in for a column name.
SELECT * FROM users ORDER BY ? ASC
Nếu bạn cần một định danh động, chẳng hạn cho người dùng chọn cột để sắp xếp, hãy kiểm tra với một allowlist trước khi dựng SQL:
# Allowlist approach
ALLOWED_SORT_COLUMNS = {"name", "created_at", "role"}
if sort_column not in ALLOWED_SORT_COLUMNS:
raise ValueError(f"Invalid sort column: {sort_column}")
query = f"SELECT * FROM users ORDER BY {sort_column} ASC"
cursor.execute(query)
Chuỗi do người dùng cung cấp được kiểm tra với một tập cố định các giá trị đã biết là an toàn trước khi đến gần SQL. F-string chỉ chấp nhận được ở đây vì sort_column giờ chỉ có thể là một trong ba tên được viết cứng.
Một lần thử injection cụ thể, đã bị vô hiệu hóa
Hãy xem cả hai phiên bản cạnh nhau với một đầu vào độc hại. Tạo một bảng người dùng nhỏ:
Dạng dễ bị tấn công trả về mọi người dùng. Dạng tham số hóa tìm một người dùng có tên đúng nguyên văn là ' OR 1=1 -- và không trả về gì. Cùng một đầu vào, kết quả hoàn toàn khác, vì trong trường hợp thứ hai, giá trị không bao giờ trở thành SQL.
Danh sách kiểm tra ngắn
- Dùng placeholder
?hoặc:namecho mọi giá trị đến từ bên ngoài code của bạn: đầu vào của người dùng, body của request, biến môi trường, bất cứ thứ gì bạn không tự viết trực tiếp. - Không bao giờ dựng SQL bằng
+, f-string hayformatở chỗ có giá trị. - Với tên bảng hoặc tên cột động, kiểm tra với một allowlist cố định trước khi ghép vào truy vấn.
- Tin vào driver. Đừng tự viết hàm escape dấu nháy. Cơ chế bind tham số lâu đời hơn, được kiểm chứng kỹ hơn và đúng.
- Review truy vấn của nhóm bằng một câu hỏi: có đầu vào nào của người dùng đang bị nối vào văn bản SQL không? Nếu có, hãy sửa.
Biến điều này thành phản xạ và SQL injection sẽ không còn là loại bug bạn phải bận tâm.
Tiếp theo: kết nối từ ứng dụng
Bạn đã thấy hình dạng an toàn của một truy vấn: placeholder trong SQL, giá trị truyền kèm. Trang tiếp theo hướng dẫn cách thật sự kết nối SQLite từ code ứng dụng thực tế bằng Python, Node.js và vài ngôn ngữ khác, gồm cả cách quản lý kết nối và vị trí của truy vấn tham số hóa trong một luồng xử lý request điển hình.
Câu hỏi thường gặp
SQLite có bị SQL injection không?
Có. SQLite cũng dễ bị tấn công như mọi cơ sở dữ liệu SQL khác khi code ứng dụng dựng truy vấn bằng cách nối chuỗi. Cách sửa không nằm ở một thiết lập của SQLite mà ở cách bạn truyền giá trị từ ứng dụng. Dùng truy vấn tham số hóa với placeholder ? hoặc :name và driver sẽ xử lý an toàn.
Truy vấn tham số hóa chống SQL injection như thế nào?
Khi bạn dùng placeholder như ?, SQLite phân tích và biên dịch truy vấn trước, rồi mới bind giá trị của bạn vào các ô trong câu lệnh đã biên dịch. Giá trị không bao giờ có thể trở thành cú pháp SQL; chúng được coi là dữ liệu, hết chuyện. Không có chuỗi nào để kẻ tấn công thoát ra.
Tôi chỉ cần escape dấu nháy trong đầu vào của người dùng có được không?
Không. Escape thủ công rất mong manh: bạn sẽ bỏ sót một trường hợp biên (dấu nháy Unicode, mẹo mã hóa, ký hiệu chú thích) và để lọt lỗ hổng. Driver cung cấp tham số ? và :name chính là để bạn khỏi phải nghĩ về chuyện escape. Hãy dùng chúng mọi lúc, kể cả với những giá trị bạn 'biết chắc' là an toàn.
Còn tên bảng hoặc tên cột đến từ đầu vào của người dùng thì sao?
Tham số được bind chỉ dùng được cho giá trị, không dùng cho định danh. Nếu tên bảng hoặc tên cột phải động, hãy kiểm tra nó với một allowlist các tên đã biết trước khi ghép vào SQL. Đừng bao giờ đưa một định danh thô do người dùng cung cấp qua định dạng chuỗi.