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
Vấn đề
Phần tiêu đề “Vấn đề”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àoORDER BY→ lại một đường injection.fileđượcpath.joinvới thư mục export → đọc được file ngoài thư mục.- Lỗi DB trả nguyên
err.messagecho client, log ghi cả headerAuthorization.
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 đó.
Ranh giới tin cậy: dữ liệu đi qua đâu
Phần tiêu đề “Ranh giới tin cậy: dữ liệu đi qua đâu”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'Brienhợ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.
1. Validate ở biên: allowlist, không denylist
Phần tiêu đề “1. Validate ở biên: allowlist, không denylist”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
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.tsexport 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”Parameterized query
Phần tiêu đề “Parameterized query”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ạ.
// SAIawait db.query(`SELECT id, email FROM users WHERE tenant_id = ${tenantId} AND email = '${email}'`);
// ĐÚNGawait 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:
npx --prefix examples ts-node examples/src/secure-coding/sql-injection-demo.tsnố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 -> 400Dò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.
Identifier không thể là parameter
Phần tiêu đề “Identifier không thể là parameter”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 $1báo lỗi cú pháp: dễ phát hiện.ORDER BY $1khô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.
3. Secret: không trong code, log hay URL
Phần tiêu đề “3. Secret: không trong code, log hay URL”| 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):
- Revoke ngay secret cũ ở phía provider. Restart app không thu hồi gì cả.
- 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.
- Kiểm tra audit log của provider trong khoảng thời gian bị lộ.
- 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.
- Viết postmortem: vì sao scanner không chặn được.
4. Logging: structured, có redact
Phần tiêu đề “4. Logging: structured, có redact”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 requestlogger.info({ req }, 'incoming'); // headers.authorization, cookie, body.password
// ĐÚNG: pino redact ở nguồn + chọn fieldconst 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
redacttố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
\nkhô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.
5. Mật khẩu: băm chậm, không mã hoá
Phần tiêu đề “5. Mật khẩu: băm chậm, không mã hoá”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:
npx --prefix examples ts-node examples/src/secure-coding/password-hash-demo.tsnode v24.17.0sha256 x100.000 : 60.2 msscrypt N=2^17 (mặc định) : Invalid scrypt params: error:030000AC:digital envelope routines::memory limit exceededscrypt N=2^17, r=8, p=1 : 173.4 msargon2id m=19MiB,t=2,p=1 : 13.7 msverify (timingSafeEqual) : truescrypt 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
maxmemmặc định nên phải tăngmaxmem. 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 ===.
6. Random và so sánh hằng thời gian
Phần tiêu đề “6. Random và so sánh hằng thời gian”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.
npx --prefix examples ts-node examples/src/secure-coding/random-token-demo.tsMath.random token : 9eyq8035tkw (11 ký tự)randomBytes(32) : 5XsZKj7gUFZJomn5Z1F613OxoHnmn9hJQYdoOosRQb0randomUUID() : b7dd5cd6-db97-476e-9bfc-b39a576a8a08OTP 6 số randomInt : 784789safeEqual đúng : truesafeEqual sai : falsekhá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.
7. Lỗi không được kể chuyện nội bộ
Phần tiêu đề “7. Lỗi không được kể chuyện nội bộ”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.
// SAIcatch (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ụccatch (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.
8. Path traversal và file upload
Phần tiêu đề “8. Path traversal và file upload”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;}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ọtstartsWith(BASE + sep) : false- Kiểm tra sau khi decode:
..%2Fvô 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-evilvẫnstartsWith('/srv/app/uploads'). - Symlink trong
BASEvẫn có thể trỏ ra ngoài; nếu user tạo được symlink, so vớifs.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 |
9. Prototype pollution
Phần tiêu đề “9. Prototype pollution”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;}npx --prefix examples ts-node examples/src/secure-coding/prototype-pollution-demo.tskeys của payload : [ 'theme', '__proto__' ]--disable-proto : falsetrước merge ({}).isAdmin = undefinedsau safeMerge ({}).isAdmin = undefined -> Object.prototype sạchsau naiveMerge ({}).isAdmin = true -> MỌI object đều "là admin"user mới tạo, chưa gán gì: user.isAdmin = trueconstructor.prototype : ({}).polluted = yesObject.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ặnuser mới tạo, chưa gán gì: user.isAdmin = undefinedconstructor.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ủ:
- Không deep merge input thô; validate bằng DTO trước (
whitelistđã loại key lạ). - Dữ liệu dạng “từ điển” do user đặt key: dùng
Map, hoặcObject.create(null). - Hàm merge/set-by-path tự viết phải chặn
__proto__,constructor,prototype. - Giữ thư viện merge/clone/query-string ở bản đã vá; đây là nhóm hay dính CVE prototype pollution.
10. ReDoS: regex chạy hàm mũ
Phần tiêu đề “10. ReDoS: regex chạy hàm mũ”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ụ.
npx --prefix examples ts-node examples/src/secure-coding/redos-demo.tsn | evil /^(a+)+$/ | safe /^a+$/16 | false 1.28 ms | false 0.00 ms20 | false 4.69 ms | false 0.01 ms22 | false 15.59 ms | false 0.00 ms24 | false 66.65 ms | false 0.00 ms26 | false 264.13 ms | false 0.00 ms
khớp (26 'a') | evil true 0.01 ms | safe true 0.00 msMỗ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 (
@MaxLengthtrong 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ùngincludeshoặ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.
Khi nào áp dụng / ngoại lệ
Phần tiêu đề “Khi nào áp dụng / ngoại lệ”| 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 |
Checklist code review
Phần tiêu đề “Checklist code review”- Mọi input (body, query, param, header, file, message) đi qua DTO có decorator;
whitelistvàforbidNonWhitelistedbật toàn cục; có giới hạn độ dài. - Không template string nào chứa SQL với biến từ input; identifier và
ASC/DESCđi qua map allowlist; grepUnsafe,raw(. - Update dùng DTO riêng, không truyền
req.bodyvào ORM; query có scope theo actor (BOLA). - Không secret trong code, test fixture, URL, log; diff không chứa
.env; secret scanner chạy ở CI. - Log là structured và có redact cho
authorization,cookie,password,token. - 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ằngtimingSafeEqual. - Response 5xx không chứa stack trace, câu SQL, đường dẫn; có
errorIdđể tra log. - Đường dẫn file từ input đi qua
resolve+ prefixBASE + sep, tốt hơn là dùngfileId; khôngnew RegExp(input), không deep merge input thô. - Dependency mới có lý do; CI dùng
npm civànpm audit.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”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ì?
Tham khảo
Phần tiêu đề “Tham khảo”- OWASP Top 10:2021
- OWASP Input Validation Cheat Sheet
- OWASP SQL Injection Prevention Cheat Sheet
- OWASP Password Storage Cheat Sheet
- OWASP Secrets Management Cheat Sheet
- OWASP Logging Cheat Sheet
- OWASP Prototype Pollution Prevention Cheat Sheet
- OWASP File Upload Cheat Sheet
- node-postgres — Queries
- NestJS — Validation
- Node.js — Crypto