Skip to content

Bảo mật database ​

Phần lớn sự cố lộ dữ liệu không đến từ lỗi của chính database, mà đến từ cách ứng dụng dùng nó: câu SQL nối chuỗi, user của app có toàn quyền, mật khẩu nằm trong code, bản sao production nằm trên máy dev. Trang này đi qua các lỗi đó và cách chặn. Phần bảo mật chung như xác thực, phân quyền người dùng và OWASP ở Kiến trúc › Bảo mật.

SQL injection ​

SQL injection xảy ra khi input của người dùng được ghép thẳng vào câu SQL:

js
// Sai: input trở thành một phần của câu lệnh
db.query(`SELECT id, role FROM users WHERE email = '${email}' AND password_hash = '${hash}'`)

Nếu email là ' OR '1'='1, dấu nháy trong input đóng chuỗi sớm, và phần còn lại thành điều kiện OR '1'='1' luôn đúng. Query trả về mọi user, và code đăng nhập thường lấy dòng đầu, thường là admin. Trên lab, chiêu này chạy được trên cả MySQL lẫn PostgreSQL. Gõ thử các input khác:

Thử nhập
Code của app
db.query(`SELECT … WHERE email = '${email}' AND password_hash = '${hash}'`)

SELECT id, role FROM users WHERE email = '' OR '1'='1' AND password_hash = 'bat-ky'
input nằm trong chuỗi, chỉ là dữ liệuinput thoát ra ngoài, thành code SQL

Dấu nháy trong input đóng chuỗi sớm, nên OR '1'='1' thành một phần của điều kiện WHERE và luôn đúng. Query trả về mọi user, và code đăng nhập thường lấy dòng đầu tiên, tức thường là admin.

Cách chặn là placeholder (tham số hoá), không phải tự escape hay lọc ký tự:

js
// mysql2
await pool.query('SELECT id, role FROM users WHERE email = ? AND password_hash = ?', [email, hash])
await pool.execute('SELECT id, role FROM users WHERE email = ? AND password_hash = ?', [email, hash])
// pg
await pool.query('SELECT id, role FROM users WHERE email = $1 AND password_hash = $2', [email, hash])

Với placeholder, database chỉ coi input là một giá trị, dù nó chứa dấu nháy hay dấu chấm phẩy. execute() của mysql2 và pg gửi câu lệnh và tham số tách riêng (prepared statement). query() của mysql2 thì escape tham số rồi ghép ở phía client, nhưng việc escape do thư viện làm đúng cho từng kiểu dữ liệu, nên vẫn an toàn. Cả hai đều cho 0 dòng với input trên.

Câu lệnh chồng như x'; DROP TABLE users; -- nguy hiểm hơn nữa. mysql2 mặc định không cho chạy nhiều câu lệnh trong một lần gọi (multipleStatements: false), nên lệnh trên chỉ báo lỗi cú pháp. Nhưng trên lab, bật tuỳ chọn đó lên thì bảng users bị xoá thật. Với pg, query không có tham số chạy được nhiều câu lệnh, còn query có tham số thì bị chặn. Đừng bật multipleStatements trên connection của app.

ORM không tự động an toàn. Các hàm cho phép viết SQL thô (sequelize.query với chuỗi tự ghép, $queryRawUnsafe của Prisma, knex.raw không có tham số) cũng bị injection như trên nếu ghép chuỗi.

Những chỗ không dùng được placeholder ​

Placeholder chỉ thay được giá trị, không thay được tên bảng, tên cột hay từ khoá SQL:

  • ORDER BY theo cột người dùng chọn. ORDER BY ? với giá trị 'email DESC' không báo lỗi mà biến thành sắp theo một hằng chuỗi, tức là không sắp gì. Cách đúng là danh sách cho phép: map giá trị từ request sang tên cột cố định trong code, giá trị nào không có trong danh sách thì dùng mặc định.

    js
    const SORTABLE = { created: 'created_at', price: 'price', name: 'name' }
    const col = SORTABLE[req.query.sort] ?? 'created_at'
    const dir = req.query.dir === 'asc' ? 'ASC' : 'DESC'
    await pool.query(`SELECT … FROM products ORDER BY ${col} ${dir} LIMIT ?`, [20])
  • Tên bảng, tên cột động (báo cáo cho người dùng chọn cột): cũng dùng danh sách cho phép.

  • LIKE: tham số hoá vẫn chưa đủ, vì % và _ trong input là ký tự đại diện. Tìm "email chứa %" mà không escape thì trả về mọi dòng; escape %, _ và \ trước khi đưa vào thì trả về đúng 0 dòng. Với bảng lớn, một input % còn là cách làm database quét cả bảng.

  • Danh sách IN: mysql2 tự mở rộng mảng thành IN (?, ?, ?); với pg thì dùng = ANY($1) và truyền một mảng.

