Skip to content

Bảo mật Node.js ​

Phần lớn lỗ hổng của một backend Node giống mọi backend khác: SQL injection, phân quyền sai, lộ dữ liệu (Bảo mật database, Kiến trúc › Bảo mật). Trang này tập trung vào những lỗ hổng riêng của JavaScript và hệ sinh thái npm: prototype pollution, chuỗi cung ứng package, ReDoS, bí mật trong biến môi trường, và permission model của Node.

Prototype pollution ​

Mọi object thường trong JavaScript kế thừa từ Object.prototype (Prototype). Nếu kẻ tấn công ghi được một thuộc tính lên Object.prototype, thì mọi object trong process đều "có" thuộc tính đó, kể cả object của những request khác, cho tới khi process khởi động lại.

Đường vào phổ biến nhất là một hàm gộp sâu (deep merge, set theo đường dẫn, clone) chạy trên dữ liệu từ client:

js
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] ??= {}
      merge(target[key], source[key])
    } else target[key] = source[key]
  }
  return target
}
const settings = merge({}, req.body)   // body: {"__proto__": {"isAdmin": true}}

Vì sao nó làm bẩn được:

  1. JSON.parse giữ "__proto__" như một key bình thường. Trên lab, Object.keys của input trả về name,__proto__.
  2. Trong merge, target["__proto__"] không phải là một thuộc tính bình thường: đó là getter kế thừa, trả về chính Object.prototype. ??= thấy đã có giá trị nên không tạo object mới.
  3. Lần gọi đệ quy ghi isAdmin = true thẳng vào Object.prototype.

Trên lab, sau một lần merge như vậy, một object mới tạo ở chỗ khác có isAdmin === true. Payload {"constructor": {"prototype": {...}}} đi đường khác (target.constructor là hàm Object, .prototype là Object.prototype) nhưng tới cùng một chỗ. Chạy từng bước để thấy target trỏ tới đâu:

Payload
Hàm merge

Body gửi lên: {"name":"An","__proto__":{"isAdmin":true}}

Bước 1 / 5
settings
object mới, của một request
Object
hàm Object, có .prototype
Object.prototype
mọi object thường kế thừa từ đây

JSON.parse giữ mọi key như thuộc tính riêng bình thường, kể cả "__proto__" và "constructor". Bắt đầu merge(settings = {}, input).

Từ prototype bị bẩn tới lỗ hổng ​

Prototype bị bẩn chỉ nguy hiểm khi có code đọc một thuộc tính không tồn tại và tin vào giá trị kế thừa. Những chỗ như vậy gọi là gadget:

  • Kiểm tra quyền: if (user.isAdmin) với user không có isAdmin riêng.
  • Đọc tuỳ chọn có giá trị mặc định: const shell = options.shell ?? false. Nếu Object.prototype.shell bị làm bẩn, child_process.spawn có thể chạy lệnh qua shell.
  • Thư viện đọc tuỳ chọn từ object người dùng truyền vào. Chính lab này gặp một trường hợp như vậy: npm audit báo piscina 5.3.1 (thư viện worker pool dùng ở trang Chặn event loop) có lỗ hổng mức critical, vì nó đọc filename, execArgv, env từ object tuỳ chọn mà không kiểm tra là thuộc tính riêng. Nếu một chỗ khác trong process đã bị làm bẩn prototype, kẻ tấn công điều khiển được code mà worker chạy. Bản 5.3.2 đã sửa.

Cách chặn ​

CáchChặn đượcGhi chú
Validate input bằng schema, chặn trường lạMọi payload có key lạCách chính: body chỉ có những trường đã khai báo (Validation)
Không tự viết deep merge cho dữ liệu từ client; nếu phải viết thì bỏ qua __proto__, constructor, prototype và chỉ duyệt Object.keysCả hai đườngTrên lab, bản an toàn để Object.prototype sạch với cả hai payload
Map hoặc Object.create(null) cho dữ liệu có key do người dùng đặtĐọc nhầm thuộc tính kế thừaObject không có prototype nên không kế thừa gì; trên lab, cả hai trả undefined khi prototype đã bẩn
Object.hasOwn(obj, key) khi kiểm tra một tuỳ chọn có được đặt khôngGadgetThay cho obj.key hay key in obj
Object.freeze(Object.prototype) lúc khởi độngMọi lần ghi vào prototypeTrên lab, phép gán ném TypeError trong strict mode. Có thể làm hỏng thư viện cũ sửa prototype
node --disable-proto=delete (hoặc =throw)Chỉ đường __proto__Trên lab, đường constructor.prototype vẫn làm bẩn được: chỉ là một lớp phụ

