Bỏ qua để đến nội dung
Knowledge Hub

Secure coding cho backend: lỗ hổng nằm ngay trong code bạn viết Trung cấp

Thời gian đọc ước tính: 24 phút

Một PR thêm endpoint GET /reports/export?email=...&sort=...&file=.... Bốn mươi dòng code, test xanh, reviewer approve. Trong đó có:

  • email được nối thẳng vào câu SQL → đọc được dữ liệu tenant khác.
  • sort được nhét vào ORDER BY → lại một đường injection.
  • file được path.join với thư mục export → đọc được file ngoài thư mục.
  • Lỗi DB trả nguyên err.message cho client, log ghi cả header Authorization.

Không lỗi nào cần kiến trúc phức tạp để sửa. Chúng là lỗi viết code, và chỉ bị bắt nếu người review biết nhìn vào đâu. Bài này là danh sách “nhìn vào đâu” đó, mỗi mục có lý do, before/after và demo chạy thật.

Các bài khác đã đi sâu từng mảng; ở đây chỉ link: phân quyền theo object (BOLA/IDOR), CORS/CSRF, SSRF, session/JWT, rate limiting đăng nhập.

Tóm tắt nhanh: lỗ hổng → cách viết đúng

Phần tiêu đề “Tóm tắt nhanh: lỗ hổng → cách viết đúng”
Lỗ hổng Cách viết sai hay gặp Cách viết đúng (mặc định) OWASP Top 10:2021 (CWE)
Input không kiểm soát Tin req.body như object đã đúng kiểu Schema validation ở biên, allowlist trường (whitelist + forbidNonWhitelisted) A03 Injection (CWE-20)
Mass assignment repo.update(id, req.body) DTO chỉ chứa trường được phép sửa A08 Software and Data Integrity Failures (CWE-915)
SQL injection Template string '${email}' Parameterized query $1, giá trị đi riêng A03 Injection (CWE-89)
Identifier động ORDER BY ${sort} Map allowlist sang SQL cố định A03 Injection
Secret lộ Hard-code, commit .env, in ra log/URL Secret manager, inject lúc chạy, rotate được A07 Identification and Authentication Failures (CWE-798)
Log rò dữ liệu Log nguyên request/headers Structured log + redact ở nguồn A09 Security Logging and Monitoring Failures (CWE-532, CWE-117)
Mật khẩu MD5/SHA-256, hoặc mã hoá hai chiều Argon2id (m = 19 MiB, t = 2, p = 1) theo OWASP A02 Cryptographic Failures (CWE-916)
Token đoán được Math.random() crypto.randomBytes / randomUUID A02 Cryptographic Failures (CWE-330, CWE-338)
So sánh secret a === b crypto.timingSafeEqual Không có nhóm riêng
Lỗi lộ nội bộ Trả stack trace, câu SQL Problem details chung chung, chi tiết vào log A04 Insecure Design (CWE-209)
Path traversal path.join(base, input) path.resolve + kiểm tra prefix base + sep A01 Broken Access Control (CWE-22)
Prototype pollution Deep merge JSON không lọc key Bỏ __proto__/constructor/prototype, dùng Map CWE-1321, nhánh con của CWE-915 (A08)
ReDoS Regex có quantifier lồng nhau Regex tuyến tính + giới hạn độ dài input Không có nhóm riêng
Dependency npm install trong CI, thêm package cho tiện npm ci + lockfile, npm audit trong CI, ít dependency A06 Vulnerable and Outdated Components

Cột OWASP định vị theo danh sách CWE của từng nhóm; một lỗi thật thường chạm nhiều nhóm. Mass assignment trong API còn có tên riêng là BOPLA (API3:2023). OWASP đã phát hành bản Top 10:2025; bảng trên vẫn dùng bản 2021 vì mapping CWE ở đây được đối chiếu theo bản đó.

Mọi quy tắc dưới đây xoay quanh một câu hỏi: dữ liệu này đến từ đâu, và đã qua bước kiểm soát nào? Body, query, header, file upload, message từ queue, response của bên thứ ba đều là untrusted.

Đang tải diagram…

