Giao diện
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:
JSON.parsegiữ"__proto__"như một key bình thường. Trên lab,Object.keyscủa input trả vềname,__proto__.- 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ínhObject.prototype.??=thấy đã có giá trị nên không tạo object mới. - Lần gọi đệ quy ghi
isAdmin = truethẳng vàoObject.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ớiuserkhông cóisAdminriêng. - Đọc tuỳ chọn có giá trị mặc định:
const shell = options.shell ?? false. NếuObject.prototype.shellbị làm bẩn,child_process.spawncó 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 auditbáopiscina5.3.1 (thư viện worker pool dùng ở trang Chặn event loop) có lỗ hổng mức critical, vì nó đọcfilename,execArgv,envtừ 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ách | Chặn được | Ghi 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.keys | Cả hai đường | Trê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ừa | Object 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ông | Gadget | Thay cho obj.key hay key in obj |
Object.freeze(Object.prototype) lúc khởi động | Mọi lần ghi vào prototype | Trê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úcnpm install, trước khi code được dùng. Trên lab,ignore-scriptsmặ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 audittìm thấy trong chính lab.
Biện pháp:
| Biện pháp | Vì sao |
|---|---|
Commit lockfile, dùng npm ci trong CI và lúc build image | npm 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 CI | Phá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 dependency | Mỗ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ó test | Cậ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
shbất kỳ được spawn từ process Node đọc đượcDB_PASSWORDcủa process cha. Khi spawn công cụ bên ngoài (ImageMagick, ffmpeg, một script), truyềnenvtối thiểu: trên lab, chỉ truyềnPATHthì 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
configlà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óredacttheo đường dẫn), và không log nguyênreq.headers(chứaAuthorization,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
.envbị commit, hoặc bị đóng vào Docker image. Thêm.envvào.gitignorevà.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ớ
- Prototype pollution: deep merge trên input chứa
__proto__hoặcconstructor.prototypeghi vàoObject.prototype, ảnh hưởng mọi object trong process; nguy hiểm khi có gadget đọc thuộc tính kế thừa. - Chặn bằng validate schema, không tự viết deep merge,
Map/Object.create(null),Object.hasOwn;--disable-protochỉ chặn đường__proto__. - Chuỗi cung ứng: commit lockfile,
npm ci,npm audittrong CI, cân nhắc--ignore-scripts, ít dependency hơn. - ReDoS: giới hạn độ dài input trước khi chạy regex, tránh lượng từ lồng nhau.
- 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ồn | Dù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-proto | delete xoá hẳn Object.prototype.__proto__, throw ném ERR_PROTO_ACCESS |
npm docs, npm ci | Bá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-cwfg | Lỗ 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ới | true |
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.hasOwn | admin, undefined, undefined, false |
Object.freeze(Object.prototype) rồi gán | TypeError, prototype vẫn sạch |
--disable-proto=delete, qua __proto__ / qua constructor | sạ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-scripts | false |
npm audit thư mục lab (155 package), trước và sau khi nâng piscina lên 5.3.2 | 1 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)') }