Chuỗi cung ứng npm ​

Một ứng dụng Node thường kéo theo hàng trăm package, phần lớn là phụ thuộc gián tiếp không ai đọc. Thư mục lab của site này có 9 dependency trực tiếp nhưng 155 package. Mỗi package là code chạy với toàn quyền của process. Các kiểu tấn công đã xảy ra thật:

  • Tài khoản maintainer bị chiếm, phát hành bản mới có mã độc của một package phổ biến.
  • Typosquatting: package tên gần giống package nổi tiếng, chờ người gõ nhầm.
  • Script cài đặt (preinstall, postinstall) chạy ngay lúc npm install, trước khi code được dùng. Trên lab, ignore-scripts mặc định là false, tức npm chạy mọi script cài đặt.
  • Lỗ hổng không cố ý trong package hợp lệ, như hai lỗ hổng npm audit tìm thấy trong chính lab.

Biện pháp:

Biện phápVì sao
Commit lockfile, dùng npm ci trong CI và lúc build imagenpm ci cài đúng theo package-lock.json, báo lỗi nếu lockfile không khớp package.json, không bao giờ ghi lại lockfile: build lần nào cũng ra cùng một cây package
npm audit (hoặc Dependabot, Snyk) trong CIPhát hiện package có lỗ hổng đã công bố. Đọc kết quả thay vì chạy npm audit fix --force mù quáng: --force có thể nâng major version và làm hỏng code
--ignore-scripts khi cài, bật lại cho từng package thật sự cần (native addon)Chặn mã độc chạy lúc cài
Cân nhắc trước khi thêm dependencyMỗi package là một bên thứ ba có quyền chạy code; một hàm 10 dòng không đáng một dependency
Cập nhật đều đặn, có testCập nhật một lần sau hai năm thì quá nhiều thay đổi để kiểm tra

ReDoS ​

Một regex viết kém có thể mất thời gian tăng theo cấp số mũ với độ dài input khi không khớp, vì bộ máy regex của V8 thử lại mọi cách chia chuỗi (backtracking). Một input dài vài chục ký tự đủ chặn event loop hàng giây, và vì Node chỉ có một main thread, mọi request khác đứng chờ (Regex và ReDoS).

Dấu hiệu nguy hiểm là lượng từ lồng nhau hoặc các nhánh chồng lấn nhau, như (a+)+$, (\w+\s?)*$, (a|a)*. Cách chặn: giới hạn độ dài input trước khi chạy regex, tránh tự viết regex phức tạp cho email hay URL (dùng thư viện validate đã kiểm tra), và kiểm tra regex bằng công cụ như safe-regex hay linter. Trang Chặn event loop có mô phỏng để kéo độ dài input và thấy thời gian tăng.

Bí mật trong biến môi trường ​

Biến môi trường là cách phổ biến để truyền mật khẩu database và API key vào container. Nó tốt hơn ghi thẳng vào code, nhưng có những chỗ dễ lộ:

  • Process con kế thừa toàn bộ biến môi trường. Trên lab, một lệnh sh bất kỳ được spawn từ process Node đọc được DB_PASSWORD của process cha. Khi spawn công cụ bên ngoài (ImageMagick, ffmpeg, một script), truyền env tối thiểu: trên lab, chỉ truyền PATH thì process con không thấy mật khẩu.
  • Log cả object config hay object request. Trên lab, một dòng log "khởi động" in nguyên config làm mật khẩu nằm trong log, và log thường được gửi sang hệ thống khác, giữ lâu, nhiều người đọc được. Dùng tính năng redact của logger (pino có redact theo đường dẫn), và không log nguyên req.headers (chứa Authorization, Cookie).
  • Thông báo lỗi trả về client có thể chứa connection string hay câu SQL (Error class và mã lỗi).
  • File .env bị commit, hoặc bị đóng vào Docker image. Thêm .env vào .gitignore và .dockerignore.

