Bỏ qua đến nội dung chính
SQL injectionsecuritySQLbackendOWASP

SQL Injection: Cách Tránh Thật Sự (Không Chỉ Escape)

SQL injection cách tránh thật sự: parameterized query, ORM, escape không đủ, second-order injection. Code Node.js + Postgres minh hoạ rõ ràng.

Xuất bản 7 phút đọc

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.

Zalo