Phân quyền tối thiểu ​

User mà app dùng để kết nối chỉ nên có đúng quyền app cần. Nếu app bị chiếm hay có lỗi injection, kẻ tấn công cũng chỉ làm được những gì user đó làm được.

UserQuyềnDùng cho
appSELECT, INSERT, UPDATE, DELETE trên database của appỨng dụng
migratorThêm CREATE, ALTER, DROP, INDEXBước migration trong pipeline (Migration)
reportSELECT, có khi chỉ trên một số bảng hoặc view, kết nối vào replicaBáo cáo, BI
Người vận hànhTài khoản cá nhân có ghi log, không dùng chungTruy cập khi có sự cố

Trên lab, user app chỉ có quyền đọc ghi dữ liệu thì bị từ chối khi chạy DROP TABLE (DROP command denied), và không đọc được bảng của database hệ thống. Role chỉ đọc trên PostgreSQL bị từ chối cả UPDATE lẫn DROP TABLE. Muốn ẩn một số cột nhạy cảm khỏi user báo cáo, cho nó quyền trên một view chỉ chứa các cột được phép, thay vì trên bảng gốc.

Mã hoá ​

  • Khi truyền (TLS): connection giữa app và database phải được mã hoá, nhất là khi đi qua mạng mà mình không kiểm soát hết. MySQL 8 và PostgreSQL đều hỗ trợ TLS, nhưng client không phải lúc nào cũng tự bật. Trên lab, connection mặc định của mysql2 không dùng TLS (Ssl_cipher rỗng). Bật bằng tuỳ chọn ssl của driver, và phía server đặt require_secure_transport = ON (MySQL) hay dòng hostssl trong pg_hba.conf (PostgreSQL) để từ chối connection không mã hoá.
  • Khi lưu: mã hoá đĩa hoặc mã hoá tablespace (dịch vụ cloud thường bật sẵn). Cách này chặn được người lấy cắp đĩa hay file backup, nhưng không chặn được người có quyền query, vì database tự giải mã khi đọc.
  • Mã hoá từng cột ở tầng app: với dữ liệu rất nhạy cảm như số CMND/CCCD, số tài khoản, app mã hoá trước khi ghi và giữ khoá ở ngoài database (KMS, Vault). Ai đọc được bảng hay bản backup cũng chỉ thấy dữ liệu đã mã hoá. Cái giá là không tìm hay sắp xếp được theo cột đó, trừ khi lưu thêm một giá trị băm để tra cứu chính xác.
  • Mật khẩu người dùng thì không mã hoá mà băm bằng thuật toán chậm có salt như bcrypt hay Argon2, để không ai, kể cả người có toàn quyền database, lấy lại được mật khẩu gốc.

Dữ liệu ở môi trường dev và staging ​

Chép nguyên database production sang staging hay máy dev là một cách lộ dữ liệu rất hay gặp, vì các môi trường đó thường được bảo vệ lỏng hơn nhiều. Nếu cần dữ liệu giống thật để test:

  • Che dữ liệu (masking) trước khi chép: thay email, số điện thoại, tên bằng giá trị giả nhưng giữ đúng định dạng và phân bố, xoá hay băm các cột nhạy cảm.
  • Tạo dữ liệu giả bằng script, đủ lớn để test hiệu năng (Test với database).
  • Bản backup production chỉ được khôi phục vào môi trường có cùng mức bảo vệ.

Mật khẩu của database ​

  • Không để trong code hay trong repo. Đọc từ biến môi trường được nạp từ một kho bí mật (secret manager của cloud, Vault, Kubernetes secret).
  • Xoay vòng định kỳ mà không làm gián đoạn: tạo user thứ hai (hoặc mật khẩu thứ hai), cập nhật app sang thông tin mới, rồi mới vô hiệu hoá thông tin cũ.
  • Dịch vụ cloud còn cho xác thực bằng danh tính của máy chủ (IAM authentication), không cần mật khẩu cố định.