Ở production, nên lấy bí mật từ một secret manager (AWS Secrets Manager, Vault, Kubernetes Secret gắn từ hệ thống quản lý), có phân quyền, có lịch sử truy cập, và xoay vòng được mà không cần sửa code.

Permission model của Node ​

Từ Node 20 (ổn định từ 22.13 và 23.5), node --permission chặn mọi truy cập file, process con, worker thread, addon native, trừ những gì được cho phép rõ ràng bằng --allow-fs-read, --allow-fs-write, --allow-child-process… Trên lab, chỉ cho đọc thư mục của ứng dụng thì đọc ~/.ssh, ghi file và spawn process con đều bị chặn với ERR_ACCESS_DENIED.

Nhưng tài liệu Node nói rõ: tính năng này không bảo vệ khỏi mã độc. Nó là lớp hạn chế để giảm tác hại khi code đáng tin bị lợi dụng, không phải sandbox để chạy code không tin được. Muốn chạy code của người dùng thì cần cô lập ở mức process hay container, không phải vm hay permission model (Chặn event loop). Lab cũng gặp một điểm dễ vấp: trên macOS, os.tmpdir() là symlink (/var trỏ tới /private/var), và --allow-fs-read so theo đường dẫn thật, nên phải truyền đường dẫn đã qua realpath.

Ghi nhớ

  1. Prototype pollution: deep merge trên input chứa __proto__ hoặc constructor.prototype ghi vào Object.prototype, ảnh hưởng mọi object trong process; nguy hiểm khi có gadget đọc thuộc tính kế thừa.
  2. Chặn bằng validate schema, không tự viết deep merge, Map/Object.create(null), Object.hasOwn; --disable-proto chỉ chặn đường __proto__.
  3. Chuỗi cung ứng: commit lockfile, npm ci, npm audit trong CI, cân nhắc --ignore-scripts, ít dependency hơn.
  4. ReDoS: giới hạn độ dài input trước khi chạy regex, tránh lượng từ lồng nhau.
  5. Bí mật: process con kế thừa env, log hay lỗi dễ lộ config; redact log, dùng secret manager. Permission model không phải sandbox cho mã độc.

Nguồn tham khảo ​

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

NguồnDùng cho phần
Node.js docs, PermissionsỔn định từ v23.5.0 và v22.13.0; không bảo vệ khỏi mã độc
Node.js docs, --disable-protodelete xoá hẳn Object.prototype.__proto__, throw ném ERR_PROTO_ACCESS
npm docs, npm ciBáo lỗi khi lockfile không khớp package.json; không bao giờ ghi package.json hay lockfile
GitHub Advisory GHSA-67c8-pqhq-4rmx, GHSA-x9g3-xrwr-cwfgLỗ hổng prototype-pollution gadget của piscina, sửa ở 5.3.2 (theo kết quả npm audit)

Kiểm bằng lab: prototype pollution qua hai đường và các cách chặn, permission model, process con kế thừa env, log lộ config, ignore-scripts mặc định, npm audit. Từ kiến thức chung, chưa đối chiếu lần này: gadget options.shell của child_process; tính năng redact của pino; safe-regex; các vụ tấn công chuỗi cung ứng đã xảy ra.

Số đo chi tiết ​