Hai lớp có vai trò khác nhau:

  • Biên loại input sai hình dạng (kiểu, độ dài, enum). Nó giảm bề mặt tấn công nhưng không thay thế bước an toàn ở sink.
  • Sink (SQL, file, log, HTML, shell) phải an toàn với mọi chuỗi hợp lệ. Tên O'Brien hợp lệ, nhưng vẫn phá câu SQL nối chuỗi.

OWASP Input Validation Cheat Sheet nói thẳng: validation không thay thế parameterized query hay output encoding.

Denylist (“chặn dấu nháy”, “chặn <script>”) luôn thiếu và còn chặn nhầm dữ liệu hợp lệ. Allowlist định nghĩa cái được nhận, phần còn lại bị từ chối.

Sai: tin body là đúng kiểu

@Patch('me')
updateMe(@Req() req: Request, @Body() body: any) {
return this.users.update(req.user.id, body); // body có thể chứa role, tenantId, isVerified...
}

Đúng: DTO + ValidationPipe toàn cục

main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // bỏ property không có decorator
forbidNonWhitelisted: true, // thay vì bỏ, trả 400 để client biết gửi sai
transform: true, // trả về instance của DTO, convert kiểu cho param
}));
// update-me.dto.ts
export class UpdateMeDto {
@IsOptional() @IsString() @Length(1, 80)
displayName?: string;
@IsOptional() @IsIn(['vi', 'en'])
locale?: string;
}

Theo docs NestJS, whitelist loại các property không có decorator validation nào; forbidNonWhitelisted biến việc loại thầm lặng thành exception. Bật cả hai làm mặc định: request gửi {"role":"admin"} nhận 400 thay vì âm thầm thành công.

Ba điểm reviewer cần soi:

Điểm Vì sao
Mọi field trong DTO đều có decorator Field không decorator bị whitelist xoá, dễ thành bug “field không lưu được” rồi có người tắt whitelist
Giới hạn độ dài, khoảng giá trị, kích thước mảng Chuỗi 10 MB hợp lệ về kiểu vẫn là DoS
DTO riêng cho create, update, admin update Một DTO dùng chung làm role lọt vào endpoint của user thường

Kiểm tra ngữ nghĩa (ngày kết thúc sau ngày bắt đầu, orderId thuộc user này) nằm ở business logic. Phần “trường nào user được sửa” chính là BOPLA/mass assignment: xem BOLA, BFLA, BOPLA.

2. SQL injection: giá trị đi riêng, identifier đi qua allowlist

Phần tiêu đề “2. SQL injection: giá trị đi riêng, identifier đi qua allowlist”

node-postgres gửi câu SQL và mảng tham số tách rời lên server; server tự thay giá trị, client không ghép chuỗi. Vì vậy x' OR '1'='1 chỉ là một email lạ.

// SAI
await db.query(`SELECT id, email FROM users WHERE tenant_id = ${tenantId} AND email = '${email}'`);
// ĐÚNG
await db.query('SELECT id, email FROM users WHERE tenant_id = $1 AND email = $2', [tenantId, email]);

Demo chạy với PostgreSQL 17 của repo, database tạm scratch_sec, 3 user thuộc 2 tenant, input email = x' OR '1'='1:

Terminal window
npx --prefix examples ts-node examples/src/secure-coding/sql-injection-demo.ts
nối chuỗi -> [ 'an@a.vn', 'binh@a.vn', 'ceo@b.vn' ]
parameterized -> []
ORDER BY $1 -> [ 1, 2, 3 ] (không sort theo cột)
FROM $1 -> syntax error at or near "$1"
allowlist sort -> [ 2, 3, 1 ]
input lạ -> undefined -> 400

Dòng đầu đáng sợ hơn vẻ ngoài: ceo@b.vn thuộc tenant 2. Vì AND ưu tiên hơn OR, điều kiện tenant_id = 1 bị vô hiệu. Injection ở đây đồng thời là lỗi phân quyền.