Ghi log truy cập dữ liệu nhạy cảm ​

Với dữ liệu cá nhân hay tài chính, cần trả lời được câu hỏi "ai đã xem hay sửa dữ liệu của khách hàng X, lúc nào". Ghi log ở tầng app (user nào, API nào, bản ghi nào) thường hữu ích hơn log của database, vì database chỉ thấy user app chung. Thao tác của người vận hành trực tiếp trên database thì ghi bằng audit log của database (MySQL Enterprise Audit hay plugin audit, pgaudit của PostgreSQL). Nghị định 13/2023 về bảo vệ dữ liệu cá nhân cũng yêu cầu các biện pháp kiểu này (Soft delete & xoá theo yêu cầu).

Ghi nhớ

  1. Luôn dùng placeholder cho giá trị. Tên cột, ORDER BY và từ khoá thì dùng danh sách cho phép; LIKE còn phải escape % và _.
  2. Không bật nhiều câu lệnh trong một lần gọi (multipleStatements); ORM cũng bị injection khi ghép chuỗi vào SQL thô.
  3. Mỗi mục đích một user với quyền tối thiểu: app chỉ đọc ghi dữ liệu, migration dùng user riêng, báo cáo chỉ đọc.
  4. Bật TLS cho connection (driver không phải lúc nào cũng tự bật), mã hoá cột rất nhạy cảm ở tầng app, băm mật khẩu bằng bcrypt hay Argon2.
  5. Không chép dữ liệu production chưa che sang dev hay staging; mật khẩu database nằm trong kho bí mật và được xoay vòng.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-23):

NguồnDùng cho phần
OWASP, SQL Injection Prevention Cheat SheetPrepared statement là cách phòng chính; danh sách cho phép cho tên bảng, tên cột; không khuyến khích tự escape; quyền tối thiểu

Kiểm bằng lab: mọi kết quả về injection, câu lệnh chồng, ORDER BY ?, LIKE với %, quyền của user app và role chỉ đọc, connection mysql2 mặc định không có TLS. Từ kiến thức chung, chưa đối chiếu lần này: query() của mysql2 escape ở phía client còn execute() dùng prepared statement trên server (tài liệu mysql2); require_secure_transport, hostssl; pgaudit; nội dung Nghị định 13/2023.

Số đo chi tiết ​

Chạy bằng script lab/bao-mat/injection.mjs ngày 2026-10-23 trên MySQL 8.0 (mysql2) và PostgreSQL 17 (pg) trong Docker.

Kết quả chạy thử
Phép thửMySQL 8.0PostgreSQL 17
Nối chuỗi, email ' OR '1'='13 dòng, dòng đầu là admin3 dòng, dòng đầu là admin
Placeholder0 dòng (query() và execute())0 dòng
x'; DROP TABLE users; -- Lỗi cú pháp (mặc định); bật multipleStatements thì bảng bị xoáQuery không tham số: bảng bị xoá; có tham số: cannot insert multiple commands into a prepared statement
ORDER BY ? với 'email DESC'Thứ tự 1,2,3 (không sắp); danh sách cho phép cho 3,2,1
LIKE với input %3 dòng; escape trước thì 0 dòng
User chỉ có quyền đọc ghi dữ liệuDROP command denied; SELECT command denied với mysql.userRole chỉ đọc: permission denied khi UPDATE, must be owner khi DROP TABLE
TLS của connection mặc địnhKhông (Ssl_cipher rỗng)

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- bao-mat sau npm run lab:up, vài giây. Xem Lab.