Kết quả chạy lab/node-bao-mat/security.mjs (Node 24.14, npm 11, macOS)
Phép thửKết quả
Object.keys(JSON.parse('{"name":"An","__proto__":{…}}'))name,__proto__
merge({}, input) rồi đọc .isAdmin trên object mớitrue
safeMerge (bỏ qua 3 key, chỉ duyệt Object.keys)undefined
Payload {"constructor":{"prototype":{…}}} với merge({}).polluted === 'yes'
Prototype đã bẩn: opts.role, Object.create(null).role, Map.get, Object.hasOwnadmin, undefined, undefined, false
Object.freeze(Object.prototype) rồi gánTypeError, prototype vẫn sạch
--disable-proto=delete, qua __proto__ / qua constructorsạch / vẫn bẩn
--permission --allow-fs-read=<thư mục app>Đọc trong thư mục: được; đọc ~/.ssh, ghi file, spawn: ERR_ACCESS_DENIED
Process con sh đọc DB_PASSWORD của cha / chỉ truyền PATHđọc được / không thấy
npm config get ignore-scriptsfalse
npm audit thư mục lab (155 package), trước và sau khi nâng piscina lên 5.3.21 critical + 1 high (piscina), 1 high (zod 3) / còn 1 high (zod 3, sửa ở zod 4.6.6)

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

Chạy bằng npm run lab -- node-bao-mat, chỉ cần Node.js, vài giây; phần npm audit cần mạng. Các phép thử làm bẩn prototype chạy trong process con. Xem Lab.

lab/node-bao-mat/security.mjs
js
// Trang nodejs/bao-mat. Chỉ cần Node.js, vài giây (npm audit cần mạng). Các phép thử làm bẩn prototype chạy
// trong process con để không ảnh hưởng process chính.
//   1. Prototype pollution qua hàm merge tự viết, và các cách chặn
//   2. Permission model (--permission)
//   3. Bí mật trong biến môi trường: process con kế thừa, log lộ config
//   4. Chuỗi cung ứng npm: ignore-scripts, npm audit
import { spawnSync } from 'node:child_process'
import { writeFileSync, mkdtempSync, rmSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

const log = (label, v) => console.log(`${label.padEnd(62)} ${v}`)
const child = (code, args = []) => spawnSync(process.execPath, [...args, '--input-type=module', '-e', code], { encoding: 'utf8' })

console.log('\n=== 1. Prototype pollution qua hàm merge tự viết ===')
const MERGE = `
function merge(target, source) {               // gộp sâu kiểu hay gặp trong code tự viết
  for (const key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] ??= {}
      merge(target[key], source[key])
    } else target[key] = source[key]
  }
  return target
}
function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
    if (typeof source[key] === 'object' && source[key] !== null) {
      if (!Object.hasOwn(target, key)) target[key] = {}
      safeMerge(target[key], source[key])
    } else target[key] = source[key]
  }
  return target
}`
const body = JSON.stringify('{"name":"An","__proto__":{"isAdmin":true}}')
let r = child(`${MERGE}
  const input = JSON.parse(${body})
  console.log(Object.keys(input).join(','))
  const settings = merge({}, input)
  const someoneElse = {}
  console.log(String(someoneElse.isAdmin), String(({}).isAdmin))`)
const [keys, polluted] = r.stdout.trim().split('\n')
log('JSON.parse giữ "__proto__" như một key bình thường', `keys: ${keys}`)
log('merge({}, input) rồi đọc .isAdmin trên một object khác, mới tạo', polluted.split(' ')[0] + ' (mọi object trong process đều "là admin")')
r = child(`${MERGE}
  safeMerge({}, JSON.parse(${body}))
  console.log(String(({}).isAdmin))`)
log('safeMerge bỏ qua __proto__, constructor, prototype', `({}).isAdmin = ${r.stdout.trim()}`)
r = child(`${MERGE}
  merge({}, JSON.parse('{"constructor":{"prototype":{"polluted":"yes"}}}'))
  console.log(String(({}).polluted))`)
log('Đường khác: {"constructor":{"prototype":{…}}}', `({}).polluted = ${r.stdout.trim()}`)
r = child(`
  const user = { role: 'user' }
  Object.prototype.role = 'admin'                 // giả sử đã bị làm bẩn
  const opts = {}
  console.log(opts.role, Object.create(null).role, new Map().get('role'), Object.hasOwn(opts, 'role'))`)
log('Sau khi bị làm bẩn: opts.role, Object.create(null).role, Map, hasOwn', r.stdout.trim())
r = child(`Object.freeze(Object.prototype); try { Object.prototype.isAdmin = true } catch (e) { console.log('gán thất bại:', e.constructor.name) }; console.log(String(({}).isAdmin))`, [])
log('Object.freeze(Object.prototype) ở đầu chương trình (strict mode)', r.stdout.trim().replace('\n', '; ({}).isAdmin = '))
r = child(`${MERGE}
  merge({}, JSON.parse(${body})); const a = String(({}).isAdmin)
  merge({}, JSON.parse('{"constructor":{"prototype":{"polluted":"yes"}}}')); console.log(a, String(({}).polluted))`, ['--disable-proto=delete'])
const [viaProto, viaCtor] = r.stdout.trim().split(' ')
log('--disable-proto=delete, cùng merge: qua __proto__ / qua constructor', `({}).isAdmin = ${viaProto} / ({}).polluted = ${viaCtor}`)

console.log('\n=== 2. Permission model của Node (--permission) ===')
// realpath: trên macOS tmpdir() là symlink /var → /private/var, mà --allow-fs-read so theo đường dẫn thật
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'perm-')))
try {
  const script = join(dir, 'app.mjs')
  writeFileSync(script, `import { readFileSync, writeFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
const tryIt = (label, fn) => { try { fn(); console.log(label + ': được') } catch (e) { console.log(label + ': bị chặn (' + (e.code || e.message) + ')') } }
tryIt('đọc file trong thư mục được phép', () => readFileSync(${JSON.stringify(script)}))
tryIt('đọc ~/.ssh', () => readFileSync(process.env.HOME + '/.ssh/known_hosts'))
tryIt('ghi file', () => writeFileSync(${JSON.stringify(join(dir, 'x.txt'))}, 'x'))
tryIt('chạy process con', () => spawnSync('ls'))
console.log('process.permission.has("fs.read", "/etc/passwd") =', process.permission?.has('fs.read', '/etc/passwd'))`)
  r = spawnSync(process.execPath, ['--permission', `--allow-fs-read=${dir}`, script], { encoding: 'utf8' })
  console.log(r.stdout.trim().split('\n').map((l) => '   ' + l).join('\n') + (r.stderr ? '\n   stderr: ' + r.stderr.split('\n')[0] : ''))
} finally { rmSync(dir, { recursive: true, force: true }) }