PostgreSQL không nhận parameter cho identifier (tên bảng, tên cột) hay keyword (ASC/DESC). Demo cho thấy hai kiểu thất bại:

  • FROM $1 báo lỗi cú pháp: dễ phát hiện.
  • ORDER BY $1 không báo lỗi, chỉ sort theo một hằng số. Kết quả [1, 2, 3] thay vì [2, 3, 1]; bug kiểu này thường được “sửa” bằng cách quay lại nối chuỗi.

Cách đúng theo OWASP: map input sang SQL cố định do code định nghĩa.

const SORT = {
newest: 'created_at DESC',
oldest: 'created_at ASC',
email: 'email ASC',
} as const;
const clause = SORT[query.sort as keyof typeof SORT];
if (!clause) throw new BadRequestException('invalid sort');
await db.query(`SELECT id FROM users WHERE tenant_id = $1 ORDER BY ${clause}, id`, [tenantId]);

Chuỗi được nối vào SQL ở đây là hằng trong code, không phải input. Với DTO, @IsIn(Object.keys(SORT)) chặn từ biên, map vẫn là lớp chặn ở sink.

ORM không miễn nhiễm: soi các lối thoát raw

Phần tiêu đề “ORM không miễn nhiễm: soi các lối thoát raw”
Thư viện An toàn Nguy hiểm
node-postgres query(text, values) Template string trong text
Prisma ORM 7 (ổn định) $queryRaw`... ${x}` (tagged template, thành prepared statement) $queryRawUnsafe(str), lạm dụng Prisma.raw
TypeORM QueryBuilder .where('u.email = :email', { email }) .where(`u.email = '${email}'`)
Knex knex.raw('... WHERE email = ?', [email]); identifier qua ?? hoặc :col: knex.raw(`... '${email}'`), whereRaw/orderByRaw với chuỗi tự ghép
Kysely Query builder thường, hoặc sql`... ${email}` (giá trị luôn thành parameter) sql.raw(...); cả sql.ref, sql.id, sql.table, sql.lit (tài liệu Kysely: dùng với input chưa kiểm tra “WILL lead to SQL injection”)

