SQL injection là vulnerability cổ điển nhưng vẫn xuất hiện trong audit production. Bài này code Node.js + PostgreSQL, đi qua các pattern attack thực tế và defense duy nhất đáng tin.
Vấn đề kinh điển
// ❌ String concat — SQL injection cổ điển
const email = req.body.email
db.query(`SELECT * FROM users WHERE email = '${email}'`)
// User nhập: ' OR '1'='1
// Query thực thi: SELECT * FROM users WHERE email = '' OR '1'='1' — trả mọi user
// User nhập: '; DROP TABLE users; --
// Query: SELECT * FROM users WHERE email = ''; DROP TABLE users; --'
Parameterized query — defense duy nhất đáng tin
// ✓ pg lib — $1 placeholder
const { rows } = await pool.query(
'SELECT * FROM users WHERE email = $1',
[email]
)
// ✓ MySQL — ? placeholder
const [rows] = await mysql.query(
'SELECT * FROM users WHERE email = ?',
[email]
)
// ✓ Prisma — ORM
const user = await prisma.user.findFirst({ where: { email } })
// ✓ Drizzle
const user = await db.select().from(users).where(eq(users.email, email))
Database engine biết phần nào là query, phần nào là data — không bao giờ execute data. Escape charset không thể vượt qua được.
Raw query trong ORM vẫn nguy hiểm
// ❌ Sequelize raw — SQL injection
sequelize.query(`SELECT * FROM users WHERE name = '${name}'`)
// ✓ Sequelize raw có replacements
sequelize.query('SELECT * FROM users WHERE name = :name',
{ replacements: { name }, type: QueryTypes.SELECT })
// ❌ Knex.raw concat
knex.raw(`UPDATE users SET name = '${name}' WHERE id = ${id}`)
// ✓ Knex.raw bind
knex.raw('UPDATE users SET name = ? WHERE id = ?', [name, id])
Dynamic WHERE clause
Khi cần build dynamic filter, đừng concat. Dùng query builder:
// ❌ Sai
let where = '1=1'
if (req.query.status) where += ` AND status = '${req.query.status}'`
db.query(`SELECT * FROM orders WHERE ${where}`)
// ✓ Đúng — array params
const conds = []
const params = []
if (req.query.status) {
params.push(req.query.status)
conds.push(`status = $${params.length}`)
}
const where = conds.length ? 'WHERE ' + conds.join(' AND ') : ''
db.query(`SELECT * FROM orders ${where}`, params)
Second-order injection
Inject payload lưu DB → sau đó dùng dynamic SQL đọc ra:
// User đăng ký với name = "admin' --"
await db.query('INSERT INTO users (name) VALUES ($1)', [name]) // OK, parameterized
// Sau đó admin generate report:
const userName = (await db.query('SELECT name FROM users WHERE id = $1', [id])).rows[0].name
// userName = "admin' --"
await db.query(`SELECT * FROM logs WHERE user = '${userName}'`)
// → SQL injection ở đây!
Quy tắc: parameterize ở MỌI query, kể cả data đến từ DB.
Least privilege DB user
-- Tạo user app với quyền minimum
CREATE USER alodev_app WITH PASSWORD '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON users, orders, products TO alodev_app;
-- KHÔNG GRANT DROP, ALTER, CREATE
-- KHÔNG GRANT trên bảng admin/audit nếu app không cần
Dù SQL injection xảy ra, attacker không drop table được.
Test bằng sqlmap
sqlmap -u "https://alodev.vn/api/users?id=1" \
--headers="Authorization: Bearer $TOKEN" \
--batch --level=3 --risk=2
# Test POST
sqlmap -u "https://alodev.vn/api/login" \
--data='{"email":"a@b.c","password":"x"}' \
--header='Content-Type: application/json'
Kết luận
SQL injection 100% prevent được — chỉ cần kỷ luật parameterize MỌI query. Escape không đủ. ORM cơ bản an toàn nhưng raw query phải cẩn thận. Combine với least privilege DB user + sqlmap test trong CI. Đọc OWASP Top 10 để cover các vulnerability khác.