console.log('\n=== 3. Bí mật trong biến môi trường ===')
{
  const env = { ...process.env, DB_PASSWORD: 'hunter2-giả' }
  r = spawnSync('sh', ['-c', 'echo "${DB_PASSWORD:-không thấy}"'], { encoding: 'utf8', env })
  log('Process con (một lệnh sh bất kỳ) đọc DB_PASSWORD của process cha', r.stdout.trim())
  r = spawnSync('sh', ['-c', 'echo "${DB_PASSWORD:-không thấy}"'], { encoding: 'utf8', env: { PATH: process.env.PATH } })
  log('Truyền env tối thiểu cho process con', r.stdout.trim())
  r = spawnSync(process.execPath, ['--input-type=module', '-e', `const config = { db: { host: 'db', password: process.env.DB_PASSWORD } }; console.log(JSON.stringify({ msg: 'khởi động', config }))`], { encoding: 'utf8', env })
  log('Log cả object config lúc khởi động', r.stdout.trim())
}

console.log('\n=== 4. Chuỗi cung ứng npm ===')
const cfg = (k) => spawnSync('npm', ['config', 'get', k], { encoding: 'utf8' }).stdout.trim()
log('npm config get ignore-scripts (mặc định)', cfg('ignore-scripts'))
const audit = spawnSync('npm', ['audit', '--json'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname })
try {
  const j = JSON.parse(audit.stdout)
  const v = j.metadata?.vulnerabilities ?? {}
  log('npm audit trên thư mục lab', `${j.metadata?.dependencies?.total ?? '?'} package; lỗ hổng: ${Object.entries(v).filter(([k]) => k !== 'total').map(([k, n]) => `${k} ${n}`).join(', ')}`)
} catch { log('npm audit', 'không chạy được (cần mạng)') }