lab/bao-mat/injection.mjs
js
// Bảo mật database: SQL injection và phân quyền tối thiểu, trên MySQL 8.0 (mysql2) và PostgreSQL 17 (pg).
//   1. Đăng nhập bằng câu SQL nối chuỗi so với placeholder, với input ' OR '1'='1.
//   2. Câu lệnh chồng "; DROP TABLE …": mysql2 mặc định multipleStatements = false; pg với query không có tham số.
//   3. ORDER BY theo cột người dùng chọn: placeholder không dùng được cho tên cột.
//   4. LIKE với input "%": trả về mọi dòng nếu không escape.
//   5. User của app chỉ có quyền đọc ghi dữ liệu: thử DROP TABLE.
import mysql from 'mysql2/promise'
import pg from 'pg'
import { MYSQL, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'

const show = async (label, fn) => {
  try { const r = await fn(); console.log(`${label}: ${typeof r === 'string' ? r : JSON.stringify(r)}`) }
  catch (e) { console.log(`${label}: LỖI ${e.message.split('\n')[0].slice(0, 110)}`) }
}

ensureDb('mysql', 'sec')
ensureDb('pg', 'sec')
const root = await mysql.createConnection({ ...MYSQL, database: 'sec' })
await root.query('DROP TABLE IF EXISTS users')
await root.query('CREATE TABLE users (id INT PRIMARY KEY, email VARCHAR(100) NOT NULL, password_hash VARCHAR(100) NOT NULL, role VARCHAR(10) NOT NULL)')
await root.query("INSERT INTO users VALUES (1, 'admin@shop.vn', 'h1', 'admin'), (2, 'an@shop.vn', 'h2', 'user'), (3, 'binh@shop.vn', 'h3', 'user')")

const evil = "' OR '1'='1"
console.log('\n=== MySQL 8.0 ===')
await show('1a. Nối chuỗi, email = ' + evil, async () => {
  const [r] = await root.query(`SELECT id, role FROM users WHERE email = '${evil}' AND password_hash = '${evil}'`)
  return `${r.length} dòng, dòng đầu là ${r[0]?.role}` })
await show('1b. Placeholder (query, mysql2 tự escape)', async () => (await root.query('SELECT id, role FROM users WHERE email = ? AND password_hash = ?', [evil, evil]))[0].length + ' dòng')
await show('1c. Placeholder (execute, prepared statement trên server)', async () => (await root.execute('SELECT id, role FROM users WHERE email = ? AND password_hash = ?', [evil, evil]))[0].length + ' dòng')
const stacked = "x'; DROP TABLE users; -- "
await show('2a. Câu lệnh chồng, multipleStatements mặc định (false)', async () => (await root.query(`SELECT id FROM users WHERE email = '${stacked}'`), 'chạy được'))
const multi = await mysql.createConnection({ ...MYSQL, database: 'sec', multipleStatements: true })
await show('2b. Cùng câu đó khi bật multipleStatements', async () => { await multi.query(`SELECT id FROM users WHERE email = '${stacked}'`); const [t] = await root.query("SHOW TABLES LIKE 'users'"); return t.length ? 'bảng users còn' : 'bảng users đã bị xoá' })
await multi.end()
await root.query('CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, email VARCHAR(100) NOT NULL, password_hash VARCHAR(100) NOT NULL, role VARCHAR(10) NOT NULL)')
await root.query("INSERT IGNORE INTO users VALUES (1, 'admin@shop.vn', 'h1', 'admin'), (2, 'an@shop.vn', 'h2', 'user'), (3, 'binh@shop.vn', 'h3', 'user')")
await show("3a. ORDER BY ? với 'email DESC' (thành hằng chuỗi, không sắp gì)", async () => (await root.query('SELECT id FROM users ORDER BY ?', ['email DESC']))[0].map((r) => r.id).join(','))
const SORTABLE = { email: 'email', id: 'id' }
await show('3b. ORDER BY theo danh sách cho phép, sort=email&dir=desc', async () => {
  const col = SORTABLE['email'] ?? 'id', dir = 'desc' === 'desc' ? 'DESC' : 'ASC'
  return (await root.query(`SELECT id FROM users ORDER BY ${col} ${dir}`))[0].map((r) => r.id).join(',') })
await show("4a. LIKE ? với input '%' (tìm email chứa…)", async () => (await root.query("SELECT COUNT(*) AS n FROM users WHERE email LIKE CONCAT('%', ?, '%')", ['%']))[0][0].n + ' dòng')
const escLike = (s) => s.replace(/[\\%_]/g, (c) => '\\' + c)
await show("4b. Escape % và _ trước khi đưa vào LIKE", async () => (await root.query("SELECT COUNT(*) AS n FROM users WHERE email LIKE CONCAT('%', ?, '%')", [escLike('%')]))[0][0].n + ' dòng')

// 5. Phân quyền tối thiểu
await root.query("DROP USER IF EXISTS 'app'@'%'")
await root.query("CREATE USER 'app'@'%' IDENTIFIED BY 'app-pw'")
await root.query("GRANT SELECT, INSERT, UPDATE, DELETE ON sec.* TO 'app'@'%'")
const app = await mysql.createConnection({ ...MYSQL, user: 'app', password: 'app-pw', database: 'sec' })
await show('5a. User app: SELECT', async () => (await app.query('SELECT COUNT(*) AS n FROM users'))[0][0].n + ' dòng')
await show('5b. User app: DROP TABLE users', async () => (await app.query('DROP TABLE users'), 'đã xoá'))
await show('5c. User app: đọc bảng của database khác (mysql.user)', async () => (await app.query('SELECT COUNT(*) FROM mysql.user'), 'đọc được'))
const [[ssl]] = await app.query("SHOW SESSION STATUS LIKE 'Ssl_cipher'")
console.log(`TLS của connection này: ${ssl.Value ? 'có, ' + ssl.Value : 'không'}`)
await app.end()
await root.end()

// ---------- PostgreSQL ----------
console.log('\n=== PostgreSQL 17 ===')
const p = new pg.Client({ ...PG, database: 'sec' })
await p.connect()
await p.query('DROP TABLE IF EXISTS users')
await p.query('CREATE TABLE users (id INT PRIMARY KEY, email VARCHAR(100) NOT NULL, password_hash VARCHAR(100) NOT NULL, role VARCHAR(10) NOT NULL)')
await p.query("INSERT INTO users VALUES (1, 'admin@shop.vn', 'h1', 'admin'), (2, 'an@shop.vn', 'h2', 'user'), (3, 'binh@shop.vn', 'h3', 'user')")
await show('1a. Nối chuỗi, email = ' + evil, async () => { const r = await p.query(`SELECT id, role FROM users WHERE email = '${evil}' AND password_hash = '${evil}'`); return `${r.rowCount} dòng, dòng đầu là ${r.rows[0]?.role}` })
await show('1b. Tham số $1, $2', async () => (await p.query('SELECT id FROM users WHERE email = $1 AND password_hash = $2', [evil, evil])).rowCount + ' dòng')
await show('2a. Câu lệnh chồng khi query không có tham số (simple query protocol)', async () => { await p.query(`SELECT id FROM users WHERE email = '${stacked}'`); const r = await p.query("SELECT to_regclass('users') AS t"); return r.rows[0].t ? 'bảng users còn' : 'bảng users đã bị xoá' })
await p.query('CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, email VARCHAR(100) NOT NULL, password_hash VARCHAR(100) NOT NULL, role VARCHAR(10) NOT NULL)')
await p.query("INSERT INTO users VALUES (1, 'admin@shop.vn', 'h1', 'admin'), (2, 'an@shop.vn', 'h2', 'user'), (3, 'binh@shop.vn', 'h3', 'user') ON CONFLICT DO NOTHING")
await show('2b. Câu lệnh chồng khi có tham số (extended protocol)', async () => (await p.query(`SELECT id FROM users WHERE email = '${stacked}' AND id = $1`, [1]), 'chạy được'))
await p.query('DROP ROLE IF EXISTS app_ro')
await p.query("CREATE ROLE app_ro LOGIN PASSWORD 'ro-pw'")
await p.query('GRANT SELECT ON users TO app_ro')
const ro = new pg.Client({ ...PG, user: 'app_ro', password: 'ro-pw', database: 'sec' })
await ro.connect()
await show('5a. Role chỉ đọc: SELECT', async () => (await ro.query('SELECT COUNT(*) AS n FROM users')).rows[0].n + ' dòng')
await show('5b. Role chỉ đọc: UPDATE', async () => (await ro.query("UPDATE users SET role = 'admin'"), 'đã sửa'))
await show('5c. Role chỉ đọc: DROP TABLE', async () => (await ro.query('DROP TABLE users'), 'đã xoá'))
await ro.end()
await p.query('REVOKE ALL ON users FROM app_ro')
await p.query('DROP ROLE app_ro')
await p.end()