Bảng lừa đảo này liệt kê các hành động mà các nhà phát triển có thể thực hiện để phát triển ứng dụng Node.js an toàn. Mỗi mục có một giải thích ngắn gọn và giải pháp cụ thể cho môi trường Node.js.
Ngữ cảnh
Ứng dụng Node.js đang tăng lên về số lượng và chúng không khác gì các khung và ngôn ngữ lập trình khác. Ứng dụng Node.js dễ dàng bị tổn thương bởi tất cả các lỗ hổng ứng dụng web.
Mục tiêu
Bảng lừa đảo này nhằm cung cấp một danh sách các thực hành tốt để tuân thủ trong quá trình phát triển ứng dụng Node.js.
Đề xuất
Có một số đề xuất để tăng cường bảo mật cho ứng dụng Node.js của bạn. Chúng được phân loại thành:
- Bảo Mật Ứng dụng
- Xử lý Lỗi & Ngoại lệ
- Bảo Mật Máy chủ
- Bảo Mật Nền tảng
Bảo Mật Ứng dụng
Sử dụng chuỗi Promise phẳng
Hàm gọi lại không đồng bộ là một trong những tính năng mạnh mẽ nhất của Node.js. Tuy nhiên, việc tăng lớp lồng trong các hàm gọi lại có thể trở thành một vấn đề. Bất kỳ quy trình nhiều giai đoạn nào cũng có thể được lồng vào 10 hoặc nhiều lớp sâu hơn. Vấn đề này được gọi là “Pyramid of Doom” hoặc “Callback Hell”. Trong mã như vậy, các lỗi và kết quả bị mất trong hàm gọi lại. Promise là một cách tốt để viết mã không đồng bộ mà không bị mắc khuôn mẫu lồng. Promise cung cấp thực thi từ trên xuống trong khi vẫn là không đồng bộ bằng cách truyền lỗi và kết quả cho hàm .then
tiếp theo.
Cảm ơn bạn!
Một lợi ích khác của Promises là cách Promises xử lý lỗi. Nếu có lỗi xảy ra trong một lớp Promise, nó bỏ qua các hàm .then
và gọi hàm .catch
đầu tiên nó tìm thấy. Như vậy, Promises cung cấp độ bảo đảm cao hơn trong việc ghi nhận và xử lý lỗi. Theo nguyên tắc, bạn có thể làm cho tất cả mã không đồng bộ của bạn (ngoại trừ các trình phát tín hiệu) trả về Promises. Cần lưu ý rằng cuộc gọi Promise cũng có thể trở thành một cái lồng. Để hoàn toàn tránh xa khỏi “Callback Hell”, nên sử dụng chuỗi Promise phẳng. Nếu mô-đul bạn đang sử dụng không hỗ trợ Promises, bạn có thể chuyển đổi đối tượng cơ sở thành Promise bằng cách sử dụng hàm Promise.promisifyAll()
.
Đoạn mã sau đây là một ví dụ về “Callback Hell”:
function func1(name, callback) {
// operations that takes a bit of time and then calls the callback
}
function func2(name, callback) {
// operations that takes a bit of time and then calls the callback
}
function func3(name, callback) {
// operations that takes a bit of time and then calls the callback
}
function func4(name, callback) {
// operations that takes a bit of time and then calls the callback
}
func1("input1", function(err, result1){
if(err){
// error operations
}
else {
//some operations
func2("input2", function(err, result2){
if(err){
//error operations
}
else{
//some operations
func3("input3", function(err, result3){
if(err){
//error operations
}
else{
// some operations
func4("input 4", function(err, result4){
if(err){
// error operations
}
else {
// some operations
}
});
}
});
}
});
}
});
Mã trên có thể được viết một cách an toàn như sau bằng cách sử dụng chuỗi Promise phẳng:
function func1(name) {
// operations that takes a bit of time and then resolves the promise
}
function func2(name) {
// operations that takes a bit of time and then resolves the promise
}
function func3(name) {
// operations that takes a bit of time and then resolves the promise
}
function func4(name) {
// operations that takes a bit of time and then resolves the promise
}
func1("input1")
.then(function (result){
return func2("input2");
})
.then(function (result){
return func3("input3");
})
.then(function (result){
return func4("input4");
})
.catch(function (error) {
// error operations
});
Và sử dụng async/await:
function async func1(name) {
// operations that takes a bit of time and then resolves the promise
}
function async func2(name) {
// operations that takes a bit of time and then resolves the promise
}
function async func3(name) {
// operations that takes a bit of time and then resolves the promise
}
function async func4(name) {
// operations that takes a bit of time and then resolves the promise
}
(async() => {
try {
let res1 = await func1("input1");
let res2 = await func2("input2");
let res3 = await func3("input2");
let res4 = await func4("input2");
} catch(err) {
// error operations
}
})();
Đặt giới hạn kích thước yêu cầu
Việc đệm và phân tích cơ thể yêu cầu có thể là một nhiệm vụ tốn tài nguyên. Nếu không có giới hạn về kích thước yêu cầu, người tấn công có thể gửi yêu cầu với thân yêu cầu lớn có thể làm cạn kiểu bộ nhớ máy chủ và/hoặc làm đầy không gian đĩa. Bạn có thể giới hạn kích thước thân yêu cầu cho tất cả yêu cầu bằng cách sử dụng raw-body.
const contentType = require('content-type')
const express = require('express')
const getRawBody = require('raw-body')
const app = express()
app.use(function (req, res, next) {
if (!['POST', 'PUT', 'DELETE'].includes(req.method)) {
next()
return
}
getRawBody(req, {
length: req.headers['content-length'],
limit: '1kb',
encoding: contentType.parse(req).parameters.charset
}, function (err, string) {
if (err) return next(err)
req.text = string
next()
})
})
Tuy nhiên, việc sửa lỗi một giới hạn kích thước yêu cầu cho tất cả yêu cầu có thể không phải là hành vi đúng, vì một số yêu cầu có thể có thân yêu cầu lớn trong thân yêu cầu, chẳng hạn khi tải lên một tệp. Ngoài ra, đầu vào với kiểu JSON nguy hiểm hơn đầu vào đa phần, vì phân tích cú pháp JSON là một hoạt động chặn. Do đó, bạn nên đặt giới hạn kích thước yêu cầu cho các loại nội dung khác nhau. Bạn có thể thực hiện điều này rất dễ dàng với middleware express như sau:
app.use(express.urlencoded({ extended: true, limit: "1kb" }));
app.use(express.json({ limit: "1kb" }));
Cảm ơn bạn!
Cần lưu ý rằng người tấn công có thể thay đổi tiêu đề Content-Type
của yêu cầu và vượt qua giới hạn kích thước yêu cầu. Do đó, trước khi xử lý yêu cầu, dữ liệu chứa trong yêu cầu nên được xác thực đối với loại nội dung được nêu trong tiêu đề yêu cầu. Nếu việc xác thực loại nội dung cho mỗi yêu cầu ảnh hưởng nghiêm trọng đến hiệu suất, bạn chỉ nên xác thực các loại nội dung cụ thể hoặc yêu cầu lớn hơn một kích thước được quy định trước.
Không chặn vòng lặp sự kiện
Node.js rất khác biệt so với các nền tảng ứng dụng thông thường sử dụng luồng. Node.js có kiến trúc sự kiện đơn luồng duy nhất. Bằng cách của kiến trúc này, công suất trở nên cao và mô hình lập trình trở nên đơn giản hơn. Node.js được triển khai xung quanh một vòng lặp sự kiện không chặn I/O. Với vòng lặp sự kiện này, không có sự chờ đợi trong I/O hoặc chuyển đổi ngữ cảnh. Vòng lặp sự kiện tìm kiếm các sự kiện và gửi chúng đến các hàm xử lý. Chính vì vậy, khi thực hiện các hoạt động JavaScript tốn nhiều CPU, vòng lặp sự kiện đợi chúng hoàn thành. Đây là lý do tại sao các hoạt động như vậy được gọi là “chặn”. Để vượt qua vấn đề này, Node.js cho phép gán các callback cho các sự kiện bị chặn I/O. Điều này giúp ứng dụng chính không bị chặn và các callback chạy bất đồng bộ. Do đó, như một nguyên tắc tổng quan, tất cả các hoạt động chặn nên được thực hiện bất đồng bộ để vòng lặp sự kiện không bị chặn.
Ngay cả khi bạn thực hiện các hoạt động chặn một cách bất đồng bộ, ứng dụng của bạn có thể vẫn không phục vụ như mong đợi. Điều này xảy ra nếu có mã nằm ngoài callback mà phụ thuộc vào mã trong callback phải chạy trước. Ví dụ, xem xét đoạn mã sau đây:
const fs = require('fs');
fs.readFile('/file.txt', (err, data) => {
// perform actions on file content
});
fs.unlinkSync('/file.txt');
Cảm ơn bạn!
Trong ví dụ trên, hàm unlinkSync
có thể chạy trước callback, điều này sẽ xóa tệp trước khi các hành động mong muốn trên nội dung tệp được thực hiện. Những tình huống cạnh tranh như vậy cũng có thể ảnh hưởng đến bảo mật của ứng dụng của bạn. Một ví dụ có thể là một tình huống trong đó xác thực được thực hiện trong một callback và các hành động đã xác thực được thực hiện đồng bộ. Để loại bỏ những tình huống cạnh tranh như vậy, bạn có thể viết tất cả các hoạt động phụ thuộc vào nhau trong một hàm không chặn. Bằng cách làm như vậy, bạn có thể đảm bảo rằng tất cả các hoạt động được thực hiện theo đúng thứ tự. Ví dụ, ví dụ mã trên có thể được viết theo cách không chặn như sau:
const fs = require('fs');
fs.readFile('/file.txt', (err, data) => {
// perform actions on file content
fs.unlink('/file.txt', (err) => {
if (err) throw err;
});
});
Trong mã trên, cuộc gọi để xóa tệp và các hoạt động tệp khác đều nằm trong cùng một callback. Điều này đảm bảo thứ tự thực hiện đúng.
Thực hiện kiểm tra đầu vào
Kiểm tra đầu vào là một phần quan trọng của bảo mật ứng dụng. Việc kiểm tra đầu vào không thành công có thể dẫn đến nhiều loại tấn công ứng dụng. Chúng bao gồm Sử dụng SQL, Sử dụng Kịch bản Trang web Băng giấy, Sử dụng Lệnh, Kết hợp Tệp Cục bộ/Từ xa, Tổng hợp Dịch vụ, Traversal Thư mục, Sử dụng LDAP và nhiều loại tấn công tiêm chất khác. Để tránh những cuộc tấn công này, đầu vào cho ứng dụng của bạn nên được xử lý trước. Kỹ thuật kiểm tra đầu vào tốt nhất là sử dụng một danh sách các đầu vào được chấp nhận. Tuy nhiên, nếu điều này không thể thực hiện, đầu vào nên được kiểm tra trước theo kế hoạch đầu vào được mong đợi và đầu vào nguy hiểm nên được thoát. Để đơn giản hóa việc kiểm tra đầu vào trong các ứng dụng Node.js, có một số mô-đul như validator và mongo-express-sanitize. Để biết thông tin chi tiết về kiểm tra đầu vào, vui lòng tham khảo Input Validation Cheat Sheet.
Cảm ơn bạn!
JavaScript là một ngôn ngữ động và tùy thuộc vào cách framework phân tích URL, dữ liệu mà mã ứng dụng thấy có thể có nhiều hình dạng khác nhau. Dưới đây là một số ví dụ sau khi phân tích chuỗi truy vấn trong express.js:
URL | Nội dung của request.query.foo trong mã |
---|---|
?foo=bar |
'bar' (chuỗi) |
?foo=bar&foo=baz |
['bar', 'baz'] (mảng của chuỗi) |
?foo[]=bar |
['bar'] (mảng của chuỗi) |
?foo[]=bar&foo[]=baz |
['bar', 'baz'] (mảng của chuỗi) |
?foo[bar]=baz |
{ bar : 'baz' } (đối tượng với một khóa) |
?foo[]=bar |
['bar'] (mảng của chuỗi) |
?foo[]baz=bar |
['bar'] (mảng của chuỗi – postfix bị mất) |
?foo[][baz]=bar |
[ { baz: 'bar' } ] (mảng của đối tượng) |
?foo[bar][baz]=bar |
{ foo: { bar: { baz: 'bar' } } } (cây đối tượng) |
?foo[10]=bar&foo[9]=baz |
[ 'baz', 'bar' ] (mảng của chuỗi – chú ý thứ tự) |
?foo[toString]=bar |
{} (đối tượng trong đó gọi toString() sẽ thất bại) |
Thực hiện thoát đầu ra
Ngoài việc kiểm tra đầu vào, bạn nên thoát tất cả nội dung HTML và JavaScript hiển thị cho người dùng thông qua ứng dụng để ngăn chặn các cuộc tấn công chèn mã (XSS). Bạn có thể sử dụng các thư viện như escape-html hoặc node-esapi để thực hiện thoát đầu ra.
Thực hiện ghi nhật ký hoạt động ứng dụng
Ghi nhật ký hoạt động ứng dụng là một thực hành tốt được khuyến khích. Nó làm cho việc gỡ lỗi các lỗi gặp phải trong quá trình chạy ứng dụng trở nên dễ dàng hơn. Nó cũng hữu ích cho mục đích bảo mật, vì nó có thể được sử dụng trong quá trình phản ứng khi có sự cố xảy ra. Ngoài ra, các bản ghi này có thể được sử dụng để cung cấp dữ liệu cho Hệ thống Phát hiện/Phòng ngừa Xâm nhập (IDS/IPS). Trong Node.js, có các mô-đul như Winston, Bunyan, hoặc Pino để thực hiện việc ghi nhật ký hoạt động ứng dụng. Các mô-đul này cho phép truyền và truy vấn các bản ghi nhật ký và cung cấp cách để xử lý các ngoại lệ không được bắt.
Với đoạn mã sau đây, bạn có thể ghi nhật ký hoạt động ứng dụng trên cả bảng điều khiển và tệp nhật ký mong muốn:
const logger = new (Winston.Logger) ({
transports: [
new (winston.transports.Console)(),
new (winston.transports.File)({ filename: 'application.log' })
],
level: 'verbose'
});
Bạn có thể cung cấp các phương tiện truyền thông khác nhau để bạn có thể lưu lỗi vào một tệp nhật ký riêng và lưu các nhật ký ứng dụng chung vào một tệp nhật ký khác. Thêm thông tin về việc ghi nhật ký bảo mật có thể được tìm thấy tại Logging Cheat Sheet.
Giám sát vòng lặp sự kiện
Khi máy chủ ứng dụng của bạn đang gặp lưu lượng mạng nặng, nó có thể không thể phục vụ người dùng của nó. Điều này về cơ bản là một loại tấn công Denial of Service (DoS). Mô-đun toobusy-js cho phép bạn giám sát vòng lặp sự kiện. Nó theo dõi thời gian phản hồi, và khi vượt quá một ngưỡng cụ thể, mô-đun này có thể cho biết máy chủ của bạn quá bận rộn. Trong trường hợp đó, bạn có thể ngừng xử lý các yêu cầu đến và gửi cho họ thông báo 503 Server Quá Bận
để ứng dụng của bạn vẫn phản hồi. Ví dụ về việc sử dụng mô-đun toobusy-js được hiển thị ở đây:
const toobusy = require('toobusy-js');
const express = require('express');
const app = express();
app.use(function(req, res, next) {
if (toobusy()) {
// log if you see necessary
res.status(503).send("Server Too Busy");
} else {
next();
}
});
Đề phòng tấn công đoán mật khẩu
Brute-forcing là mối đe dọa phổ biến đối với tất cả các ứng dụng web. Hacker có thể sử dụng tấn công đoán mật khẩu như một cách thức để đoán mật khẩu tài khoản. Vì vậy, các nhà phát triển ứng dụng nên đề phòng trước tấn công đoán mật khẩu, đặc biệt là trên trang đăng nhập. Node.js có một số mô-đun có sẵn cho mục đích này. Express-bouncer, express-brute và rate-limiter chỉ là một số ví dụ. Dựa trên nhu cầu và yêu cầu của bạn, bạn nên chọn một hoặc nhiều mô-đun này và sử dụng tương ứng. Express-bouncer và express-brute cũng hoạt động tương tự. Chúng tăng thời gian trễ cho mỗi yêu cầu thất bại và có thể được sắp xếp cho một tuyến đường cụ thể. Các mô-đun này có thể được sử dụng như sau:
const bouncer = require('express-bouncer');
bouncer.whitelist.push('127.0.0.1'); // allow an IP address
// give a custom error message
bouncer.blocked = function (req, res, next, remaining) {
res.status(429).send("Too many requests have been made. Please wait " + remaining/1000 + " seconds.");
};
// route to protect
app.post("/login", bouncer.block, function(req, res) {
if (LoginFailed){ }
else {
bouncer.reset( req );
}
});
const ExpressBrute = require('express-brute');
const store = new ExpressBrute.MemoryStore(); // stores state locally, don't use this in production
const bruteforce = new ExpressBrute(store);
app.post('/auth',
bruteforce.prevent, // error 429 if we hit this route too often
function (req, res, next) {
res.send('Success!');
}
);
Ngoài express-bouncer và express-brute, mô-đun rate-limiter cũng có thể giúp ngăn chặn tấn công đoán mật khẩu. Nó cho phép chỉ định bao nhiêu yêu cầu một địa chỉ IP cụ thể có thể thực hiện trong một khoảng thời gian cụ thể.
const limiter = new RateLimiter();
limiter.addLimit('/login', 'GET', 5, 500); // login page can be requested 5 times at max within 500 seconds
CAPTCHA usage cũng là một cơ chế phổ biến khác được sử dụng để chống lại tấn công đoán mật khẩu. Có các mô-đun được phát triển cho CAPTCHA của Node.js. Một mô-đun phổ biến được sử dụng trong ứng dụng Node.js là svg-captcha. Nó có thể được sử dụng như sau:
const svgCaptcha = require('svg-captcha');
app.get('/captcha', function (req, res) {
const captcha = svgCaptcha.create();
req.session.captcha = captcha.text;
res.type('svg');
res.status(200).send(captcha.data);
});
Account lockout là một giải pháp được đề xuất để giữ kẻ tấn công ra xa khỏi người dùng hợp lệ của bạn. Khóa tài khoản là có thể với nhiều mô-đun như mongoose. Bạn có thể tham khảo this blog post để xem cách khóa tài khoản được thực hiện trong mongoose.
Sử dụng mã thông báo Anti-CSRF
Cross-Site Request Forgery (CSRF) nhằm thực hiện các hành động được ủy quyền thay mặt người dùng đã xác thực, trong khi người dùng không nhận thức về hành động này. Các tấn công CSRF thường được thực hiện cho các yêu cầu thay đổi trạng thái như thay đổi mật khẩu, thêm người dùng hoặc đặt hàng. Csurf là một middleware express đã được sử dụng để giảm tấn công CSRF. Nhưng đã có lỗ hổng bảo mật trong gói này được phát hiện gần đây. Nhóm phát triển gói không sửa lỗi được phát hiện và họ đã đánh dấu gói là lỗi thời, khuyến nghị sử dụng bất kỳ gói bảo vệ CSRF nào khác.
Để biết thông tin chi tiết về các tấn công và các phương pháp ngăn chặn tấn công giả mạo yêu cầu qua trình (CSRF), bạn có thể tham khảo Cross-Site Request Forgery Prevention.
Loại bỏ các tuyến đường không cần thiết
Một ứng dụng web không nên chứa bất kỳ trang nào không được sử dụng bởi người dùng, vì điều này có thể làm tăng bề mặt tấn công của ứng dụng. Do đó, tất cả các tuyến đường API không sử dụng nên bị vô hiệu hóa trong các ứng dụng Node.js. Điều này xảy ra đặc biệt trong các framework như Sails và Feathers, vì chúng tự động tạo ra các điểm cuối API REST. Ví dụ, trong Sails, nếu một URL không phù hợp với một tuyến đường tùy chỉnh, nó có thể phù hợp với một trong các tuyến đường tự động và vẫn tạo ra một phản hồi. Tình huống này có thể dẫn đến các kết quả từ rò rỉ thông tin đến thực thi lệnh tùy ý. Do đó, trước khi sử dụng các framework và mô-đun như vậy, quan trọng là phải biết các tuyến đường mà chúng tự động tạo ra và loại bỏ hoặc vô hiệu hóa những tuyến đường này.
Ngăn chặn Ô nhiễm Tham số HTTP
HTTP Parameter Pollution(HPP) là một cuộc tấn công trong đó kẻ tấn công gửi nhiều tham số HTTP có cùng tên và điều này khiến ứng dụng của bạn hiểu chúng một cách không đoán trước. Khi nhiều giá trị tham số được gửi, Express sẽ đưa chúng vào một mảng. Để giải quyết vấn đề này, bạn có thể sử dụng mô-đun hpp. Khi sử dụng, mô-đun này sẽ bỏ qua tất cả các giá trị được gửi cho một tham số trong req.query
và/hoặc req.body
và chỉ chọn giá trị tham số cuối cùng được gửi. Bạn có thể sử dụng nó như sau:
const hpp = require('hpp');
app.use(hpp());
Chỉ trả về những gì cần thiết
Thông tin về người dùng của một ứng dụng là một trong những thông tin quan trọng nhất về ứng dụng. Bảng người dùng thường bao gồm các trường như id, tên người dùng, họ tên đầy đủ, địa chỉ email, ngày sinh, mật khẩu và trong một số trường hợp số Bảo hiểm Xã hội. Do đó, khi truy vấn và sử dụng đối tượng người dùng, bạn cần chỉ trả về các trường cần thiết vì nó có thể dễ bị tiết lộ thông tin cá nhân. Điều này cũng đúng cho các đối tượng khác được lưu trữ trong cơ sở dữ liệu. Nếu bạn chỉ cần một trường cụ thể của một đối tượng, bạn nên chỉ trả về các trường cụ thể cần thiết. Ví dụ, bạn có thể sử dụng một hàm như sau mỗi khi bạn cần lấy thông tin về một người dùng. Bằng cách này, bạn chỉ trả về các trường cần thiết cho hoạt động cụ thể của bạn. Nói cách khác, nếu bạn chỉ cần liệt kê tên của các người dùng có sẵn, bạn sẽ không trả lại địa chỉ email hoặc số thẻ tín dụng của họ ngoài tên đầy đủ.
exports.sanitizeUser = function(user) {
return {
id: user.id,
username: user.username,
fullName: user.fullName
};
};
Sử dụng mô tả thuộc tính đối tượng
Các thuộc tính đối tượng bao gồm ba thuộc tính ẩn: writable
(nếu false, giá trị thuộc tính không thể thay đổi), enumerable
(nếu false, thuộc tính không thể sử dụng trong vòng lặp for) và configurable
(nếu false, thuộc tính không thể bị xóa). Khi định nghĩa một thuộc tính đối tượng thông qua phép gán, ba thuộc tính ẩn này được đặt thành true mặc định. Các thuộc tính này có thể được thiết lập như sau:
const o = {};
Object.defineProperty(o, "a", {
writable: true,
enumerable: true,
configurable: true,
value: "A"
});
Ngoài ra, còn có một số hàm đặc biệt cho các thuộc tính đối tượng. Object.preventExtensions()
ngăn việc thêm các thuộc tính mới vào đối tượng.
Sử dụng danh sách kiểm soát truy cập
Quyền ủy quyền ngăn người dùng thực hiện ngoài quyền hạn dự định của họ. Để làm như vậy, người dùng và vai trò của họ cần được xác định dựa trên nguyên tắc của quyền hạn tối thiểu. Mỗi vai trò người dùng nên chỉ có quyền truy cập vào các tài nguyên mà họ cần sử dụng. Đối với ứng dụng Node.js của bạn, bạn có thể sử dụng mô-đun acl để cung cấp việc thực hiện danh sách kiểm soát truy cập (access control list – ACL). Với mô-đun này, bạn có thể tạo vai trò và gán người dùng vào các vai trò này.
Xử lý Lỗi & Ngoại lệ
Xử lý uncaughtException
Hành vi của Node.js đối với các ngoại lệ không được xử lý là in ra ngăn xếp gọi hiện tại và sau đó chấm dứt luồng làm việc. Tuy nhiên, Node.js cho phép tùy chỉnh hành vi này. Nó cung cấp một đối tượng toàn cục mang tên process mà có sẵn cho tất cả các ứng dụng Node.js. Đó là một đối tượng EventEmitter và trong trường hợp có một ngoại lệ không được xử lý, sự kiện uncaughtException được phát ra và nó được đưa lên vòng lặp sự kiện chính. Để cung cấp hành vi tùy chỉnh cho các ngoại lệ không được xử lý, bạn có thể kết nối với sự kiện này. Tuy nhiên, việc tiếp tục ứng dụng sau một ngoại lệ không được xử lý có thể dẫn đến các vấn đề tiếp theo. Do đó, nếu bạn không muốn bỏ lỡ bất kỳ ngoại lệ không được xử lý nào, bạn nên kết nối với sự kiện uncaughtException và dọn dẹp tài nguyên đã được cấp như mô tả tệp, bộ xử lý và tương tự trước khi tắt quy trình. Việc tiếp tục ứng dụng được khuyến nghị mạnh mẽ vì ứng dụng sẽ ở trong trạng thái không xác định. Quan trọng phải lưu ý rằng khi hiển thị thông báo lỗi cho người dùng trong trường hợp có một ngoại lệ không được xử lý, thông tin chi tiết như ngăn xếp gọi không nên được tiết lộ cho người dùng. Thay vào đó, thông báo lỗi tùy chỉnh nên được hiển thị cho người dùng để không gây rò rỉ thông tin.
process.on("uncaughtException", function(err) {
// clean up allocated resources
// log necessary error details to log files
process.exit(); // exit the process to avoid unknown state
});
Lắng nghe lỗi khi sử dụng EventEmitter
Khi sử dụng EventEmitter, lỗi có thể xảy ra ở bất kỳ đâu trong chuỗi sự kiện. Thông thường, nếu có lỗi xảy ra trong một đối tượng EventEmitter, một sự kiện lỗi có một đối tượng Lỗi (Error) làm đối số được gọi. Tuy nhiên, nếu không có bất kỳ người nghe nào được đính kèm cho sự kiện lỗi đó, đối tượng Lỗi được gửi như một đối số sẽ được ném ra và trở thành một ngoại lệ không được xử lý. Tóm lại, nếu bạn không xử lý lỗi trong một đối tượng EventEmitter một cách đúng đắn, những lỗi không được xử lý này có thể làm đứt ứng dụng của bạn. Do đó, bạn nên luôn lắng nghe các sự kiện lỗi khi sử dụng đối tượng EventEmitter.
const events = require('events');
const myEventEmitter = function(){
events.EventEmitter.call(this);
}
require('util').inherits(myEventEmitter, events.EventEmitter);
myEventEmitter.prototype.someFunction = function(param1, param2) {
//in case of an error
this.emit('error', err);
}
const emitter = new myEventEmitter();
emitter.on('error', function(err){
//Perform necessary error handling here
});
Xử lý lỗi trong các cuộc gọi không đồng bộ
Các lỗi xảy ra trong các cuộc gọi không đồng bộ thường dễ bị bỏ lỡ. Vì vậy, theo một nguyên tắc chung, đối số đầu tiên cho các cuộc gọi không đồng bộ nên là một đối tượng Lỗi. Ngoài ra, các tuyến đường express tự xử lý lỗi, nhưng luôn nhớ rằng các lỗi xảy ra trong các cuộc gọi không đồng bộ được thực hiện trong các tuyến đường express sẽ không được xử lý, trừ khi một đối tượng Lỗi được gửi làm đối số đầu tiên.
Các lỗi trong các cuộc gọi này có thể được truyền đi nhiều lần. Mỗi cuộc gọi mà lỗi đã được truyền đến có thể bỏ qua, xử lý hoặc truyền lỗi tiếp.
Bảo mật Máy chủ
Đặt cờ cookie một cách thích hợp
Thông thường, thông tin phiên được gửi bằng cách sử dụng cookie trong các ứng dụng web. Tuy nhiên, việc sử dụng không đúng cách của cookie HTTP có thể khiến ứng dụng trở nên dễ bị lỗ hổng quản lý phiên. Một số cờ có thể được đặt cho mỗi cookie để ngăn chặn những loại tấn công này. Các cờ httpOnly
, Secure
và SameSite
rất quan trọng đối với cookie phiên. Cờ httpOnly
ngăn chặn việc truy cập cookie bằng JavaScript phía máy khách. Đây là một biện pháp phòng ngừa hiệu quả đối với tấn công XSS. Cờ Secure
cho phép cookie chỉ được gửi khi truyền thông diễn ra qua HTTPS. Cờ SameSite
có thể ngăn chặn việc gửi cookie trong các yêu cầu qua trang web khác giúp bảo vệ khỏi các tấn công Cross-Site Request Forgery (CSRF). Ngoài ra, còn có các cờ khác như domain, path và expires. Khuyến khích đặt các cờ này một cách thích hợp, nhưng chúng chủ yếu liên quan đến phạm vi cookie chứ không phải bảo mật cookie. Sử dụng mẫu của các cờ này được thể hiện trong ví dụ dưới đây:
const session = require('express-session');
app.use(session({
secret: 'your-secret-key',
name: 'cookieName',
cookie: { secure: true, httpOnly: true, path: '/user', sameSite: true}
}));
Sử dụng các tiêu đề bảo mật thích hợp
Có một số HTTP security headers có thể giúp bạn ngăn chặn một số vector tấn công phổ biến. Gói helmet có thể giúp đặt các tiêu đề đó:
const express = require("express");
const helmet = require("helmet");
const app = express();
app.use(helmet()); // Add various HTTP headers
Hàm helmet
cấp cao nhất là một bao bọc cho 14 middleware nhỏ hơn. Dưới đây là danh sách các tiêu đề bảo mật HTTP được bao gồm bởi các middleware helmet
:
-
Strict-Transport-Security: HTTP Strict Transport Security (HSTS) quy định trình duyệt rằng ứng dụng chỉ có thể được truy cập qua kết nối HTTPS. Để sử dụng nó trong ứng dụng của bạn, thêm các mã sau đây:
app.use(helmet.hsts()); // default configuration
app.use(
helmet.hsts({
maxAge: 123456,
includeSubDomains: false,
})
); // custom configuration -
X-Frame-Options : xác định xem một trang có thể được nạp thông qua một phần tử
<frame>
hoặc<iframe>
. Cho phép trang được nạp trong khung có thể dẫn đến các tấn công Clickjacking.
app.use(helmet.frameguard()); // hành vi mặc định (SAMEORIGIN)
-
X-XSS-Protection : ngăn trang web từ việc tải khi phát hiện các cuộc tấn công cross-site scripting (XSS) được phản ánh. Tiêu đề này đã bị loại bỏ bởi các trình duyệt hiện đại và việc sử dụng nó có thể giới thiệu thêm vấn đề bảo mật phụ trên phía máy khách. Do đó, đề nghị đặt tiêu đề như X-XSS-Protection: 0 để tắt XSS Auditor và không cho phép nó thực hiện hành vi mặc định của trình duyệt xử lý phản hồi.
app.use(helmet.xssFilter()); // sets “X-XSS-Protection: 0”
Đối với các trình duyệt hiện đại, đề nghị triển khai chính sách Content-Security-Policy mạnh, như được mô tả trong phần tiếp theo.
-
Content-Security-Policy : Chính sách Bảo mật Nội dung (Content Security Policy) được phát triển để giảm nguy cơ các cuộc tấn công như Cross-Site Scripting (XSS) và Clickjacking. Nó cho phép nội dung từ một danh sách bạn quyết định. Nó có một số chỉ thị, mỗi chỉ thị cấm việc tải một loại cụ thể của nội dung. Bạn có thể tham khảo Content Security Policy Cheat Sheet để biết giải thích chi tiết về mỗi chỉ thị và cách sử dụng nó. Bạn có thể triển khai các cài đặt này trong ứng dụng của bạn như sau:
app.use(
helmet.contentSecurityPolicy({
// the following directives will be merged into the default helmet CSP policy
directives: {
defaultSrc: [“‘self'”], // default value for all directives that are absent
scriptSrc: [“‘self'”], // helps prevent XSS attacks
frameAncestors: [“‘none'”], // helps prevent Clickjacking attacks
imgSrc: [“‘self'”, “‘http://imgexample.com'”],
styleSrc: [“‘none'”]
}
})
);
Vì middleware này thực hiện rất ít kiểm tra hợp lệ, nên đề nghị dựa vào các công cụ kiểm tra CSP như CSP Evaluator thay vì thế.
-
X-Content-Type-Options : Ngay cả khi máy chủ đặt tiêu đề
Content-Type
hợp lệ trong phản hồi, trình duyệt có thể cố gắng phát hiện loại MIME của tài nguyên được yêu cầu. Tiêu đề này là một cách để ngăn chặn hành vi này và cho biết trình duyệt không nên thay đổi các loại MIME được chỉ định trong tiêu đềContent-Type
. Nó có thể được cấu hình như sau:app.use(helmet.noSniff());
-
Cache-Control và Pragma : Tiêu đề Cache-Control có thể được sử dụng để ngăn trình duyệt lưu trữ tải phản hồi đã được cung cấp. Điều này nên được thực hiện đối với các trang chứa thông tin nhạy cảm về người dùng hoặc ứng dụng. Tuy nhiên, việc vô hiệu hóa bộ nhớ cache cho các trang không chứa thông tin nhạy cảm có thể ảnh hưởng đến hiệu suất của ứng dụng. Do đó, chỉ nên vô hiệu hóa bộ nhớ cache cho các trang trả về thông tin nhạy cảm. Bạn có thể dễ dàng đặt các điều khiển và tiêu đề bộ nhớ cache thích hợp bằng cách sử dụng gói nocache như sau:
const nocache = require(“nocache”);
app.use(nocache());
Mã trên đặt các tiêu đề Cache-Control, Surrogate-Control, Pragma và Expires tương ứng.
-
X-Download-Options: Tiêu đề này ngăn trình duyệt Internet Explorer thực thi các tệp đã tải về trong ngữ cảnh của trang web. Điều này được thực hiện bằng chỉ thị noopen. Bạn có thể làm như vậy bằng đoạn mã sau:
app.use(helmet.ieNoOpen());
-
Expect-CT : Chính sách Kiểm tra Chứng chỉ là một cơ chế mới được phát triển để khắc phục một số vấn đề cơ cấu liên quan đến cơ sở hạ tầng SSL hiện tại. Tiêu đề Expect-CT có thể bắt buộc yêu cầu kiểm tra chứng chỉ trong ứng dụng của bạn. Nó có thể được triển khai trong ứng dụng của bạn như sau:
const expectCt = require(‘expect-ct’);
app.use(expectCt({ maxAge: 123 }));
app.use(expectCt({ enforce: true, maxAge: 123 }));
app.use(expectCt({ enforce: true, maxAge: 123, reportUri: ‘http://example.com’})); -
X-Powered-By: Tiêu đề X-Powered-By được sử dụng để thông báo về công nghệ được sử dụng ở phía máy chủ. Đây là một tiêu đề không cần thiết gây ra rò rỉ thông tin, vì vậy nó nên được loại bỏ khỏi ứng dụng của bạn. Để làm như vậy, bạn có thể sử dụng
hidePoweredBy
như sau:app.use(helmet.hidePoweredBy());
Ngoài ra, bạn có thể đưa ra thông tin sai về các công nghệ được sử dụng với tiêu đề này. Ví dụ, ngay cả khi ứng dụng của bạn không sử dụng PHP, bạn có thể đặt tiêu đề X-Powered-By để có vẻ như vậy.
app.use(helmet.hidePoweredBy({ setTo: 'PHP 4.2.0' }));
Bảo mật Nền tảng
Giữ cho các gói của bạn luôn được cập nhật
Bảo mật của ứng dụng của bạn phụ thuộc trực tiếp vào việc các gói bên thứ ba bạn sử dụng trong ứng dụng có bảo mật hay không. Do đó, việc duy trì cập nhật các gói của bạn rất quan trọng. Cần lưu ý rằng Using Components with Known Vulnerabilities vẫn nằm trong OWASP Top 10. Bạn có thể sử dụng OWASP Dependency-Check để xem liệu có bất kỳ gói nào được sử dụng trong dự án có lỗ hổng đã biết hay không. Ngoài ra, bạn có thể sử dụng Retire.js để kiểm tra các thư viện JavaScript có lỗ hổng đã biết.
Bắt đầu từ phiên bản 6, npm
đã giới thiệu audit
, một công cụ sẽ cảnh báo về các gói có lỗ hổng:
npm audit
npm
cũng đã giới thiệu một cách đơn giản để nâng cấp các gói bị ảnh hưởng:
npm audit fix
Có nhiều công cụ khác bạn có thể sử dụng để kiểm tra các phụ thuộc của bạn. Một danh sách chi tiết hơn có thể được tìm thấy trong Vulnerable Dependency Management CS.
Không sử dụng các hàm nguy hiểm
Có một số hàm JavaScript có tính nguy hiểm và chỉ nên được sử dụng khi cần thiết hoặc không tránh được. Ví dụ đầu tiên là hàm eval()
. Hàm này nhận đối số là một chuỗi và thực thi nó như bất kỳ mã nguồn JavaScript nào khác. Kết hợp với đầu vào của người dùng, hành vi này gây ra lỗ hổng thực thi mã từ xa một cách tự nhiên. Tương tự, các cuộc gọi đến child_process.exec
cũng rất nguy hiểm. Hàm này hoạt động như một trình thông dịch bash và gửi các đối số của nó đến /bin/sh. Bằng cách tiêm nạp đầu vào vào hàm này, các tấn công viên có thể thực thi các lệnh tùy ý trên máy chủ.
Ngoài các hàm này, một số module đòi hỏi sự quan tâm đặc biệt khi sử dụng. Ví dụ, module fs
xử lý các thao tác trên hệ thống tệp. Tuy nhiên, nếu đầu vào của người dùng không được xử lý đúng cách và được cung cấp cho module này, ứng dụng của bạn có thể trở nên dễ bị tấn công qua lỗ hổng bao gồm việc bao gồm tệp và duyệt thư mục. Tương tự, module vm
cung cấp các API để biên dịch và chạy mã trong ngữ cảnh Máy ảo V8. Vì nó có thể thực hiện các hành động nguy hiểm theo bản chất, nên nó nên được sử dụng trong một môi trường đóng cát.
Không công bằng khi nói rằng các hàm và module này không nên được sử dụng hoàn toàn, tuy nhiên, chúng nên được sử dụng cẩn thận đặc biệt khi sử dụng với đầu vào của người dùng. Ngoài ra, còn có some other functions có thể làm cho ứng dụng của bạn dễ bị tấn công.
Tránh xa khỏi biểu thức chính quy độc ác
Cuộc tấn công từ chối dịch vụ bằng biểu thức chính quy (ReDoS) là một cuộc tấn công từ chối dịch vụ, lợi dụng việc hầu hết các triển khai biểu thức chính quy có thể đạt đến các tình huống cực đoan gây cho họ làm việc rất chậm (tương quan mũ với kích thước đầu vào). Một kẻ tấn công có thể khiến một chương trình sử dụng biểu thức chính quy vào những tình huống cực đoan này và sau đó treo lâu rất lâu.
The Regular Expression Denial of Service (ReDoS) là một loại cuộc tấn công từ chối dịch vụ sử dụng biểu thức chính quy. Một số triển khai Biểu thức Chính quy (Regex) gây ra các tình huống cực đoan làm cho ứng dụng trở nên rất chậm. Kẻ tấn công có thể sử dụng các triển khai biểu thức chính quy như vậy để khiến ứng dụng rơi vào những tình huống cực đoan này và treo lâu rất lâu. Những biểu thức chính quy như vậy được gọi là độc ác nếu ứng dụng có thể bị kẹt trên đầu vào được tạo ra một cách cố ý. Thông thường, những biểu thức chính quy này bị khai thác bằng cách nhóm lại với sự lặp lại và sự xen kẽ. Ví dụ, biểu thức chính quy sau ^(([a-z])+.)+[A-Z]([a-z])+$
có thể được sử dụng để chỉ định tên lớp Java. Tuy nhiên, một chuỗi rất dài (aaaa…aaaaAaaaaa…aaaa) cũng có thể khớp với biểu thức chính quy này. Có một số công cụ để kiểm tra xem một biểu thức chính quy có tiềm năng gây ra sự từ chối dịch vụ hay không. Một ví dụ là vuln-regex-detector.
Chạy công cụ kiểm tra bảo mật
Khi phát triển mã, việc luôn ghi nhớ tất cả các lời khuyên về bảo mật có thể rất khó khăn. Ngoài ra, việc đảm bảo tất cả các thành viên trong nhóm tuân thủ các quy tắc này gần như là không thể. Đây là lý do tại sao có các công cụ Kiểm tra Bảo mật Phân tích Tĩnh (SAST). Các công cụ này không thực thi mã của bạn, nhưng chúng đơn giản là tìm các mẫu có thể chứa rủi ro bảo mật. Vì JavaScript là một ngôn ngữ động và không kiểu, các công cụ kiểm tra lỗi cú pháp (linting) thực sự quan trọng trong vòng đời phát triển phần mềm. Các quy tắc kiểm tra lỗi cú pháp nên được xem xét định kỳ và các phát hiện nên được kiểm tra. Một lợi ích khác của các công cụ này là tính năng bạn có thể thêm các quy tắc tùy chỉnh cho các mẫu bạn thấy nguy hiểm. ESLint và JSHint là các công cụ SAST phổ biến được sử dụng để kiểm tra lỗi cú pháp JavaScript.
Sử dụng chế độ nghiêm ngặt (strict mode)
JavaScript có một số tính năng lỗi và nguy hiểm từ quá khứ không nên sử dụng. Để loại bỏ những tính năng này, ES5 đã bao gồm chế độ nghiêm ngặt cho nhà phát triển. Với chế độ này, các lỗi trước đây không được báo lỗi sẽ bị ném ra. Nó cũng giúp các trình duyệt JavaScript thực hiện các tối ưu hóa. Với chế độ nghiêm ngặt, cú pháp không tốt trước đây sẽ gây ra lỗi thực sự. Do những cải tiến này, bạn nên luôn sử dụng chế độ nghiêm ngặt trong ứng dụng của bạn. Để kích hoạt chế độ nghiêm ngặt, bạn chỉ cần viết "use strict";
ở đầu mã của bạn.
Đoạn mã sau đây sẽ tạo ra một ReferenceError: Không thể tìm thấy biến: y
trên bảng điều khiển, mà sẽ không được hiển thị trừ khi sử dụng chế độ nghiêm ngặt:
"use strict";
func();
function func() {
y = 3.14; // This will cause an error (y is not defined)
}
Tuân theo các nguyên tắc bảo mật ứng dụng tổng quát
Danh sách này chủ yếu tập trung vào các vấn đề phổ biến trong ứng dụng Node.js, kèm theo các đề xuất và ví dụ. Ngoài những điều này, còn có các security by design principles tổng quát áp dụng cho ứng dụng web bất kể công nghệ nào được sử dụng trong máy chủ ứng dụng. Bạn cũng nên luôn nhớ các nguyên tắc đó khi phát triển ứng dụng của bạn. Bạn có thể luôn tham khảo OWASP Cheat Sheet Series để tìm hiểu thêm về các lỗ hổng ứng dụng web và các kỹ thuật giảm nhẹ được sử dụng chống lại chúng.
Tài liệu thêm về bảo mật Node.js
Awesome Node.js Security resources