Quy tắc review: grep Unsafe, raw(, Raw(, sql.raw, sql.ref, .query( và mọi template string chứa SQL.

Một điểm dễ hiểu nhầm: helper cho identifier (?? của Knex, sql.ref/sql.id của Kysely) chỉ quote tên cột, không quyết định user được chọn cột nào. Nhận thẳng sort từ query string rồi đưa vào ?? vẫn cho phép sort theo cột nhạy cảm, và Kysely ghi rõ các helper đó không an toàn với input chưa kiểm tra. Map allowlist ở trên vẫn là bước bắt buộc. Prisma docs cũng nhắc biến trong $queryRaw không dùng được cho tên cột, tên bảng hay keyword; phần đó vẫn cần allowlist. Prisma ORM 8 (đang release candidate khi viết bài; bản ổn định vẫn là 7) đổi API raw sang tagged template db.raw.sql (giá trị nội suy luôn thành parameter) và tài liệu của nó vẫn yêu cầu chọn tên cột/bảng từ tập cố định trong code.

Quy tắc Lý do
Không hard-code, không commit .env Git giữ lịch sử; xoá ở commit sau không xoá khỏi history và các bản clone
Không đặt secret trong URL (?api_key=) URL vào access log, proxy log, browser history, header Referer
Không log config hay process.env khi khởi động Một dòng debug là secret nằm trong kho log nhiều tháng
Dùng secret manager (Vault, AWS Secrets Manager, GCP Secret Manager…) Phân quyền, audit, rotate tự động
Mỗi môi trường, mỗi service một secret Lộ một chỗ không kéo theo tất cả
Bật secret scanning ở pre-commit và CI Rẻ hơn nhiều so với xử lý sau khi lộ

Biến môi trường tiện nhưng không phải nơi cất giữ tốt nhất. OWASP Secrets Management Cheat Sheet lưu ý env var thường đọc được bởi các process khác và có thể lọt vào log hay dump, nên chỉ dùng khi không có cách tốt hơn. Cách hay dùng: secret manager inject lúc deploy, app chỉ đọc một lần khi khởi động.

Khi secret bị lộ (commit lên repo public, dán vào chat, nằm trong log):

  1. Revoke ngay secret cũ ở phía provider. Restart app không thu hồi gì cả.
  2. Tạo secret mới, deploy; quy trình này phải tự động được, nếu không bước 1 sẽ bị trì hoãn.
  3. Kiểm tra audit log của provider trong khoảng thời gian bị lộ.
  4. Gỡ secret khỏi nơi bị lộ (log, ticket). Rewrite git history là tuỳ chọn có hậu quả (đổi hash commit, hỏng link); nó không thay thế bước 1.
  5. Viết postmortem: vì sao scanner không chặn được.

Log là kho dữ liệu sống lâu, nhiều người đọc, ít khi được bảo vệ như database. OWASP Logging Cheat Sheet liệt kê những thứ thường không được ghi trực tiếp: session ID, access token, mật khẩu, connection string, encryption key, dữ liệu thẻ, PII nhạy cảm.

// SAI: log nguyên request
logger.info({ req }, 'incoming'); // headers.authorization, cookie, body.password
// ĐÚNG: pino redact ở nguồn + chọn field
const logger = pino({
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'],
censor: '[REDACTED]',
},
});
logger.info({ userId, orderId, route: 'POST /orders' }, 'order.created');
  • Wildcard trong redact tốn hơn path cụ thể; liệt kê path cụ thể cho các field biết trước.
  • Structured log (JSON) cũng giảm log injection: input chứa \n không tạo được dòng log giả như với log dạng text.
  • Cần nhận diện session để điều tra? Log hash của session ID, không log giá trị.

Phân tách log, metric, trace và quy tắc retention: xem Logs, metrics, traces.

Mật khẩu cần băm một chiều bằng thuật toán chậm có tham số. Mã hoá hai chiều nghĩa là ai có key thì có toàn bộ mật khẩu. MD5/SHA-256 là hàm băm nhanh: tốt cho checksum, tệ cho mật khẩu vì brute-force offline cũng nhanh.

Tham số tối thiểu theo OWASP Password Storage Cheat Sheet:

Thuật toán Tham số OWASP Khi nào dùng
Argon2id m = 19 MiB, t = 2, p = 1 (tương đương: 46 MiB/t=1, 12 MiB/t=3, 9 MiB/t=4, 7 MiB/t=5, đều p = 1) Mặc định
scrypt N = 2^17, r = 8 (1024 byte), p = 1 Khi không có Argon2id
bcrypt work factor ≥ 10; input tối đa 72 byte Hệ thống legacy
PBKDF2 HMAC-SHA-256: 600.000 vòng; HMAC-SHA-512: 220.000 vòng Khi cần tuân thủ FIPS-140

Node.js có crypto.argon2 từ v24.7.0, và crypto.scrypt từ lâu. Demo đo trên Apple M5, Node v24.17.0:

Terminal window
npx --prefix examples ts-node examples/src/secure-coding/password-hash-demo.ts
node v24.17.0
sha256 x100.000 : 60.2 ms
scrypt N=2^17 (mặc định) : Invalid scrypt params: error:030000AC:digital envelope routines::memory limit exceeded
scrypt N=2^17, r=8, p=1 : 173.4 ms
argon2id m=19MiB,t=2,p=1 : 13.7 ms
verify (timingSafeEqual) : true
scrypt key (base64) : AQlU+LqeQve9Tr3T...

Đọc kết quả:

  • 100.000 lần SHA-256 mất khoảng 60 ms trên một core: kẻ có bản dump thử được hàng triệu mật khẩu mỗi giây.
  • Mỗi lần argon2id tốn khoảng 14 ms và 19 MiB RAM; chi phí bộ nhớ làm GPU brute-force đắt hơn nhiều.
  • scrypt với N = 2^17, r = 8 cần khoảng 128 MiB (128 × N × r byte), vượt maxmem mặc định nên phải tăng maxmem. Lỗi này hay bị “sửa” bằng cách hạ N.

Lưu cùng hash: thuật toán, tham số và salt (random, mỗi user một salt). Nhờ vậy có thể nâng tham số sau và băm lại khi user đăng nhập thành công. So sánh hash bằng timingSafeEqual, không bằng ===.

Math.random() không phải nguồn ngẫu nhiên an toàn mật mã (MDN). Không dùng nó cho token reset mật khẩu, session ID, OTP, API key, invite code.

Terminal window
npx --prefix examples ts-node examples/src/secure-coding/random-token-demo.ts
Math.random token : 9eyq8035tkw (11 ký tự)
randomBytes(32) : 5XsZKj7gUFZJomn5Z1F613OxoHnmn9hJQYdoOosRQb0
randomUUID() : b7dd5cd6-db97-476e-9bfc-b39a576a8a08
OTP 6 số randomInt : 784789
safeEqual đúng : true
safeEqual sai : false
khác độ dài : ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH
Nhu cầu Dùng
Token, API key, reset link randomBytes(32).toString('base64url')
ID khó đoán randomUUID() (UUID v4 từ CSPRNG)
OTP số randomInt(0, 1_000_000) (không dùng % 10**6 trên byte random, bị lệch phân phối)
So sánh secret, chữ ký, token timingSafeEqual trên hai Buffer cùng độ dài

a === b dừng ở byte khác đầu tiên, nên thời gian phản hồi rò rỉ độ dài phần khớp. timingSafeEqual ném ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH khi khác độ dài; hoặc kiểm tra độ dài trước (như webhook signature), hoặc băm cả hai về cùng độ dài như trong demo.

Token lưu trong database (reset link, API key) nên lưu hash của token, giống mật khẩu nhưng không cần băm chậm vì token đã đủ entropy.

Stack trace, câu SQL lỗi, đường dẫn file, version framework giúp kẻ tấn công định vị injection point. OWASP Error Handling Cheat Sheet: trả response chung chung, ghi chi tiết ở server.

// SAI
catch (err) { res.status(500).json({ error: err.message, stack: err.stack }); }
// err.message: 'duplicate key value violates unique constraint "users_email_key"'
// ĐÚNG: exception filter toàn cục
catch (exception, host) {
const errorId = randomUUID();
this.logger.error({ errorId, err: exception }, 'unhandled');
res.status(500).type('application/problem+json').json({
type: 'about:blank', title: 'Internal Server Error', status: 500, instance: `/errors/${errorId}`,
});
}

Lỗi nghiệp vụ dự kiến (409, 422) vẫn nên rõ ràng cho client, theo Problem Details (RFC 9457). Chỉ lỗi không lường trước mới bị che. Với đăng nhập, “sai mật khẩu” và “không có tài khoản” trả cùng một thông báo.

path.join(base, input) chuẩn hoá .. rồi trả về đường dẫn ngoài base mà không báo gì. Node docs còn ghi rõ path.isAbsolute() không an toàn để chống path traversal.

const BASE = path.resolve('/srv/app/uploads');
function safePath(name: string): string {
const target = path.resolve(BASE, name);
if (!target.startsWith(BASE + path.sep)) throw new BadRequestException('invalid path');
return target;
}
Terminal window
npx --prefix examples ts-node examples/src/secure-coding/path-traversal-demo.ts
"invoice-42.pdf" join -> /srv/app/uploads/invoice-42.pdf safe -> /srv/app/uploads/invoice-42.pdf
"../../../etc/passwd" join -> /etc/passwd safe -> REJECTED
"/etc/passwd" join -> /srv/app/uploads/etc/passwd safe -> REJECTED
"..%2F..%2Fetc%2Fpasswd" join -> /srv/app/uploads/..%2F..%2Fetc%2Fpasswd safe -> /srv/app/uploads/..%2F..%2Fetc%2Fpasswd
"../../etc/passwd" join -> /srv/etc/passwd safe -> REJECTED
"../uploads-evil/x.sh" join -> /srv/app/uploads-evil/x.sh safe -> REJECTED
"a/../../uploads/ok.txt" join -> /srv/app/uploads/ok.txt safe -> /srv/app/uploads/ok.txt
startsWith(BASE) : true <- lọt
startsWith(BASE + sep) : false
  • Kiểm tra sau khi decode: ..%2F vô hại khi còn encode, nguy hiểm khi framework đã decode (dòng 5).
  • Prefix phải kèm path.sep: /srv/app/uploads-evil vẫn startsWith('/srv/app/uploads').
  • Symlink trong BASE vẫn có thể trỏ ra ngoài; nếu user tạo được symlink, so với fs.realpath.

Cách tốt hơn cả: không nhận tên file từ client. Client gửi fileId, server tra bảng files (có kiểm tra quyền theo object) để lấy đường dẫn.

Với upload, OWASP File Upload Cheat Sheet khuyến nghị:

Quy tắc Lý do
Allowlist extension theo nghiệp vụ Denylist luôn thiếu (.phtml, .svg chứa script…)
Không tin header Content-Type Client tự đặt được
Đổi tên thành tên do app sinh (ví dụ UUID) Tên gốc chứa ../, ký tự lạ, ghi đè file
Giới hạn kích thước Bảo vệ dung lượng và bộ nhớ
Lưu ngoài webroot, tốt nhất ở storage riêng File tải lên không bao giờ được thực thi hay serve trực tiếp

JSON từ client chứa key __proto__. JSON.parse tạo nó thành own property bình thường, nhưng một hàm deep merge ngây thơ gán target['__proto__']... sẽ ghi vào Object.prototype. Từ đó mọi object trong process có thuộc tính mới.

const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target: Obj, source: Obj): Obj {
for (const key of Object.keys(source)) {
if (FORBIDDEN.has(key)) continue;
const value = source[key];
if (isObj(value)) {
const next = Object.hasOwn(target, key) && isObj(target[key]) ? (target[key] as Obj) : {};
target[key] = safeMerge(next, value);
} else {
target[key] = value;
}
}
return target;
}
Terminal window
npx --prefix examples ts-node examples/src/secure-coding/prototype-pollution-demo.ts
keys của payload : [ 'theme', '__proto__' ]
--disable-proto : false
trước merge ({}).isAdmin = undefined
sau safeMerge ({}).isAdmin = undefined -> Object.prototype sạch
sau naiveMerge ({}).isAdmin = true -> MỌI object đều "là admin"
user mới tạo, chưa gán gì: user.isAdmin = true
constructor.prototype : ({}).polluted = yes
Object.create(null) : ({}).isAdmin = undefined | dict.__proto__ là own key: [ 'theme', '__proto__' ]

Chạy lại với NODE_OPTIONS=--disable-proto=delete:

sau naiveMerge ({}).isAdmin = undefined -> __proto__ bị chặn
user mới tạo, chưa gán gì: user.isAdmin = undefined
constructor.prototype : ({}).polluted = yes

Đúng như OWASP mô tả: --disable-proto=delete chỉ là defense in depth, đường constructor.prototype vẫn mở. Thứ tự phòng thủ:

  1. Không deep merge input thô; validate bằng DTO trước (whitelist đã loại key lạ).
  2. Dữ liệu dạng “từ điển” do user đặt key: dùng Map, hoặc Object.create(null).
  3. Hàm merge/set-by-path tự viết phải chặn __proto__, constructor, prototype.
  4. Giữ thư viện merge/clone/query-string ở bản đã vá; đây là nhóm hay dính CVE prototype pollution.

Regex engine của V8 dùng backtracking. Pattern có quantifier lồng nhau như ^(a+)+$ phải thử số cách chia tăng gấp đôi theo mỗi ký tự khi input gần khớp rồi hỏng ở cuối. Event loop bị chặn, cả process ngừng phục vụ.

Terminal window
npx --prefix examples ts-node examples/src/secure-coding/redos-demo.ts
n | evil /^(a+)+$/ | safe /^a+$/
16 | false 1.28 ms | false 0.00 ms
20 | false 4.69 ms | false 0.01 ms
22 | false 15.59 ms | false 0.00 ms
24 | false 66.65 ms | false 0.00 ms
26 | false 264.13 ms | false 0.00 ms
khớp (26 'a') | evil true 0.01 ms | safe true 0.00 ms

Mỗi 2 ký tự thêm vào, thời gian nhân khoảng 4 lần. Ở 26 ký tự đã 264 ms; theo đúng tỉ lệ đó, thêm 10 ký tự là nhân khoảng 2^10 lần, tức vài phút chặn event loop. Dòng cuối cho thấy vì sao test thường không bắt được: input khớp chạy tức thì.

Cách viết đúng:

  • Giới hạn độ dài trước khi match (@MaxLength trong DTO), vì chi phí tăng theo độ dài.
  • Tránh nhóm có quantifier chứa quantifier ((a+)+, ([a-z]+)*) hoặc alternation chồng lấn ((a|aa)+).
  • Dùng validator có sẵn (@IsEmail, @IsUUID) thay vì tự viết regex email.
  • Không bao giờ tạo new RegExp(userInput); nếu phải tìm chuỗi do user nhập, dùng includes hoặc escape.

11. Dependency: mỗi package là code bạn chưa review

Phần tiêu đề “11. Dependency: mỗi package là code bạn chưa review”
Việc Lệnh / cách làm Lý do
Cài đúng lockfile trong CI npm ci Báo lỗi nếu package.json lệch lockfile, không sửa lockfile
Commit lockfile package-lock.json vào git Build tái lập được, review được diff dependency
Quét lỗ hổng đã biết npm audit --audit-level=high trong CI Exit code khác 0 khi có lỗ hổng từ mức đặt ra
Kiểm tra chữ ký registry npm audit signatures Phát hiện package bị thay đổi so với registry
Ít dependency Hỏi “20 dòng tự viết được không?” trước khi thêm Mỗi package kéo theo cây transitive và install script
Cân nhắc tắt install script npm ci --ignore-scripts nếu dự án không cần Install script chạy code tuỳ ý lúc cài

Cập nhật đều đặn bằng bot (Dependabot, Renovate) với PR nhỏ dễ hơn nhiều so với nâng 30 package một lúc khi có CVE khẩn.

Quy tắc Luôn áp dụng Ngoại lệ hợp lý
Parameterized query Mọi giá trị trong SQL Không có. Identifier đi qua allowlist, không phải ngoại lệ
whitelist + forbidNonWhitelisted API public, API nội bộ nhận input từ client Endpoint nhận payload mở có chủ đích (webhook lưu nguyên body): validate phần dùng, lưu phần còn lại dưới dạng JSON thô, không merge vào object
Argon2id Mật khẩu người dùng Token random 256 bit: SHA-256 là đủ vì không brute-force được
crypto thay Math.random Mọi giá trị liên quan bảo mật Shuffle giao diện, jitter retry, sampling log
Redact log Production Môi trường local với dữ liệu giả
Tránh regex tự viết Input từ client Regex đơn giản, không lồng quantifier, input đã giới hạn độ dài
  1. Mọi input (body, query, param, header, file, message) đi qua DTO có decorator; whitelist và forbidNonWhitelisted bật toàn cục; có giới hạn độ dài.
  2. Không template string nào chứa SQL với biến từ input; identifier và ASC/DESC đi qua map allowlist; grep Unsafe, raw(.
  3. Update dùng DTO riêng, không truyền req.body vào ORM; query có scope theo actor (BOLA).
  4. Không secret trong code, test fixture, URL, log; diff không chứa .env; secret scanner chạy ở CI.
  5. Log là structured và có redact cho authorization, cookie, password, token.
  6. Mật khẩu băm bằng Argon2id (hoặc scrypt/bcrypt theo bảng OWASP); token sinh bằng crypto; so sánh secret bằng timingSafeEqual.
  7. Response 5xx không chứa stack trace, câu SQL, đường dẫn; có errorId để tra log.
  8. Đường dẫn file từ input đi qua resolve + prefix BASE + sep, tốt hơn là dùng fileId; không new RegExp(input), không deep merge input thô.
  9. Dependency mới có lý do; CI dùng npm ci và npm audit.

Tự kiểm tra: (1) Vì sao ORDER BY $1 nguy hiểm hơn FROM $1? (2) path.resolve(BASE, input).startsWith(BASE) sai ở trường hợp nào? (3) --disable-proto=delete chặn được biến thể prototype pollution nào và bỏ sót biến thể nào? (4) Khi lỡ commit API key lên repo public, bước đầu tiên là gì?